Skip to main content

memra_tokenizer/
lib.rs

1//! memra-tokenizer — host-only GPT-2/BPE tokenizer (encode + decode + chat template).
2//!
3//! Algorithm TAKEn ~1:1 from llama.cpp's GPT-2 BPE path (`src/llama-vocab.cpp`,
4//! `src/unicode.cpp`), Rust glue hand-rolled. Built from the model's own GGUF
5//! tokenizer metadata (`tokenizer.ggml.*`) so it is integer-exact for that model.
6//!
7//! Scope: the `gpt2` vocab model with the `qwen35`/`qwen2`/`deepseek-v3` pre-tokenizers, plus
8//! the `gemma4` SPM-style path — see `SUPPORTED_PRETOKENIZERS`. A model declaring anything else
9//! is REFUSED at load (`UnknownPretokenizer`), because an unported pre-tokenizer produces
10//! fluent output with wrong token ids and nothing downstream can see it.
11
12pub mod chat;
13mod json;
14mod unicode;
15mod unicode_data;
16
17pub use chat::apply_chat_template_str;
18
19use memra_gguf::{GgufFile, MetaValue};
20use std::cmp::Ordering;
21use std::collections::{BinaryHeap, HashMap};
22
23/// ggml token_type values (llama.cpp `LLAMA_TOKEN_TYPE_*`).
24const TT_UNKNOWN: i64 = 2;
25const TT_CONTROL: i64 = 3;
26const TT_USER_DEFINED: i64 = 4;
27const TT_BYTE: i64 = 6;
28const QWEN35_PRETOKENIZE_REGEX: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
29/// llama.cpp `LLAMA_VOCAB_PRE_TYPE_QWEN2`. Differs from qwen35 in exactly two places —
30/// `\p{L}+` vs `[\p{L}\p{M}]+` and `[^\s\p{L}\p{N}]+` vs `[^\s\p{L}\p{M}\p{N}]+` — both of
31/// which the qwen35 state machine covers (see the `"qwen2"` arm in `PreSplit::resolve`).
32const QWEN2_PRETOKENIZE_REGEX: &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+";
33/// The three `Split` steps of the `deepseek-v3` (DEEPSEEK3_LLM) pre-tokenizer Sequence, in the
34/// order HF serializes them: `\p{N}{1,3}` digit grouping, an isolated CJK/kana pass, then the
35/// six-alternative pattern. `unicode::split_deepseek_v3` is a pass-for-pass port of exactly
36/// these three. Read off the Hy3 and Step-3.7-Flash checkpoints' own `tokenizer.json`.
37const DEEPSEEK_V3_SPLIT_REGEXES: [&str; 3] = [
38    r"\p{N}{1,3}",
39    "[\u{4e00}-\u{9fa5}\u{3040}-\u{309f}\u{30a0}-\u{30ff}]+",
40    "[!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~][A-Za-z]+|[^\r\n\\p{L}\\p{P}\\p{S}]?[\\p{L}\\p{M}]+| ?[\\p{P}\\p{S}]+[\r\n]*|\\s*[\r\n]+|\\s+(?!\\S)|\\s+",
41];
42
43/// Every `tokenizer.ggml.pre` id memra implements an EXACT split for. This is the allowlist a
44/// load is checked against and the list quoted in the load error, so the two can never drift.
45pub const SUPPORTED_PRETOKENIZERS: &[&str] = &["qwen35", "qwen2", "deepseek-v3", "gemma4"];
46
47/// Escape hatch for deliberate experimentation with a family whose pre-tokenizer is not ported
48/// yet. Set to `1` to downgrade the hard load error to a loud per-load WARN.
49pub const ALLOW_UNKNOWN_PRETOKENIZER_ENV: &str = "MEMRA_ALLOW_UNKNOWN_PRETOKENIZER";
50
51fn allow_unknown_pretokenizer() -> bool {
52    std::env::var(ALLOW_UNKNOWN_PRETOKENIZER_ENV).as_deref() == Ok("1")
53}
54
55/// A model declared a pre-tokenizer memra has no exact split for.
56///
57/// Before 2026-08-19 this was one `eprintln!` per process followed by a silent fall-through to
58/// the qwen35 split: the model loaded, generated fluent text, and every token id was wrong —
59/// the same fluent-and-invisible class as the GGUF chat-template mint trap. Wrong ids poison
60/// goldens, parity fixtures, acceptance counts and every quality number downstream, and nothing
61/// in the stack can detect it after the fact. So it is a hard load error now.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct UnknownPretokenizer {
64    /// The value that was rejected (`tokenizer.ggml.pre`, or `default` when an HF checkpoint's
65    /// pre-tokenizer regexes matched no known family).
66    pub pre: String,
67    /// True when the vocab model is SPM-style (`tokenizer.ggml.model == "gemma4"`); a `pre`/model
68    /// disagreement is itself the fault, so it is worth naming.
69    pub spm_style: bool,
70}
71
72impl std::fmt::Display for UnknownPretokenizer {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(
75            f,
76            "unsupported tokenizer.ggml.pre '{}' (vocab model is {}) — memra has no exact \
77             pre-tokenizer split for it and token ids would NOT be exact. Supported: {}. \
78             Set {}=1 to load anyway for deliberate experimentation (token ids will be wrong).",
79            self.pre,
80            if self.spm_style { "SPM/gemma4" } else { "gpt2" },
81            SUPPORTED_PRETOKENIZERS.join(", "),
82            ALLOW_UNKNOWN_PRETOKENIZER_ENV,
83        )
84    }
85}
86
87impl std::error::Error for UnknownPretokenizer {}
88
89/// The pre-tokenizer split a loaded `Tokenizer` runs. Constructed only through
90/// `PreSplit::resolve`, so "we do not know how to split for this model" is not a state a live
91/// tokenizer can be in unless the operator asked for it via the env opt-out.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum PreSplit {
94    /// `unicode::split_qwen35` — serves both `qwen35` and `qwen2`.
95    Qwen35,
96    /// `unicode::split_deepseek_v3` — DeepSeek-V3 and the Step-3.5/3.7-Flash family.
97    DeepseekV3,
98    /// gemma4 SPM-style BPE: `bpe_tokenize` splits whole lines itself and the `pre` id is never
99    /// consulted. Requires the `gemma4` vocab model, not just the `pre` string.
100    Spm,
101    /// `MEMRA_ALLOW_UNKNOWN_PRETOKENIZER=1` was set for an unrecognized `pre`. Runs the qwen35
102    /// split; token ids are NOT exact and every downstream measurement is invalid.
103    UnknownFallbackQwen35,
104}
105
106impl PreSplit {
107    /// Resolve a `tokenizer.ggml.pre` id against the implemented splits. `spm_style` is
108    /// `tokenizer.ggml.model == "gemma4"`.
109    pub fn resolve(pre: &str, spm_style: bool) -> Result<Self, UnknownPretokenizer> {
110        Self::resolve_with(pre, spm_style, allow_unknown_pretokenizer())
111    }
112
113    /// `resolve` with the env decision passed in, so tests exercise both branches without
114    /// mutating process-global environment underneath the rest of the suite.
115    fn resolve_with(
116        pre: &str,
117        spm_style: bool,
118        allow_unknown: bool,
119    ) -> Result<Self, UnknownPretokenizer> {
120        // The pair is matched, not just the `pre` string: an SPM vocab with a gpt2 `pre` (or the
121        // reverse) is a metadata disagreement, and picking either side of it silently is how a
122        // wrong split gets chosen for a right-looking model.
123        match (pre, spm_style) {
124            ("qwen35" | "qwen2", false) => Ok(PreSplit::Qwen35),
125            ("deepseek-v3", false) => Ok(PreSplit::DeepseekV3),
126            ("gemma4", true) => Ok(PreSplit::Spm),
127            _ => {
128                let err = UnknownPretokenizer {
129                    pre: pre.to_string(),
130                    spm_style,
131                };
132                if allow_unknown {
133                    // Deliberately NOT once-per-process: this prints on every load so it cannot
134                    // scroll out of one boot log and be missed on the next.
135                    eprintln!(
136                        "memra-tokenizer: WARNING {ALLOW_UNKNOWN_PRETOKENIZER_ENV}=1 — loading \
137                         with {err} FALLING BACK to the qwen35 split. Token ids are NOT exact: \
138                         goldens, parity fixtures, acceptance counts and quality numbers taken \
139                         on this model are all invalid."
140                    );
141                    Ok(PreSplit::UnknownFallbackQwen35)
142                } else {
143                    Err(err)
144                }
145            }
146        }
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum TokAttr {
152    Normal,
153    Unknown,
154    Control,
155    UserDefined,
156    Byte,
157    Other,
158}
159
160impl TokAttr {
161    fn from_toktype(t: i64) -> Self {
162        match t {
163            TT_UNKNOWN => TokAttr::Unknown,
164            TT_CONTROL => TokAttr::Control,
165            TT_USER_DEFINED => TokAttr::UserDefined,
166            TT_BYTE => TokAttr::Byte,
167            1 => TokAttr::Normal,
168            _ => TokAttr::Other,
169        }
170    }
171    /// Tokens that participate in `tokenizer_st_partition` (special-token splitting):
172    /// CONTROL | USER_DEFINED | UNKNOWN.
173    fn is_special(self) -> bool {
174        matches!(
175            self,
176            TokAttr::Control | TokAttr::UserDefined | TokAttr::Unknown
177        )
178    }
179}
180
181pub struct Tokenizer {
182    /// id -> raw vocab piece string (byte-encoded GPT-2 form, e.g. "Ġworld").
183    id_to_token: Vec<String>,
184    /// piece string -> id.
185    token_to_id: HashMap<String, u32>,
186    /// per-token attribute.
187    attrs: Vec<TokAttr>,
188    /// (left, right) merge pair -> rank (lower = higher priority).
189    bpe_ranks: HashMap<(String, String), i32>,
190    /// special-token ids, sorted by descending piece length (llama's cache order).
191    special_tokens: Vec<u32>,
192    eos_id: u32,
193    bos_id: Option<u32>,
194    add_bos: bool,
195    pre: String,
196    /// The split `pre` resolved to at load. Kept alongside the raw `pre` string so the encode
197    /// path never re-interprets metadata and has no "unknown" arm to fall through.
198    split: PreSplit,
199    chat_template: Option<String>,
200    /// SPM-style BPE (gemma4): \u2581 whitespace escaping, raw-UTF-8 merges, <0xXX> byte fallback.
201    spm_style: bool,
202}
203
204/// A bigram in the BPE work queue. Ordering matches llama.cpp's comparator:
205/// the priority_queue pops the *smallest* (rank, left) under the std comparator
206/// `l.rank > r.rank || (l.rank == r.rank && l.left > r.left)`. We implement `Ord`
207/// so a max-heap pops that same element (min rank, then min left).
208#[derive(Clone, Eq, PartialEq)]
209struct Bigram {
210    left: i32,
211    right: i32,
212    rank: i32,
213    text: String,
214}
215
216impl Ord for Bigram {
217    fn cmp(&self, other: &Self) -> Ordering {
218        // BinaryHeap is a max-heap; we want the element with the lowest rank
219        // (ties: lowest left index) to be "greatest" so it pops first.
220        match other.rank.cmp(&self.rank) {
221            Ordering::Equal => other.left.cmp(&self.left),
222            o => o,
223        }
224    }
225}
226impl PartialOrd for Bigram {
227    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
228        Some(self.cmp(other))
229    }
230}
231
232/// A symbol (one or more codepoints) in the BPE chain. Mirrors `llm_symbol`.
233struct Symbol {
234    text: String,
235    prev: i32,
236    next: i32,
237    n: usize, // codepoint count (0 == merged away)
238}
239
240impl Tokenizer {
241    /// Build a tokenizer from a model's GGUF tokenizer metadata.
242    pub fn from_gguf(g: &GgufFile) -> Result<Self, String> {
243        let model = g
244            .metadata
245            .get("tokenizer.ggml.model")
246            .and_then(|v| v.as_str())
247            .ok_or("missing tokenizer.ggml.model")?;
248        if model != "gpt2" && model != "gemma4" {
249            return Err(format!(
250                "unsupported tokenizer model '{model}' (only gpt2/gemma4)"
251            ));
252        }
253        // gemma4 = SPM-style BPE (llama-vocab.cpp): spaces escaped to \u2581 by the normalizer,
254        // merges over raw UTF-8 (NO gpt2 byte-encoding), whole-line pre-split, <0xXX> byte
255        // fallback tokens, add_bos force-true (PR #21500 workaround).
256        let spm_style = model == "gemma4";
257        let pre = g
258            .metadata
259            .get("tokenizer.ggml.pre")
260            .and_then(|v| v.as_str())
261            .unwrap_or(if spm_style { "gemma4" } else { "default" })
262            .to_string();
263        // Resolve BEFORE any of the (expensive) vocab/merge work: an unsupported pre-tokenizer
264        // is a load refusal, not a warning, so there is no reason to build the tables first.
265        let split = PreSplit::resolve(&pre, spm_style).map_err(|e| e.to_string())?;
266
267        // tokens[]
268        let tokens = match g.metadata.get("tokenizer.ggml.tokens") {
269            Some(MetaValue::Array(a)) => a,
270            _ => return Err("missing tokenizer.ggml.tokens array".into()),
271        };
272        let n = tokens.len();
273        let mut id_to_token = Vec::with_capacity(n);
274        let mut token_to_id = HashMap::with_capacity(n);
275        for (i, t) in tokens.iter().enumerate() {
276            let s = t.as_str().ok_or("non-string in tokens[]")?.to_string();
277            // first-id-wins on duplicates (llama keeps the map's first insert)
278            token_to_id.entry(s.clone()).or_insert(i as u32);
279            id_to_token.push(s);
280        }
281
282        // token_type[] -> attrs
283        let mut attrs = vec![TokAttr::Normal; n];
284        if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.token_type") {
285            for (i, v) in a.iter().enumerate().take(n) {
286                if let Some(t) = v.as_u64() {
287                    attrs[i] = TokAttr::from_toktype(t as i64);
288                } else if let MetaValue::I32(t) = v {
289                    attrs[i] = TokAttr::from_toktype(*t as i64);
290                }
291            }
292        }
293
294        // merges[] -> ranks. Each entry is "first second" (split on first space at idx>=1).
295        let mut bpe_ranks = HashMap::new();
296        if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.merges") {
297            for (i, v) in a.iter().enumerate() {
298                let word = v.as_str().ok_or("non-string in merges[]")?;
299                // llama: pos = word.find(' ', 1) — a *byte* search starting at byte 1.
300                // (The space separating the two pieces is always single-byte ASCII; the
301                // pieces themselves may contain multibyte chars like 'Ġ', so we search bytes.)
302                let bytes = word.as_bytes();
303                if let Some(pos) = bytes.iter().skip(1).position(|&b| b == b' ').map(|p| p + 1) {
304                    let first = word[..pos].to_string();
305                    let second = word[pos + 1..].to_string();
306                    bpe_ranks.insert((first, second), i as i32);
307                }
308            }
309        } else {
310            return Err("missing tokenizer.ggml.merges array".into());
311        }
312
313        // special-token cache: CONTROL|USER_DEFINED|UNKNOWN, sorted by descending text length.
314        let mut special_tokens: Vec<u32> = (0..n as u32)
315            .filter(|&id| attrs[id as usize].is_special())
316            .collect();
317        special_tokens.sort_by(|&a, &b| {
318            id_to_token[b as usize]
319                .len()
320                .cmp(&id_to_token[a as usize].len())
321        });
322
323        let eos_id = g
324            .metadata
325            .get("tokenizer.ggml.eos_token_id")
326            .and_then(|v| v.as_u64())
327            .map(|v| v as u32)
328            .ok_or("missing tokenizer.ggml.eos_token_id")?;
329        let bos_id = g
330            .metadata
331            .get("tokenizer.ggml.bos_token_id")
332            .and_then(|v| v.as_u64())
333            .map(|v| v as u32);
334        let add_bos = g
335            .metadata
336            .get("tokenizer.ggml.add_bos_token")
337            .and_then(|v| match v {
338                MetaValue::Bool(b) => Some(*b),
339                _ => v.as_u64().map(|x| x != 0),
340            })
341            .unwrap_or(false);
342        let add_bos = add_bos || spm_style;
343
344        let chat_template = g
345            .metadata
346            .get("tokenizer.chat_template")
347            .and_then(|v| v.as_str())
348            .map(|s| s.to_string());
349
350        Ok(Tokenizer {
351            id_to_token,
352            token_to_id,
353            attrs,
354            bpe_ranks,
355            special_tokens,
356            eos_id,
357            bos_id,
358            add_bos,
359            pre,
360            split,
361            chat_template,
362            spm_style,
363        })
364    }
365
366    /// Build a tokenizer from an HF fast-tokenizer checkpoint directory
367    /// (`tokenizer.json` + optional `tokenizer_config.json` / `generation_config.json` /
368    /// `chat_template.jinja`). Only byte-level BPE (the gpt2 class — MiniMax-M3, Qwen,
369    /// Llama-3 style) is supported: `model.type == "BPE"` with a ByteLevel pre-tokenizer.
370    ///
371    /// Mapping to the GGUF-built struct:
372    ///   - model.vocab (token -> id map)             -> id_to_token / token_to_id
373    ///   - model.merges ("a b" strings OR [a,b] pairs; both HF serializations) -> bpe_ranks
374    ///   - added_tokens special=true -> Control class (split before BPE + hidden on decode);
375    ///     non-special added tokens stay Normal.
376    ///   - eos/bos: tokenizer_config eos_token/bos_token (string or {content} object),
377    ///     generation_config eos_token_id (int or array) as the eos fallback.
378    ///   - add_bos: tokenizer_config add_bos_token (default false).
379    ///   - chat template: tokenizer_config chat_template, else chat_template.jinja.
380    ///   - pre-tokenizer: `tokenizer_config.pretokenize_regex`, else the `Split` step regexes of
381    ///     `tokenizer.json`'s own `pre_tokenizer`, matched byte-exactly against the qwen35 /
382    ///     qwen2 / deepseek-v3 constants. No match -> a hard error naming both observations.
383    pub fn from_hf_dir(dir: &std::path::Path) -> Result<Self, String> {
384        let tj_path = dir.join("tokenizer.json");
385        let text = std::fs::read_to_string(&tj_path)
386            .map_err(|e| format!("read {}: {e}", tj_path.display()))?;
387        let tj = json::parse(&text).map_err(|e| format!("{}: {e}", tj_path.display()))?;
388
389        let model = tj.get("model").ok_or("tokenizer.json: missing model")?;
390        if let Some(t) = model.get("type").and_then(|v| v.as_str()) {
391            if t != "BPE" {
392                return Err(format!(
393                    "unsupported tokenizer.json model type '{t}' (only BPE)"
394                ));
395            }
396        }
397        // byte-level check: pre_tokenizer.type == ByteLevel (possibly inside a Sequence).
398        let pre_tok = tj
399            .get("pre_tokenizer")
400            .ok_or("tokenizer.json: missing pre_tokenizer")?;
401        if !pre_tokenizer_is_byte_level(pre_tok) {
402            return Err(
403                "tokenizer.json: pre_tokenizer is not ByteLevel — only byte-level \
404                        BPE is supported"
405                    .into(),
406            );
407        }
408
409        // ---- vocab (token -> id). ids may exceed the map len (added_tokens append). ----
410        let vocab = model
411            .get("vocab")
412            .and_then(|v| v.as_obj())
413            .ok_or("tokenizer.json: missing model.vocab")?;
414        let empty: Vec<json::Value> = Vec::new();
415        let added = tj
416            .get("added_tokens")
417            .and_then(|v| v.as_arr())
418            .unwrap_or(&empty);
419        let mut max_id = 0u32;
420        for v in vocab.values() {
421            let id =
422                v.as_u64()
423                    .ok_or("tokenizer.json: non-integer id in model.vocab")? as u32;
424            max_id = max_id.max(id);
425        }
426        for a in added {
427            if let Some(id) = a.get("id").and_then(|v| v.as_u64()) {
428                max_id = max_id.max(id as u32);
429            }
430        }
431        let n = max_id as usize + 1;
432        let mut id_to_token = vec![String::new(); n];
433        let mut token_to_id: HashMap<String, u32> = HashMap::with_capacity(n);
434        let mut attrs = vec![TokAttr::Normal; n];
435        for (tok, v) in vocab {
436            let id = v.as_u64().unwrap() as u32;
437            id_to_token[id as usize] = tok.clone();
438            token_to_id.entry(tok.clone()).or_insert(id);
439        }
440        // added_tokens: register content + special flag. special=true -> Control (the class
441        // that is split out before BPE and hidden by decode_special(.., false)).
442        for a in added {
443            let id =
444                a.get("id")
445                    .and_then(|v| v.as_u64())
446                    .ok_or("tokenizer.json: added_tokens entry missing id")? as u32;
447            let content = a
448                .get("content")
449                .and_then(|v| v.as_str())
450                .ok_or("tokenizer.json: added_tokens entry missing content")?;
451            if id_to_token[id as usize].is_empty() {
452                id_to_token[id as usize] = content.to_string();
453            }
454            token_to_id.entry(content.to_string()).or_insert(id);
455            if a.get("special").and_then(|v| v.as_bool()).unwrap_or(false) {
456                attrs[id as usize] = TokAttr::Control;
457            } else {
458                // HF's AddedVocabulary matches EVERY added token whole (special or not) before
459                // the BPE model runs; `special` only controls skip_special_tokens on decode.
460                // UserDefined = split whole before BPE but NOT hidden on decode — exactly the
461                // HF non-special class (Hy3's `<think:opensource>`/`<|reasoning_mode…|>` chat
462                // tokens are special=false and MUST encode as single ids, 2026-07-09).
463                attrs[id as usize] = TokAttr::UserDefined;
464            }
465        }
466
467        // ---- merges: array of "a b" strings OR [a, b] pairs (HF emits both). ----
468        let merges = model
469            .get("merges")
470            .and_then(|v| v.as_arr())
471            .ok_or("tokenizer.json: missing model.merges")?;
472        let mut bpe_ranks = HashMap::with_capacity(merges.len());
473        for (i, m) in merges.iter().enumerate() {
474            let (first, second) = match m {
475                json::Value::Str(s) => {
476                    // byte search for the separating space from byte 1 (same as the GGUF
477                    // path: pieces may contain multibyte chars like 'Ġ', the space is ASCII).
478                    let bytes = s.as_bytes();
479                    let pos = bytes
480                        .iter()
481                        .skip(1)
482                        .position(|&b| b == b' ')
483                        .map(|p| p + 1)
484                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] has no space"))?;
485                    (s[..pos].to_string(), s[pos + 1..].to_string())
486                }
487                json::Value::Arr(a) if a.len() == 2 => {
488                    let f = a[0]
489                        .as_str()
490                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
491                    let s2 = a[1]
492                        .as_str()
493                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
494                    (f.to_string(), s2.to_string())
495                }
496                _ => {
497                    return Err(format!(
498                        "tokenizer.json: merges[{i}] is neither \"a b\" string nor [a, b] pair"
499                    ));
500                }
501            };
502            bpe_ranks.insert((first, second), i as i32);
503        }
504
505        // special-token cache: same construction as from_gguf.
506        let mut special_tokens: Vec<u32> = (0..n as u32)
507            .filter(|&id| attrs[id as usize].is_special())
508            .collect();
509        special_tokens.sort_by(|&a, &b| {
510            id_to_token[b as usize]
511                .len()
512                .cmp(&id_to_token[a as usize].len())
513        });
514
515        // ---- sidecars: tokenizer_config.json + generation_config.json ----
516        let tc = std::fs::read_to_string(dir.join("tokenizer_config.json"))
517            .ok()
518            .and_then(|t| json::parse(&t).ok());
519        let gc = std::fs::read_to_string(dir.join("generation_config.json"))
520            .ok()
521            .and_then(|t| json::parse(&t).ok());
522
523        // eos_token/bos_token: plain string OR {"content": "..."} AddedToken object.
524        let tok_content = |v: &json::Value| -> Option<String> {
525            v.as_str().map(|s| s.to_string()).or_else(|| {
526                v.get("content")
527                    .and_then(|c| c.as_str())
528                    .map(|s| s.to_string())
529            })
530        };
531        let eos_from_cfg = tc
532            .as_ref()
533            .and_then(|c| c.get("eos_token"))
534            .and_then(&tok_content)
535            .and_then(|s| token_to_id.get(&s).copied());
536        // generation_config eos_token_id: int or array of ints (first entry wins).
537        let eos_from_gen = gc
538            .as_ref()
539            .and_then(|c| c.get("eos_token_id"))
540            .and_then(|v| match v {
541                json::Value::Num(_) => v.as_u64(),
542                json::Value::Arr(a) => a.first().and_then(|x| x.as_u64()),
543                _ => None,
544            })
545            .map(|v| v as u32);
546        let eos_id = eos_from_cfg.or(eos_from_gen).ok_or(
547            "no eos token: need tokenizer_config.json eos_token or \
548             generation_config.json eos_token_id",
549        )?;
550        let bos_id = tc
551            .as_ref()
552            .and_then(|c| c.get("bos_token"))
553            .and_then(&tok_content)
554            .and_then(|s| token_to_id.get(&s).copied());
555        let add_bos = tc
556            .as_ref()
557            .and_then(|c| c.get("add_bos_token"))
558            .and_then(|v| v.as_bool())
559            .unwrap_or(false);
560
561        // chat template: tokenizer_config chat_template string, else chat_template.jinja file.
562        let chat_template = tc
563            .as_ref()
564            .and_then(|c| c.get("chat_template"))
565            .and_then(|v| v.as_str())
566            .map(|s| s.to_string())
567            .or_else(|| std::fs::read_to_string(dir.join("chat_template.jinja")).ok());
568        // Pre-tokenizer identification, in order of authority:
569        //   1. `tokenizer_config.json`'s `pretokenize_regex` (Qwen ships it explicitly), and
570        //   2. the `Split` step regexes of `tokenizer.json`'s own `pre_tokenizer`.
571        // (2) was missing until 2026-08-19, so ONLY Qwen checkpoints could ever be identified
572        // here and everything else fell to `default` -> the silent qwen35 fallback. The Hy3
573        // checkpoints were being mis-tokenized that way while `split_deepseek_v3` — the exact
574        // splitter their own tokenizer.json asks for — already shipped in this crate.
575        let cfg_regex = tc
576            .as_ref()
577            .and_then(|c| c.get("pretokenize_regex"))
578            .and_then(|v| v.as_str());
579        let mut tj_regexes: Vec<String> = Vec::new();
580        collect_split_regexes(pre_tok, &mut tj_regexes);
581        let pre = cfg_regex
582            .and_then(|r| pre_from_split_regexes(std::slice::from_ref(&r.to_string())))
583            .or_else(|| pre_from_split_regexes(&tj_regexes))
584            .unwrap_or("default");
585        let split = PreSplit::resolve(pre, false).map_err(|e| {
586            if pre == "default" {
587                format!(
588                    "{e}\n  (HF checkpoint {}: tokenizer_config.json pretokenize_regex = {:?}, \
589                     tokenizer.json pre_tokenizer Split regexes = {:?} — neither matched a known \
590                     family)",
591                    dir.display(),
592                    cfg_regex,
593                    tj_regexes,
594                )
595            } else {
596                e.to_string()
597            }
598        })?;
599
600        Ok(Tokenizer {
601            id_to_token,
602            token_to_id,
603            attrs,
604            bpe_ranks,
605            special_tokens,
606            eos_id,
607            bos_id,
608            add_bos,
609            pre: pre.to_string(),
610            split,
611            chat_template,
612            spm_style: false,
613        })
614    }
615
616    pub fn eos_id(&self) -> u32 {
617        self.eos_id
618    }
619    /// Exact-piece id lookup (vision special tokens etc.). None = not in the vocab.
620    pub fn id_of(&self, piece: &str) -> Option<u32> {
621        self.token_to_id.get(piece).copied()
622    }
623    /// End-of-generation ids: eos + the common turn-end control tokens present in the vocab
624    /// (llama's special_eog set — <|im_end|> chatml, <turn|>/<end_of_turn> gemma).
625    pub fn eog_ids(&self) -> Vec<u32> {
626        let mut ids = vec![self.eos_id];
627        for t in ["<|im_end|>", "<turn|>", "<end_of_turn>"] {
628            if let Some(&id) = self.token_to_id.get(t) {
629                if !ids.contains(&id) {
630                    ids.push(id);
631                }
632            }
633        }
634        ids
635    }
636    pub fn bos_id(&self) -> Option<u32> {
637        self.bos_id
638    }
639    pub fn vocab_size(&self) -> usize {
640        self.id_to_token.len()
641    }
642    pub fn pre(&self) -> &str {
643        &self.pre
644    }
645    /// The split `pre` resolved to. `UnknownFallbackQwen35` means the env opt-out is engaged and
646    /// this tokenizer's ids are NOT exact — a serve gate can refuse on it.
647    pub fn split(&self) -> PreSplit {
648        self.split
649    }
650    pub fn chat_template(&self) -> Option<&str> {
651        self.chat_template.as_deref()
652    }
653
654    #[inline]
655    fn text_to_token(&self, s: &str) -> Option<u32> {
656        self.token_to_id.get(s).copied()
657    }
658
659    fn find_bpe_rank(&self, left: &str, right: &str) -> i32 {
660        self.bpe_ranks
661            .get(&(left.to_string(), right.to_string()))
662            .copied()
663            .unwrap_or(-1)
664    }
665
666    /// Encode text -> token ids.
667    ///
668    /// `add_special` controls whether a BOS is prepended when the model asks for it.
669    /// `parse_special` (always true here) splits control/user-defined/unknown tokens
670    /// (e.g. `<|im_start|>`) out before BPE — matching llama's default tokenize().
671    pub fn encode(&self, text: &str, add_special: bool) -> Vec<u32> {
672        self.encode_special(text, add_special, true)
673    }
674
675    pub fn encode_special(&self, text: &str, add_special: bool, parse_special: bool) -> Vec<u32> {
676        let mut output: Vec<u32> = Vec::new();
677        if add_special && self.add_bos {
678            if let Some(b) = self.bos_id {
679                output.push(b);
680            }
681        }
682        if text.is_empty() {
683            return output;
684        }
685
686        // fragment buffer: alternate raw-text spans and resolved special-token ids.
687        for frag in self.st_partition(text, parse_special) {
688            match frag {
689                Fragment::Token(id) => output.push(id),
690                Fragment::Text(span) => self.bpe_tokenize(&span, &mut output),
691            }
692        }
693        output
694    }
695
696    /// `tokenizer_st_partition` — split out special tokens (longest first) before BPE.
697    fn st_partition(&self, text: &str, parse_special: bool) -> Vec<Fragment> {
698        let mut frags = vec![Fragment::Text(text.to_string())];
699        for &sid in &self.special_tokens {
700            let attr = self.attrs[sid as usize];
701            // when parse_special is false, skip CONTROL/UNKNOWN (user-defined still split).
702            if !parse_special && matches!(attr, TokAttr::Control | TokAttr::Unknown) {
703                continue;
704            }
705            let needle = &self.id_to_token[sid as usize];
706            if needle.is_empty() {
707                continue;
708            }
709            let mut next: Vec<Fragment> = Vec::with_capacity(frags.len());
710            for f in frags.drain(..) {
711                match f {
712                    Fragment::Token(id) => next.push(Fragment::Token(id)),
713                    Fragment::Text(s) => {
714                        let mut rest: &str = &s;
715                        let mut acc = String::new();
716                        while let Some(m) = rest.find(needle.as_str()) {
717                            acc.push_str(&rest[..m]);
718                            if !acc.is_empty() {
719                                next.push(Fragment::Text(std::mem::take(&mut acc)));
720                            }
721                            next.push(Fragment::Token(sid));
722                            rest = &rest[m + needle.len()..];
723                        }
724                        acc.push_str(rest);
725                        if !acc.is_empty() {
726                            next.push(Fragment::Text(acc));
727                        }
728                    }
729                }
730            }
731            frags = next;
732        }
733        frags
734    }
735
736    /// Core BPE over one raw-text fragment (`llm_tokenizer_bpe_session::tokenize`).
737    fn bpe_tokenize(&self, text: &str, output: &mut Vec<u32>) {
738        if self.spm_style {
739            // gemma4 (llama PRE_TYPE_GEMMA4): escape spaces to \u2581 on the raw fragment,
740            // split whole lines ([^\n]+|[\n]+), run BPE on raw UTF-8 chars.
741            let escaped: String = text
742                .chars()
743                .map(|c| if c == ' ' { '\u{2581}' } else { c })
744                .collect();
745            let mut words: Vec<String> = Vec::new();
746            let mut cur = String::new();
747            let mut cur_nl: Option<bool> = None;
748            for c in escaped.chars() {
749                let nl = c == '\n';
750                if cur_nl != Some(nl) && !cur.is_empty() {
751                    words.push(std::mem::take(&mut cur));
752                }
753                cur_nl = Some(nl);
754                cur.push(c);
755            }
756            if !cur.is_empty() {
757                words.push(cur);
758            }
759            for word in &words {
760                // newline-run fix (llama PR #21343): whole-word vocab hit short-circuits BPE.
761                if word.chars().all(|c| c == '\n') {
762                    if let Some(tok) = self.text_to_token(word) {
763                        output.push(tok);
764                        continue;
765                    }
766                }
767                self.bpe_merge_word(word, output);
768            }
769            return;
770        }
771        // 1) pre-tokenizer split, then 2) GPT-2 byte-encode each word.
772        //
773        // Exhaustive on `PreSplit` and has NO fall-through arm: the "we do not know how to split
774        // this" case was resolved (and refused) at load, so it cannot arrive here. Adding a
775        // `PreSplit` variant must fail to compile until this match handles it.
776        let words: Vec<String> = match self.split {
777            // qwen35 also serves qwen2: llama.cpp's qwen2 regex differs from qwen35's only in
778            // [\p{L}\p{M}]+ vs \p{L}+, which the qwen35 state machine covers.
779            PreSplit::Qwen35 => unicode::split_qwen35(text),
780            // Step-3.5/3.7-Flash and the DeepSeek-V3 family
781            // (llama.cpp LLAMA_VOCAB_PRE_TYPE_DEEPSEEK3_LLM). Materially different from qwen2:
782            // \p{N}{1,3} digit grouping, an isolated CJK/kana pass, and \p{P}/\p{S}-only runs.
783            PreSplit::DeepseekV3 => unicode::split_deepseek_v3(text),
784            // MEMRA_ALLOW_UNKNOWN_PRETOKENIZER=1 — the operator asked for wrong ids. The WARN
785            // was printed at load; do not repeat it once per fragment.
786            PreSplit::UnknownFallbackQwen35 => unicode::split_qwen35(text),
787            // Unreachable: `spm_style` short-circuits above, and `PreSplit::Spm` is only
788            // produced together with it.
789            PreSplit::Spm => unreachable!("PreSplit::Spm implies spm_style, handled above"),
790        };
791
792        for word in &words {
793            let word = unicode::byte_encode(word);
794            self.bpe_merge_word(&word, output);
795        }
796    }
797
798    /// BPE merge over one pre-split word (symbols = unicode chars), emitting token ids with
799    /// byte fallback (gpt2 single-char byte tokens, or SPM <0xXX> tokens when spm_style).
800    fn bpe_merge_word(&self, word: &str, output: &mut Vec<u32>) {
801        {
802            let word = word.to_string();
803
804            // build the symbol chain, one symbol per unicode char initially.
805            let chars: Vec<char> = word.chars().collect();
806            let mut symbols: Vec<Symbol> = Vec::with_capacity(chars.len());
807            for (i, &c) in chars.iter().enumerate() {
808                symbols.push(Symbol {
809                    text: c.to_string(),
810                    prev: i as i32 - 1,
811                    next: if i + 1 == chars.len() {
812                        -1
813                    } else {
814                        i as i32 + 1
815                    },
816                    n: 1,
817                });
818            }
819
820            // seed the work queue with adjacent bigrams.
821            let mut queue: BinaryHeap<Bigram> = BinaryHeap::new();
822            for i in 1..symbols.len() {
823                self.add_bigram(&symbols, i as i32 - 1, i as i32, &mut queue);
824            }
825
826            // merge by rank.
827            while let Some(bigram) = queue.pop() {
828                let li = bigram.left as usize;
829                let ri = bigram.right as usize;
830                if symbols[li].n == 0 || symbols[ri].n == 0 {
831                    continue;
832                }
833                let combined = format!("{}{}", symbols[li].text, symbols[ri].text);
834                if combined != bigram.text {
835                    continue; // outdated bigram
836                }
837                // merge right into left
838                symbols[li].text = combined;
839                symbols[li].n += symbols[ri].n;
840                symbols[ri].n = 0;
841                let r_next = symbols[ri].next;
842                symbols[li].next = r_next;
843                if r_next >= 0 {
844                    symbols[r_next as usize].prev = bigram.left;
845                }
846                let l_prev = symbols[li].prev;
847                let l_next = symbols[li].next;
848                self.add_bigram(&symbols, l_prev, bigram.left, &mut queue);
849                self.add_bigram(&symbols, bigram.left, l_next, &mut queue);
850            }
851
852            // emit final symbols in chain order, with byte-level fallback.
853            for sym in &symbols {
854                if sym.n == 0 {
855                    continue;
856                }
857                match self.text_to_token(&sym.text) {
858                    Some(tok) => output.push(tok),
859                    None => {
860                        // byte fallback: each *byte* of the piece must be its own token.
861                        for b in sym.text.bytes() {
862                            let bs = if self.spm_style {
863                                format!("<0x{b:02X}>") // SPM-style byte tokens (gemma4)
864                            } else {
865                                (b as char).to_string()
866                            };
867                            if let Some(t) = self.text_to_token(&bs) {
868                                output.push(t);
869                            }
870                        }
871                    }
872                }
873            }
874        }
875    }
876
877    fn add_bigram(
878        &self,
879        symbols: &[Symbol],
880        left: i32,
881        right: i32,
882        queue: &mut BinaryHeap<Bigram>,
883    ) {
884        if left == -1 || right == -1 {
885            return;
886        }
887        let lt = &symbols[left as usize].text;
888        let rt = &symbols[right as usize].text;
889        let rank = self.find_bpe_rank(lt, rt);
890        if rank < 0 {
891            return;
892        }
893        queue.push(Bigram {
894            left,
895            right,
896            rank,
897            text: format!("{lt}{rt}"),
898        });
899    }
900
901    /// Decode token ids -> String. `special=false` drops control tokens (chat tags);
902    /// `special=true` renders them as their literal text.
903    pub fn decode(&self, ids: &[u32]) -> String {
904        self.decode_special(ids, true)
905    }
906
907    /// True for Control/Unknown tokens — vocab entries that are protocol markers, not text.
908    /// External vocab consumers (llguidance's toktrie, constrained decoding) must not let a
909    /// grammar match these as literal bytes (a JSON string could otherwise smuggle
910    /// `<|im_start|>`); they substitute a non-text marker form instead.
911    pub fn token_is_control(&self, id: u32) -> bool {
912        match self.attrs.get(id as usize) {
913            Some(TokAttr::Control) | Some(TokAttr::Unknown) => true,
914            _ => false,
915        }
916    }
917
918    pub fn decode_special(&self, ids: &[u32], special: bool) -> String {
919        String::from_utf8_lossy(&self.decode_bytes_special(ids, special)).into_owned()
920    }
921
922    /// Decode token ids to their exact byte stream. Streaming callers must retain incomplete
923    /// UTF-8 suffixes across token boundaries instead of replacing them prematurely.
924    pub fn decode_bytes_special(&self, ids: &[u32], special: bool) -> Vec<u8> {
925        let mut bytes: Vec<u8> = Vec::new();
926        for &id in ids {
927            let i = id as usize;
928            if i >= self.id_to_token.len() {
929                continue;
930            }
931            let attr = self.attrs[i];
932            let piece = &self.id_to_token[i];
933            match attr {
934                TokAttr::Normal | TokAttr::Byte => {
935                    if self.spm_style {
936                        // gemma4: <0xXX> byte tokens -> raw byte; else unescape \u2581 -> space.
937                        if matches!(attr, TokAttr::Byte)
938                            || (piece.len() == 6
939                                && piece.starts_with("<0x")
940                                && piece.ends_with('>'))
941                        {
942                            if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
943                                bytes.push(b);
944                                continue;
945                            }
946                        }
947                        for c in piece.chars() {
948                            if c == '\u{2581}' {
949                                bytes.push(b' ');
950                            } else {
951                                let mut buf = [0u8; 4];
952                                bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
953                            }
954                        }
955                    } else {
956                        // undo GPT-2 byte encoding: each char -> one raw byte.
957                        self.piece_to_bytes(piece, &mut bytes);
958                    }
959                }
960                TokAttr::UserDefined => {
961                    // user-defined tokens are literal text (not byte-encoded).
962                    bytes.extend_from_slice(piece.as_bytes());
963                }
964                TokAttr::Control | TokAttr::Unknown => {
965                    if special {
966                        bytes.extend_from_slice(piece.as_bytes());
967                    }
968                    // else: render nothing
969                }
970                TokAttr::Other => {}
971            }
972        }
973        bytes
974    }
975
976    fn piece_to_bytes(&self, piece: &str, out: &mut Vec<u8>) {
977        for c in piece.chars() {
978            match unicode::unicode_to_byte(c) {
979                Some(b) => out.push(b),
980                None => {
981                    // not in the byte map — emit the char's utf-8 bytes verbatim.
982                    let mut buf = [0u8; 4];
983                    out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
984                }
985            }
986        }
987    }
988
989    /// Apply the chat template (from GGUF, or a chatml fallback) to a list of
990    /// (role, content) turns, producing the prompt string. Then `encode` it.
991    pub fn apply_chat_template(
992        &self,
993        messages: &[(&str, &str)],
994        add_generation_prompt: bool,
995    ) -> String {
996        chat::apply_chat_template_str(
997            self.chat_template.as_deref(),
998            messages,
999            add_generation_prompt,
1000        )
1001    }
1002
1003    /// Tools-capable chat rendering (OpenAI `tools` / `tool_calls` / role:"tool" surface +
1004    /// the think-tail switch + the step35 `reasoning_effort` string). Plain requests render
1005    /// byte-identically to `apply_chat_template`; see `chat::apply_chat_template_tools`.
1006    pub fn apply_chat_template_tools(
1007        &self,
1008        turns: &[chat::Turn],
1009        add_generation_prompt: bool,
1010        tools_json: &[String],
1011        think: chat::ThinkMode,
1012        reasoning_effort: Option<&str>,
1013    ) -> Result<String, String> {
1014        chat::apply_chat_template_tools(
1015            self.chat_template.as_deref(),
1016            turns,
1017            add_generation_prompt,
1018            tools_json,
1019            think,
1020            reasoning_effort,
1021        )
1022    }
1023
1024    /// `apply_chat_template_tools` plus the gemma4 arm's structured tool `function` objects
1025    /// (`tools_struct`). The serve path uses this so gemma4 tool DEFINITIONS render into the
1026    /// tooluse dialect; every non-gemma dialect ignores `tools_struct`.
1027    #[allow(clippy::too_many_arguments)]
1028    pub fn apply_chat_template_tools_ex(
1029        &self,
1030        turns: &[chat::Turn],
1031        add_generation_prompt: bool,
1032        tools_json: &[String],
1033        tools_struct: &[chat::Val],
1034        think: chat::ThinkMode,
1035        reasoning_effort: Option<&str>,
1036    ) -> Result<String, String> {
1037        chat::apply_chat_template_tools_ex(
1038            self.chat_template.as_deref(),
1039            turns,
1040            add_generation_prompt,
1041            tools_json,
1042            tools_struct,
1043            think,
1044            reasoning_effort,
1045        )
1046    }
1047}
1048
1049enum Fragment {
1050    Text(String),
1051    Token(u32),
1052}
1053
1054/// True when an HF `pre_tokenizer` object is byte-level BPE: type == "ByteLevel", or a
1055/// "Sequence" whose pretokenizers include a ByteLevel step (the common Split+ByteLevel combo).
1056/// Collect the regexes of every `Split` step in an HF `pre_tokenizer`, in serialization order.
1057/// A `Sequence` is walked depth-first; non-`Split` steps (ByteLevel, Digits, …) contribute
1058/// nothing. `{"pattern": {"String": …}}` is not a regex and is skipped.
1059fn collect_split_regexes(pt: &json::Value, out: &mut Vec<String>) {
1060    match pt.get("type").and_then(|v| v.as_str()) {
1061        Some("Sequence") => {
1062            if let Some(arr) = pt.get("pretokenizers").and_then(|v| v.as_arr()) {
1063                for step in arr {
1064                    collect_split_regexes(step, out);
1065                }
1066            }
1067        }
1068        Some("Split") => {
1069            if let Some(r) = pt
1070                .get("pattern")
1071                .and_then(|p| p.get("Regex"))
1072                .and_then(|v| v.as_str())
1073            {
1074                out.push(r.to_string());
1075            }
1076        }
1077        _ => {}
1078    }
1079}
1080
1081/// Map an ordered set of pre-tokenizer split regexes onto a `tokenizer.ggml.pre` id.
1082/// Byte-exact comparison against the shipped constants — a near-match is a different splitter
1083/// (qwen2 vs qwen35 differ by two character classes and produce different ids on marks), so
1084/// there is deliberately no fuzzy path. `None` = no known family.
1085fn pre_from_split_regexes(regexes: &[String]) -> Option<&'static str> {
1086    match regexes {
1087        [one] if one == QWEN35_PRETOKENIZE_REGEX => Some("qwen35"),
1088        [one] if one == QWEN2_PRETOKENIZE_REGEX => Some("qwen2"),
1089        [a, b, c]
1090            if a == DEEPSEEK_V3_SPLIT_REGEXES[0]
1091                && b == DEEPSEEK_V3_SPLIT_REGEXES[1]
1092                && c == DEEPSEEK_V3_SPLIT_REGEXES[2] =>
1093        {
1094            Some("deepseek-v3")
1095        }
1096        _ => None,
1097    }
1098}
1099
1100fn pre_tokenizer_is_byte_level(pt: &json::Value) -> bool {
1101    match pt.get("type").and_then(|v| v.as_str()) {
1102        Some("ByteLevel") => true,
1103        Some("Sequence") => pt
1104            .get("pretokenizers")
1105            .and_then(|v| v.as_arr())
1106            .map(|arr| arr.iter().any(pre_tokenizer_is_byte_level))
1107            .unwrap_or(false),
1108        _ => false,
1109    }
1110}
1111
1112#[cfg(test)]
1113mod pretokenizer_tests {
1114    use super::*;
1115
1116    /// Every id on the shipped allowlist still resolves — the regression guard for the flip from
1117    /// warn-and-fall-through to hard-refuse. `gemma4` is the SPM path and pairs with the gemma4
1118    /// vocab model; the other three are gpt2-vocab splits.
1119    #[test]
1120    fn every_supported_pre_resolves() {
1121        assert_eq!(
1122            PreSplit::resolve_with("qwen35", false, false),
1123            Ok(PreSplit::Qwen35)
1124        );
1125        assert_eq!(
1126            PreSplit::resolve_with("qwen2", false, false),
1127            Ok(PreSplit::Qwen35)
1128        );
1129        assert_eq!(
1130            PreSplit::resolve_with("deepseek-v3", false, false),
1131            Ok(PreSplit::DeepseekV3)
1132        );
1133        assert_eq!(
1134            PreSplit::resolve_with("gemma4", true, false),
1135            Ok(PreSplit::Spm)
1136        );
1137        // and the allowlist constant is exactly that set, so the error text cannot drift from
1138        // what the code accepts
1139        assert_eq!(
1140            SUPPORTED_PRETOKENIZERS,
1141            &["qwen35", "qwen2", "deepseek-v3", "gemma4"]
1142        );
1143    }
1144
1145    /// An unknown `pre` is a typed error, not a warning and not a wrong split.
1146    #[test]
1147    fn unknown_pre_is_a_typed_error() {
1148        let err =
1149            PreSplit::resolve_with("llama4", false, false).expect_err("llama4 has no ported split");
1150        assert_eq!(
1151            err,
1152            UnknownPretokenizer {
1153                pre: "llama4".into(),
1154                spm_style: false
1155            }
1156        );
1157        let msg = err.to_string();
1158        // names the offending value, lists what IS supported, and points at the opt-out
1159        assert!(msg.contains("'llama4'"), "{msg}");
1160        for supported in SUPPORTED_PRETOKENIZERS {
1161            assert!(
1162                msg.contains(supported),
1163                "error must list {supported}: {msg}"
1164            );
1165        }
1166        assert!(msg.contains(ALLOW_UNKNOWN_PRETOKENIZER_ENV), "{msg}");
1167        // and it is a real std::error::Error, so `?` from a loader keeps the type
1168        let _: &dyn std::error::Error = &err;
1169    }
1170
1171    /// A `pre`/vocab-model disagreement is its own fault: an SPM vocab with a gpt2 `pre`, or a
1172    /// gpt2 vocab claiming the gemma4 SPM pre, must not silently pick one side.
1173    #[test]
1174    fn pre_and_vocab_model_must_agree() {
1175        assert!(PreSplit::resolve_with("qwen35", true, false).is_err());
1176        assert!(PreSplit::resolve_with("gemma4", false, false).is_err());
1177        // the historical GGUF/HF sentinels for "no pre declared" are refusals, not qwen35
1178        assert!(PreSplit::resolve_with("default", false, false).is_err());
1179        assert!(PreSplit::resolve_with("", false, false).is_err());
1180    }
1181
1182    /// The opt-out loads, and it declares itself in the resolved split so a gate can refuse it.
1183    #[test]
1184    fn opt_out_loads_with_a_fallback_marker() {
1185        assert_eq!(
1186            PreSplit::resolve_with("llama4", false, true),
1187            Ok(PreSplit::UnknownFallbackQwen35)
1188        );
1189        // ... including for an SPM-model disagreement
1190        assert_eq!(
1191            PreSplit::resolve_with("qwen35", true, true),
1192            Ok(PreSplit::UnknownFallbackQwen35)
1193        );
1194    }
1195
1196    /// The env name is the one documented, and only an exact `1` engages it (so a stale
1197    /// `=0`/`=false` in a launcher does not silently turn wrong ids back on).
1198    #[test]
1199    fn opt_out_env_gate() {
1200        // SAFETY: single-threaded within this test; no other test reads this variable, and the
1201        // resolve paths every other test uses take the decision as a parameter.
1202        unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
1203        assert!(!allow_unknown_pretokenizer());
1204        unsafe { std::env::set_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV, "0") };
1205        assert!(!allow_unknown_pretokenizer());
1206        unsafe { std::env::set_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV, "1") };
1207        assert!(allow_unknown_pretokenizer());
1208        assert_eq!(
1209            PreSplit::resolve("llama4", false),
1210            Ok(PreSplit::UnknownFallbackQwen35)
1211        );
1212        unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
1213        assert!(PreSplit::resolve("llama4", false).is_err());
1214    }
1215
1216    /// Regex identification is byte-exact and order-sensitive: a near-miss is a DIFFERENT
1217    /// splitter, and a partial deepseek Sequence is not the deepseek Sequence.
1218    #[test]
1219    fn split_regex_identification_is_exact() {
1220        let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1221        assert_eq!(
1222            pre_from_split_regexes(&s(&[QWEN35_PRETOKENIZE_REGEX])),
1223            Some("qwen35")
1224        );
1225        assert_eq!(
1226            pre_from_split_regexes(&s(&[QWEN2_PRETOKENIZE_REGEX])),
1227            Some("qwen2")
1228        );
1229        assert_eq!(
1230            pre_from_split_regexes(&s(&DEEPSEEK_V3_SPLIT_REGEXES)),
1231            Some("deepseek-v3")
1232        );
1233        // order matters
1234        assert_eq!(
1235            pre_from_split_regexes(&s(&[
1236                DEEPSEEK_V3_SPLIT_REGEXES[1],
1237                DEEPSEEK_V3_SPLIT_REGEXES[0],
1238                DEEPSEEK_V3_SPLIT_REGEXES[2],
1239            ])),
1240            None
1241        );
1242        // a truncated Sequence is not the family
1243        assert_eq!(
1244            pre_from_split_regexes(&s(&[
1245                DEEPSEEK_V3_SPLIT_REGEXES[0],
1246                DEEPSEEK_V3_SPLIT_REGEXES[1]
1247            ])),
1248            None
1249        );
1250        // one character off is a different splitter
1251        let mut near = QWEN35_PRETOKENIZE_REGEX.to_string();
1252        near.push('x');
1253        assert_eq!(pre_from_split_regexes(&s(&[&near])), None);
1254        assert_eq!(pre_from_split_regexes(&[]), None);
1255        // qwen2 and qwen35 are NOT the same string (the two-class delta is real)
1256        assert_ne!(QWEN2_PRETOKENIZE_REGEX, QWEN35_PRETOKENIZE_REGEX);
1257    }
1258
1259    /// `collect_split_regexes` walks a Sequence in order and ignores non-Split steps and
1260    /// `{"String": …}` patterns (gemma's `Split{String:" "}` is not a regex).
1261    #[test]
1262    fn collect_split_regexes_walks_in_order() {
1263        let src = r#"{"type":"Sequence","pretokenizers":[
1264            {"type":"Split","pattern":{"Regex":"A"},"behavior":"Isolated"},
1265            {"type":"Split","pattern":{"String":" "},"behavior":"Isolated"},
1266            {"type":"Digits","individual_digits":true},
1267            {"type":"Sequence","pretokenizers":[
1268                {"type":"Split","pattern":{"Regex":"B"},"behavior":"Isolated"}
1269            ]},
1270            {"type":"ByteLevel","add_prefix_space":false}
1271        ]}"#;
1272        let v = json::parse(src).unwrap();
1273        let mut out = Vec::new();
1274        collect_split_regexes(&v, &mut out);
1275        assert_eq!(out, vec!["A".to_string(), "B".to_string()]);
1276    }
1277}
1278
1279#[cfg(test)]
1280mod hf_tests {
1281    use super::*;
1282
1283    /// Inline tokenizer.json fixture: byte-level BPE, ~20 tokens incl one special added
1284    /// token, merges deliberately MIXED between the "a b" string format and the [a, b]
1285    /// pair format (HF emits both across tokenizers versions).
1286    ///
1287    /// The `Split` step carries the REAL qwen35 regex (it was an empty string until
1288    /// 2026-08-19). That is what a shipped Qwen checkpoint looks like, and it is what lets the
1289    /// no-`tokenizer_config.json` test below identify a pre-tokenizer at all — the empty-regex
1290    /// fixture only loaded because an unidentified pre-tokenizer used to fall through silently.
1291    const TOKENIZER_JSON: &str = r#"{
1292      "version": "1.0",
1293      "added_tokens": [
1294        {"id": 15, "content": "<|end|>", "special": true},
1295        {"id": 16, "content": "<think>", "special": false}
1296      ],
1297      "pre_tokenizer": {
1298        "type": "Sequence",
1299        "pretokenizers": [
1300          {"type": "Split", "pattern": {"Regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"}, "behavior": "Isolated"},
1301          {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false}
1302        ]
1303      },
1304      "model": {
1305        "type": "BPE",
1306        "vocab": {
1307          "h": 0, "e": 1, "l": 2, "o": 3, "Ġ": 4, "w": 5, "r": 6, "d": 7,
1308          "he": 8, "ll": 9, "hell": 10, "hello": 11, "Ġw": 12, "or": 13, "!": 14
1309        },
1310        "merges": [
1311          "h e",
1312          ["l", "l"],
1313          "he ll",
1314          ["hell", "o"],
1315          ["Ġ", "w"],
1316          "o r"
1317        ]
1318      }
1319    }"#;
1320
1321    fn write_fixture(
1322        name: &str,
1323        tokenizer_config: Option<&str>,
1324        generation_config: Option<&str>,
1325        jinja: Option<&str>,
1326    ) -> std::path::PathBuf {
1327        let dir = std::env::temp_dir().join(format!("memra-tok-hf-{name}-{}", std::process::id()));
1328        let _ = std::fs::remove_dir_all(&dir);
1329        std::fs::create_dir_all(&dir).unwrap();
1330        std::fs::write(dir.join("tokenizer.json"), TOKENIZER_JSON).unwrap();
1331        if let Some(tc) = tokenizer_config {
1332            std::fs::write(dir.join("tokenizer_config.json"), tc).unwrap();
1333        }
1334        if let Some(gc) = generation_config {
1335            std::fs::write(dir.join("generation_config.json"), gc).unwrap();
1336        }
1337        if let Some(j) = jinja {
1338            std::fs::write(dir.join("chat_template.jinja"), j).unwrap();
1339        }
1340        dir
1341    }
1342
1343    #[test]
1344    fn hf_dir_encode_decode_roundtrip_and_specials() {
1345        // eos as an AddedToken OBJECT + chat_template string in tokenizer_config.
1346        let tc = r#"{
1347          "eos_token": {"content": "<|end|>", "lstrip": false},
1348          "add_bos_token": false,
1349          "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
1350          "chat_template": "{{ messages }}<|end|>"
1351        }"#;
1352        let dir = write_fixture("full", Some(tc), None, None);
1353        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1354
1355        assert_eq!(tok.eos_id(), 15);
1356        assert_eq!(tok.bos_id(), None);
1357        assert_eq!(tok.pre(), "qwen35");
1358        assert_eq!(tok.vocab_size(), 17); // ids 0..16 (added tokens extend the table)
1359        assert_eq!(tok.chat_template(), Some("{{ messages }}<|end|>"));
1360
1361        // BPE over both merge formats: "hello world" -> hello(11) Ġw(12) or(13) l(2) d(7).
1362        // The 'hello' chain exercises string merges (h e / he ll), the pair merges
1363        // ([l,l] / [hell,o] / [Ġ,w]) fire inside the same words -> both formats load.
1364        let ids = tok.encode("hello world", true);
1365        assert_eq!(ids, vec![11, 12, 13, 2, 7]);
1366        assert_eq!(tok.decode(&ids), "hello world");
1367
1368        // special handling: <|end|> (Control) is split out BEFORE BPE and never byte-merged.
1369        let ids = tok.encode("hello<|end|> world", true);
1370        assert_eq!(ids, vec![11, 15, 12, 13, 2, 7]);
1371        // decode with specials rendered vs dropped
1372        assert_eq!(tok.decode_special(&ids, true), "hello<|end|> world");
1373        assert_eq!(tok.decode_special(&ids, false), "hello world");
1374
1375        // non-special added token stays Normal: decodes as literal text.
1376        assert_eq!(tok.decode(&[16]), "<think>");
1377        let _ = std::fs::remove_dir_all(&dir);
1378    }
1379
1380    #[test]
1381    fn hf_dir_generation_config_eos_fallback_and_jinja() {
1382        // no tokenizer_config eos -> generation_config eos_token_id (array form) must win;
1383        // chat template comes from chat_template.jinja.
1384        let gc = r#"{"eos_token_id": [15, 14]}"#;
1385        let dir = write_fixture("genconf", None, Some(gc), Some("JINJA {{ messages }}"));
1386        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1387        assert_eq!(tok.eos_id(), 15);
1388        assert!(!tok.encode("hello", true).is_empty());
1389        assert_eq!(tok.chat_template(), Some("JINJA {{ messages }}"));
1390        let _ = std::fs::remove_dir_all(&dir);
1391    }
1392
1393    /// The three-Split deepseek-v3 pre-tokenizer Sequence, byte-for-byte as HF serializes it
1394    /// (Hy3 / Step-3.7-Flash). This is the case that used to land on `default` -> the silent
1395    /// qwen35 fallback even though `unicode::split_deepseek_v3` already existed.
1396    #[test]
1397    fn hf_dir_identifies_deepseek_v3_from_tokenizer_json() {
1398        let dsv3_pt = r##""pre_tokenizer": {
1399        "type": "Sequence",
1400        "pretokenizers": [
1401          {"type": "Split", "pattern": {"Regex": "\\p{N}{1,3}"}, "behavior": "Isolated"},
1402          {"type": "Split", "pattern": {"Regex": "[一-龥぀-ゟ゠-ヿ]+"}, "behavior": "Isolated"},
1403          {"type": "Split", "pattern": {"Regex": "[!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~][A-Za-z]+|[^\r\n\\p{L}\\p{P}\\p{S}]?[\\p{L}\\p{M}]+| ?[\\p{P}\\p{S}]+[\r\n]*|\\s*[\r\n]+|\\s+(?!\\S)|\\s+"}, "behavior": "Isolated"},
1404          {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": false}
1405        ]
1406      },"##;
1407        // splice the deepseek pre_tokenizer into the shared fixture in place of the qwen one
1408        let open = TOKENIZER_JSON.find(r#""pre_tokenizer""#).unwrap();
1409        let close = TOKENIZER_JSON.find(r#""model""#).unwrap();
1410        let json = format!(
1411            "{}{}\n      {}",
1412            &TOKENIZER_JSON[..open],
1413            dsv3_pt,
1414            &TOKENIZER_JSON[close..]
1415        );
1416        let dir = std::env::temp_dir().join(format!("memra-tok-hf-dsv3-{}", std::process::id()));
1417        let _ = std::fs::remove_dir_all(&dir);
1418        std::fs::create_dir_all(&dir).unwrap();
1419        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1420        std::fs::write(
1421            dir.join("generation_config.json"),
1422            r#"{"eos_token_id": 15}"#,
1423        )
1424        .unwrap();
1425        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1426        assert_eq!(tok.pre(), "deepseek-v3");
1427        assert_eq!(tok.split(), PreSplit::DeepseekV3);
1428        let _ = std::fs::remove_dir_all(&dir);
1429    }
1430
1431    /// The `qwen2` regex differs from qwen35 by two character classes and must be identified as
1432    /// qwen2, not silently mistaken for qwen35 (they share a state machine but not an id).
1433    #[test]
1434    fn hf_dir_identifies_qwen2_regex() {
1435        let json = TOKENIZER_JSON.replace(
1436            r"[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+",
1437            r"[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+",
1438        );
1439        assert_ne!(json, TOKENIZER_JSON, "the qwen2 substitution must apply");
1440        let dir = std::env::temp_dir().join(format!("memra-tok-hf-qwen2-{}", std::process::id()));
1441        let _ = std::fs::remove_dir_all(&dir);
1442        std::fs::create_dir_all(&dir).unwrap();
1443        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1444        std::fs::write(
1445            dir.join("generation_config.json"),
1446            r#"{"eos_token_id": 15}"#,
1447        )
1448        .unwrap();
1449        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1450        assert_eq!(tok.pre(), "qwen2");
1451        assert_eq!(
1452            tok.split(),
1453            PreSplit::Qwen35,
1454            "qwen2 rides the qwen35 split"
1455        );
1456        let _ = std::fs::remove_dir_all(&dir);
1457    }
1458
1459    /// An HF checkpoint whose pre-tokenizer matches nothing known is REFUSED, and the error
1460    /// names both observations so the next porter knows what to implement.
1461    #[test]
1462    fn hf_dir_refuses_unidentifiable_pretokenizer() {
1463        let json = TOKENIZER_JSON.replace(r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|", "SOMETHING-ELSE|");
1464        assert_ne!(json, TOKENIZER_JSON);
1465        let dir = std::env::temp_dir().join(format!("memra-tok-hf-unk-{}", std::process::id()));
1466        let _ = std::fs::remove_dir_all(&dir);
1467        std::fs::create_dir_all(&dir).unwrap();
1468        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1469        std::fs::write(
1470            dir.join("generation_config.json"),
1471            r#"{"eos_token_id": 15}"#,
1472        )
1473        .unwrap();
1474        let err = match Tokenizer::from_hf_dir(&dir) {
1475            Ok(_) => panic!("unidentifiable pre must refuse to load"),
1476            Err(e) => e,
1477        };
1478        assert!(
1479            err.contains("unsupported tokenizer.ggml.pre 'default'"),
1480            "{err}"
1481        );
1482        assert!(
1483            err.contains("SOMETHING-ELSE"),
1484            "error must quote the regex: {err}"
1485        );
1486        assert!(err.contains("MEMRA_ALLOW_UNKNOWN_PRETOKENIZER"), "{err}");
1487        let _ = std::fs::remove_dir_all(&dir);
1488    }
1489
1490    /// Artifact-backed: real vendor `tokenizer.json` files must resolve to the right split.
1491    /// Per-entry skip when a checkpoint is not staged (same posture as tests/llama_parity.rs) —
1492    /// this is the gate that pins the regex constants against real vendor serializations rather
1493    /// than against our own fixture. Every one of these landed on the silent qwen35 fallback
1494    /// before 2026-08-19 except the Qwen entry (which carries `pretokenize_regex`).
1495    #[test]
1496    fn staged_checkpoints_resolve_their_own_pretokenizer() {
1497        let cases: &[(&str, &str)] = &[
1498            // ships the deepseek-v3 Sequence verbatim; was mis-tokenized as qwen35
1499            (
1500                "/data/ai-ml/hf-models/hy3-layer103p5-sparse-source",
1501                "deepseek-v3",
1502            ),
1503            // qwen2 regex in tokenizer.json, no `pretokenize_regex` sidecar
1504            ("/data/ai-ml/hf-models/qwen3-1.7b-blk128fp8-synth", "qwen2"),
1505            // the control: `pretokenize_regex` present and byte-equal
1506            ("/data/ai-ml/hf-models/qwen35-9b-hf", "qwen35"),
1507        ];
1508        let mut ran = 0;
1509        for (path, want) in cases {
1510            let dir = std::path::Path::new(path);
1511            if !dir.join("tokenizer.json").exists() {
1512                eprintln!("skip: {path} not staged");
1513                continue;
1514            }
1515            let tok = Tokenizer::from_hf_dir(dir).unwrap_or_else(|e| panic!("{path}: {e}"));
1516            assert_eq!(tok.pre(), *want, "{path}");
1517            ran += 1;
1518        }
1519        eprintln!("staged_checkpoints_resolve_their_own_pretokenizer: {ran}/3 cases ran");
1520    }
1521
1522    #[test]
1523    fn hf_dir_rejects_non_byte_level() {
1524        let dir = std::env::temp_dir().join(format!("memra-tok-hf-nonbl-{}", std::process::id()));
1525        let _ = std::fs::remove_dir_all(&dir);
1526        std::fs::create_dir_all(&dir).unwrap();
1527        let bad = TOKENIZER_JSON.replace("\"ByteLevel\"", "\"Metaspace\"");
1528        std::fs::write(dir.join("tokenizer.json"), bad).unwrap();
1529        assert!(Tokenizer::from_hf_dir(&dir).is_err());
1530        let _ = std::fs::remove_dir_all(&dir);
1531    }
1532}