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`/`glm4` pre-tokenizers,
8//! plus 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;
13pub mod 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, HashSet};
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;
28/// Dense token tables are indexed by token id; cap sparse metadata before it can
29/// request a multi-gigabyte `Vec` for one distant id.
30const MAX_TOKENIZER_ID: u32 = 1_000_000;
31const MAX_TOKENIZER_SPARSE_FACTOR: usize = 16;
32const MAX_TOKENIZER_SPARSE_SLACK: usize = 4096;
33const 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+";
34/// llama.cpp `LLAMA_VOCAB_PRE_TYPE_QWEN2`. Differs from qwen35 in exactly two places —
35/// `\p{L}+` vs `[\p{L}\p{M}]+` and `[^\s\p{L}\p{N}]+` vs `[^\s\p{L}\p{M}\p{N}]+` — both of
36/// which the qwen35 state machine covers (see the `"qwen2"` arm in `PreSplit::resolve`).
37const 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+";
38/// The three `Split` steps of the `deepseek-v3` (DEEPSEEK3_LLM) pre-tokenizer Sequence, in the
39/// order HF serializes them: `\p{N}{1,3}` digit grouping, an isolated CJK/kana pass, then the
40/// six-alternative pattern. `unicode::split_deepseek_v3` is a pass-for-pass port of exactly
41/// these three. Read off the Hy3 and Step-3.7-Flash checkpoints' own `tokenizer.json`.
42const DEEPSEEK_V3_SPLIT_REGEXES: [&str; 3] = [
43    r"\p{N}{1,3}",
44    "[\u{4e00}-\u{9fa5}\u{3040}-\u{309f}\u{30a0}-\u{30ff}]+",
45    "[!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~][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+",
46];
47
48/// llama.cpp `LLAMA_VOCAB_PRE_TYPE_CHATGLM4` (`tokenizer.ggml.pre` = `glm4`), read off
49/// zai-org/GLM-5.3-Flash @ 04c4e9e9's own `tokenizer.json` (sha256 19e77364…, the sha banked
50/// in that lane's `artifact.lock`). Differs from `QWEN2_PRETOKENIZE_REGEX` in EXACTLY ONE
51/// ATOM — `\p{N}{1,3}` instead of `\p{N}`, so digit runs group up to three (the cl100k /
52/// LLaMA-3 convention) instead of one token per digit — and is character-identical
53/// everywhere else. It still needs its OWN state machine (`unicode::split_glm4`) rather than
54/// a parameterized `split_qwen35`: `split_qwen35` implements qwen35's mark-folding classes
55/// (`[\p{L}\p{M}]+`, `[^\s\p{L}\p{M}\p{N}]+`), which the literal classes here do not, and the
56/// two disagree on any text carrying combining marks.
57const GLM4_PRETOKENIZE_REGEX: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
58
59/// Every `tokenizer.ggml.pre` id memra implements an EXACT split for. This is the allowlist a
60/// load is checked against and the list quoted in the load error, so the two can never drift.
61pub const SUPPORTED_PRETOKENIZERS: &[&str] = &["qwen35", "qwen2", "deepseek-v3", "gemma4", "glm4"];
62
63/// Escape hatch for deliberate experimentation with a family whose pre-tokenizer is not ported
64/// yet. Set to `1` to downgrade the hard load error to a loud per-load WARN.
65pub const ALLOW_UNKNOWN_PRETOKENIZER_ENV: &str = "MEMRA_ALLOW_UNKNOWN_PRETOKENIZER";
66
67fn allow_unknown_pretokenizer() -> bool {
68    std::env::var(ALLOW_UNKNOWN_PRETOKENIZER_ENV).as_deref() == Ok("1")
69}
70
71/// Serializes every TEST that touches `MEMRA_ALLOW_UNKNOWN_PRETOKENIZER`. The variable is
72/// PROCESS-wide and `cargo test` runs the suite in parallel threads of ONE process, so
73/// `pretokenizer_tests::opt_out_env_gate`'s `set_var("1")` was visible to
74/// `hf_tests::hf_dir_refuses_unidentifiable_pretokenizer` while it ran: that test calls
75/// `from_hf_dir`, which reads this same variable through `allow_unknown_pretokenizer` above, so
76/// it LOADED instead of refusing and the suite failed with the qwen35-fallback WARN in its
77/// stdout. It reddened at whatever interleaving the scheduler happened to pick and passed every
78/// time it was run alone, which is why it read as noise. `opt_out_env_gate`'s old SAFETY note
79/// asserted "no other test reads this variable", which was simply untrue: the prose was the
80/// entire justification and nothing checked it against an actual reader. It lives at the crate
81/// root because the two racing tests are in two different `#[cfg(test)]` modules.
82/// (Found by lane/real-system-fingerprint-20260901, whose `cargo test --workspace` it reddened.)
83#[cfg(test)]
84static PRETOKENIZER_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
85
86/// Take the env lock, tolerating poisoning: a panic inside one of these tests must not convert
87/// the others into a second, misleading failure that hides the first.
88#[cfg(test)]
89fn pretokenizer_env_lock() -> std::sync::MutexGuard<'static, ()> {
90    PRETOKENIZER_ENV_LOCK
91        .lock()
92        .unwrap_or_else(|poisoned| poisoned.into_inner())
93}
94
95/// A model declared a pre-tokenizer memra has no exact split for.
96///
97/// Before 2026-08-19 this was one `eprintln!` per process followed by a silent fall-through to
98/// the qwen35 split: the model loaded, generated fluent text, and every token id was wrong —
99/// the same fluent-and-invisible class as the GGUF chat-template mint trap. Wrong ids poison
100/// goldens, parity fixtures, acceptance counts and every quality number downstream, and nothing
101/// in the stack can detect it after the fact. So it is a hard load error now.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct UnknownPretokenizer {
104    /// The value that was rejected (`tokenizer.ggml.pre`, or `default` when an HF checkpoint's
105    /// pre-tokenizer regexes matched no known family).
106    pub pre: String,
107    /// True when the vocab model is SPM-style (`tokenizer.ggml.model == "gemma4"`); a `pre`/model
108    /// disagreement is itself the fault, so it is worth naming.
109    pub spm_style: bool,
110}
111
112impl std::fmt::Display for UnknownPretokenizer {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(
115            f,
116            "unsupported tokenizer.ggml.pre '{}' (vocab model is {}) — memra has no exact \
117             pre-tokenizer split for it and token ids would NOT be exact. Supported: {}. \
118             Set {}=1 to load anyway for deliberate experimentation (token ids will be wrong).",
119            self.pre,
120            if self.spm_style { "SPM/gemma4" } else { "gpt2" },
121            SUPPORTED_PRETOKENIZERS.join(", "),
122            ALLOW_UNKNOWN_PRETOKENIZER_ENV,
123        )
124    }
125}
126
127impl std::error::Error for UnknownPretokenizer {}
128
129/// The pre-tokenizer split a loaded `Tokenizer` runs. Constructed only through
130/// `PreSplit::resolve`, so "we do not know how to split for this model" is not a state a live
131/// tokenizer can be in unless the operator asked for it via the env opt-out.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum PreSplit {
134    /// `unicode::split_qwen35` — serves both `qwen35` and `qwen2`.
135    Qwen35,
136    /// `unicode::split_deepseek_v3` — DeepSeek-V3 and the Step-3.5/3.7-Flash family.
137    DeepseekV3,
138    /// `unicode::split_glm4` — the zai-org GLM-4.x / GLM-5.x line (llama.cpp
139    /// `LLAMA_VOCAB_PRE_TYPE_CHATGLM4`). qwen2's pattern with `\p{N}{1,3}` digit grouping.
140    Glm4,
141    /// gemma4 SPM-style BPE: `bpe_tokenize` splits whole lines itself and the `pre` id is never
142    /// consulted. Requires the `gemma4` vocab model, not just the `pre` string.
143    Spm,
144    /// `MEMRA_ALLOW_UNKNOWN_PRETOKENIZER=1` was set for an unrecognized `pre`. Runs the qwen35
145    /// split; token ids are NOT exact and every downstream measurement is invalid.
146    UnknownFallbackQwen35,
147}
148
149impl PreSplit {
150    /// Resolve a `tokenizer.ggml.pre` id against the implemented splits. `spm_style` is
151    /// `tokenizer.ggml.model == "gemma4"`.
152    pub fn resolve(pre: &str, spm_style: bool) -> Result<Self, UnknownPretokenizer> {
153        Self::resolve_with(pre, spm_style, allow_unknown_pretokenizer())
154    }
155
156    /// `resolve` with the env decision passed in, so tests exercise both branches without
157    /// mutating process-global environment underneath the rest of the suite.
158    fn resolve_with(
159        pre: &str,
160        spm_style: bool,
161        allow_unknown: bool,
162    ) -> Result<Self, UnknownPretokenizer> {
163        // The pair is matched, not just the `pre` string: an SPM vocab with a gpt2 `pre` (or the
164        // reverse) is a metadata disagreement, and picking either side of it silently is how a
165        // wrong split gets chosen for a right-looking model.
166        match (pre, spm_style) {
167            ("qwen35" | "qwen2", false) => Ok(PreSplit::Qwen35),
168            ("deepseek-v3", false) => Ok(PreSplit::DeepseekV3),
169            ("glm4", false) => Ok(PreSplit::Glm4),
170            ("gemma4", true) => Ok(PreSplit::Spm),
171            _ => {
172                let err = UnknownPretokenizer {
173                    pre: pre.to_string(),
174                    spm_style,
175                };
176                if allow_unknown {
177                    // Deliberately NOT once-per-process: this prints on every load so it cannot
178                    // scroll out of one boot log and be missed on the next.
179                    eprintln!(
180                        "memra-tokenizer: WARNING {ALLOW_UNKNOWN_PRETOKENIZER_ENV}=1 — loading \
181                         with {err} FALLING BACK to the qwen35 split. Token ids are NOT exact: \
182                         goldens, parity fixtures, acceptance counts and quality numbers taken \
183                         on this model are all invalid."
184                    );
185                    Ok(PreSplit::UnknownFallbackQwen35)
186                } else {
187                    Err(err)
188                }
189            }
190        }
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195enum TokAttr {
196    Normal,
197    Unknown,
198    Control,
199    UserDefined,
200    Byte,
201    Other,
202}
203
204impl TokAttr {
205    fn from_toktype(t: i64) -> Self {
206        match t {
207            TT_UNKNOWN => TokAttr::Unknown,
208            TT_CONTROL => TokAttr::Control,
209            TT_USER_DEFINED => TokAttr::UserDefined,
210            TT_BYTE => TokAttr::Byte,
211            1 => TokAttr::Normal,
212            _ => TokAttr::Other,
213        }
214    }
215    /// Tokens that participate in `tokenizer_st_partition` (special-token splitting):
216    /// CONTROL | USER_DEFINED | UNKNOWN.
217    fn is_special(self) -> bool {
218        matches!(
219            self,
220            TokAttr::Control | TokAttr::UserDefined | TokAttr::Unknown
221        )
222    }
223}
224
225pub struct Tokenizer {
226    /// id -> raw vocab piece string (byte-encoded GPT-2 form, e.g. "Ġworld").
227    id_to_token: Vec<String>,
228    /// piece string -> id.
229    token_to_id: HashMap<String, u32>,
230    /// per-token attribute.
231    attrs: Vec<TokAttr>,
232    /// (left, right) merge pair -> rank (lower = higher priority).
233    bpe_ranks: HashMap<(String, String), i32>,
234    /// special-token ids, sorted by descending piece length (llama's cache order).
235    special_tokens: Vec<u32>,
236    eos_id: u32,
237    /// EVERY end-of-generation id the checkpoint declares, `eos_id` first.
238    ///
239    /// `eos_id` alone is a lie on any vendor that ships an ARRAY `eos_token_id`: GLM-5.3-Flash
240    /// declares `[154820 <|endoftext|>, 154827 <|user|>, 154829 <|observation|>]` in
241    /// `generation_config.json` (its turn boundaries — the assistant reaching either of the
242    /// last two has left its turn), and the serve path stopped on the first id only, so a
243    /// finished answer ran on into a hallucinated multi-turn transcript and reported
244    /// `finish_reason: "length"` (research/glm53-flash-bringup-20260827, 2026-08-27).
245    /// GGUF keeps exactly one id here (its metadata declares one), so no GGUF family moves.
246    eos_ids: Vec<u32>,
247    bos_id: Option<u32>,
248    add_bos: bool,
249    pre: String,
250    /// The split `pre` resolved to at load. Kept alongside the raw `pre` string so the encode
251    /// path never re-interprets metadata and has no "unknown" arm to fall through.
252    split: PreSplit,
253    chat_template: Option<String>,
254    /// SPM-style BPE (gemma4): \u2581 whitespace escaping, raw-UTF-8 merges, <0xXX> byte fallback.
255    spm_style: bool,
256    /// deepseek-v4 encoding revision, detected from the checkpoint's config.json dspark_*
257    /// key census at `from_hf_dir` (see `chat::Dsv4Encoding` \u2014 the effort ladder differs
258    /// between the preview and 0731 checkpoints while every tokenizer/template byte is
259    /// identical). None = unknown (no config.json next to the tokenizer, or a GGUF \u2014 no
260    /// dsv4 GGUF lineage exists yet and no metadata key is defined for it); rendering then
261    /// refuses dsv4 effort levels whose bytes differ across revisions instead of guessing.
262    dsv4_encoding: Option<chat::Dsv4Encoding>,
263}
264
265/// A bigram in the BPE work queue. Ordering matches llama.cpp's comparator:
266/// the priority_queue pops the *smallest* (rank, left) under the std comparator
267/// `l.rank > r.rank || (l.rank == r.rank && l.left > r.left)`. We implement `Ord`
268/// so a max-heap pops that same element (min rank, then min left).
269#[derive(Clone, Eq, PartialEq)]
270struct Bigram {
271    left: i32,
272    right: i32,
273    rank: i32,
274    text: String,
275}
276
277impl Ord for Bigram {
278    fn cmp(&self, other: &Self) -> Ordering {
279        // BinaryHeap is a max-heap; we want the element with the lowest rank
280        // (ties: lowest left index) to be "greatest" so it pops first.
281        match other.rank.cmp(&self.rank) {
282            Ordering::Equal => other.left.cmp(&self.left),
283            o => o,
284        }
285    }
286}
287impl PartialOrd for Bigram {
288    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
289        Some(self.cmp(other))
290    }
291}
292
293/// A symbol (one or more codepoints) in the BPE chain. Mirrors `llm_symbol`.
294struct Symbol {
295    text: String,
296    prev: i32,
297    next: i32,
298    n: usize, // codepoint count (0 == merged away)
299}
300
301impl Tokenizer {
302    /// Build a tokenizer from a model's GGUF tokenizer metadata.
303    pub fn from_gguf(g: &GgufFile) -> Result<Self, String> {
304        let model = g
305            .metadata
306            .get("tokenizer.ggml.model")
307            .and_then(|v| v.as_str())
308            .ok_or("missing tokenizer.ggml.model")?;
309        if model != "gpt2" && model != "gemma4" {
310            return Err(format!(
311                "unsupported tokenizer model '{model}' (only gpt2/gemma4)"
312            ));
313        }
314        // gemma4 = SPM-style BPE (llama-vocab.cpp): spaces escaped to \u2581 by the normalizer,
315        // merges over raw UTF-8 (NO gpt2 byte-encoding), whole-line pre-split, <0xXX> byte
316        // fallback tokens, add_bos force-true (PR #21500 workaround).
317        let spm_style = model == "gemma4";
318        let pre = g
319            .metadata
320            .get("tokenizer.ggml.pre")
321            .and_then(|v| v.as_str())
322            .unwrap_or(if spm_style { "gemma4" } else { "default" })
323            .to_string();
324        // Resolve BEFORE any of the (expensive) vocab/merge work: an unsupported pre-tokenizer
325        // is a load refusal, not a warning, so there is no reason to build the tables first.
326        let split = PreSplit::resolve(&pre, spm_style).map_err(|e| e.to_string())?;
327
328        // tokens[]
329        let tokens = match g.metadata.get("tokenizer.ggml.tokens") {
330            Some(MetaValue::Array(a)) => a,
331            _ => return Err("missing tokenizer.ggml.tokens array".into()),
332        };
333        let n = tokens.len();
334        if n > MAX_TOKENIZER_ID as usize + 1 {
335            return Err(format!(
336                "tokenizer.ggml.tokens has {n} entries; maximum supported vocabulary is {}",
337                MAX_TOKENIZER_ID + 1
338            ));
339        }
340        let mut id_to_token = Vec::with_capacity(n);
341        let mut token_to_id = HashMap::with_capacity(n);
342        for (i, t) in tokens.iter().enumerate() {
343            let s = t.as_str().ok_or("non-string in tokens[]")?.to_string();
344            // first-id-wins on duplicates (llama keeps the map's first insert)
345            token_to_id.entry(s.clone()).or_insert(i as u32);
346            id_to_token.push(s);
347        }
348
349        // token_type[] -> attrs
350        let mut attrs = vec![TokAttr::Normal; n];
351        if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.token_type") {
352            for (i, v) in a.iter().enumerate().take(n) {
353                if let Some(t) = v.as_u64() {
354                    attrs[i] = TokAttr::from_toktype(t as i64);
355                } else if let MetaValue::I32(t) = v {
356                    attrs[i] = TokAttr::from_toktype(*t as i64);
357                }
358            }
359        }
360
361        // merges[] -> ranks. Each entry is "first second" (split on first space at idx>=1).
362        let mut bpe_ranks = HashMap::new();
363        if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.merges") {
364            for (i, v) in a.iter().enumerate() {
365                let word = v.as_str().ok_or("non-string in merges[]")?;
366                // llama: pos = word.find(' ', 1) — a *byte* search starting at byte 1.
367                // (The space separating the two pieces is always single-byte ASCII; the
368                // pieces themselves may contain multibyte chars like 'Ġ', so we search bytes.)
369                let bytes = word.as_bytes();
370                if let Some(pos) = bytes.iter().skip(1).position(|&b| b == b' ').map(|p| p + 1) {
371                    let first = word[..pos].to_string();
372                    let second = word[pos + 1..].to_string();
373                    bpe_ranks.insert((first, second), i as i32);
374                }
375            }
376        } else {
377            return Err("missing tokenizer.ggml.merges array".into());
378        }
379
380        // special-token cache: CONTROL|USER_DEFINED|UNKNOWN, sorted by descending text length.
381        let mut special_tokens: Vec<u32> = (0..n as u32)
382            .filter(|&id| attrs[id as usize].is_special())
383            .collect();
384        special_tokens.sort_by(|&a, &b| {
385            id_to_token[b as usize]
386                .len()
387                .cmp(&id_to_token[a as usize].len())
388        });
389
390        let eos_id = g
391            .metadata
392            .get("tokenizer.ggml.eos_token_id")
393            .and_then(|v| v.as_u64())
394            .map(|v| v as u32)
395            .ok_or("missing tokenizer.ggml.eos_token_id")?;
396        let bos_id = g
397            .metadata
398            .get("tokenizer.ggml.bos_token_id")
399            .and_then(|v| v.as_u64())
400            .map(|v| v as u32);
401        let add_bos = g
402            .metadata
403            .get("tokenizer.ggml.add_bos_token")
404            .and_then(|v| match v {
405                MetaValue::Bool(b) => Some(*b),
406                _ => v.as_u64().map(|x| x != 0),
407            })
408            .unwrap_or(false);
409        let add_bos = add_bos || spm_style;
410
411        let chat_template = g
412            .metadata
413            .get("tokenizer.chat_template")
414            .and_then(|v| v.as_str())
415            .map(|s| s.to_string());
416
417        Ok(Tokenizer {
418            id_to_token,
419            token_to_id,
420            attrs,
421            bpe_ranks,
422            special_tokens,
423            eos_id,
424            // GGUF declares ONE eos id (`tokenizer.ggml.eos_token_id`); there is no array
425            // form to honour, so every GGUF family's stop set is byte-identical to before.
426            eos_ids: vec![eos_id],
427            bos_id,
428            add_bos,
429            pre,
430            split,
431            chat_template,
432            spm_style,
433            // No dsv4 GGUF lineage exists (dsv4 serves from safetensors dirs); when a mint
434            // lane defines one, it must carry the encoding revision in GGUF metadata —
435            // unknown here means dsv4 "high"/"max" renders refuse rather than guess.
436            dsv4_encoding: None,
437        })
438    }
439
440    /// Build a tokenizer from an HF fast-tokenizer checkpoint directory
441    /// (`tokenizer.json` + optional `tokenizer_config.json` / `generation_config.json` /
442    /// `chat_template.jinja`). Only byte-level BPE (the gpt2 class — MiniMax-M3, Qwen,
443    /// Llama-3 style) is supported: `model.type == "BPE"` with a ByteLevel pre-tokenizer.
444    ///
445    /// Mapping to the GGUF-built struct:
446    ///   - model.vocab (token -> id map)             -> id_to_token / token_to_id
447    ///   - model.merges ("a b" strings OR [a,b] pairs; both HF serializations) -> bpe_ranks
448    ///   - added_tokens special=true -> Control class (split before BPE + hidden on decode);
449    ///     non-special added tokens stay Normal.
450    ///   - eos/bos: tokenizer_config eos_token/bos_token (string or {content} object),
451    ///     generation_config eos_token_id (int or array) as the eos fallback.
452    ///   - add_bos: tokenizer_config add_bos_token (default false).
453    ///   - chat template: tokenizer_config chat_template, else chat_template.jinja.
454    ///   - pre-tokenizer: `tokenizer_config.pretokenize_regex`, else the `Split` step regexes of
455    ///     `tokenizer.json`'s own `pre_tokenizer`, matched byte-exactly against the qwen35 /
456    ///     qwen2 / deepseek-v3 constants. No match -> a hard error naming both observations.
457    pub fn from_hf_dir(dir: &std::path::Path) -> Result<Self, String> {
458        let tj_path = dir.join("tokenizer.json");
459        let text = std::fs::read_to_string(&tj_path)
460            .map_err(|e| format!("read {}: {e}", tj_path.display()))?;
461        let tj = json::parse(&text).map_err(|e| format!("{}: {e}", tj_path.display()))?;
462
463        let model = tj.get("model").ok_or("tokenizer.json: missing model")?;
464        if let Some(t) = model.get("type").and_then(|v| v.as_str())
465            && t != "BPE"
466        {
467            return Err(format!(
468                "unsupported tokenizer.json model type '{t}' (only BPE)"
469            ));
470        }
471        // byte-level check: pre_tokenizer.type == ByteLevel (possibly inside a Sequence).
472        let pre_tok = tj
473            .get("pre_tokenizer")
474            .ok_or("tokenizer.json: missing pre_tokenizer")?;
475        if !pre_tokenizer_is_byte_level(pre_tok) {
476            return Err(
477                "tokenizer.json: pre_tokenizer is not ByteLevel — only byte-level \
478                        BPE is supported"
479                    .into(),
480            );
481        }
482
483        // ---- vocab (token -> id). ids may exceed the map len (added_tokens append). ----
484        let vocab = model
485            .get("vocab")
486            .and_then(|v| v.as_obj())
487            .ok_or("tokenizer.json: missing model.vocab")?;
488        let empty: Vec<json::Value> = Vec::new();
489        let added = tj
490            .get("added_tokens")
491            .and_then(|v| v.as_arr())
492            .unwrap_or(&empty);
493        let mut max_id = 0u32;
494        let mut used_ids = HashSet::new();
495        for v in vocab.values() {
496            let id = v
497                .as_u64()
498                .ok_or("tokenizer.json: non-integer id in model.vocab")?;
499            let id = u32::try_from(id).map_err(|_| "tokenizer.json: vocabulary id exceeds u32")?;
500            if id > MAX_TOKENIZER_ID {
501                return Err(format!(
502                    "tokenizer.json: vocabulary id {id} exceeds maximum {MAX_TOKENIZER_ID}"
503                ));
504            }
505            max_id = max_id.max(id);
506            used_ids.insert(id);
507        }
508        for a in added {
509            let id = a
510                .get("id")
511                .and_then(|v| v.as_u64())
512                .ok_or("tokenizer.json: added_tokens entry missing id")?;
513            let id = u32::try_from(id).map_err(|_| "tokenizer.json: added token id exceeds u32")?;
514            if id > MAX_TOKENIZER_ID {
515                return Err(format!(
516                    "tokenizer.json: added token id {id} exceeds maximum {MAX_TOKENIZER_ID}"
517                ));
518            }
519            max_id = max_id.max(id);
520            used_ids.insert(id);
521        }
522        let n = (max_id as usize)
523            .checked_add(1)
524            .ok_or("tokenizer.json: vocabulary size overflow")?;
525        let entry_count = used_ids.len();
526        let max_dense_len = entry_count
527            .saturating_mul(MAX_TOKENIZER_SPARSE_FACTOR)
528            .saturating_add(MAX_TOKENIZER_SPARSE_SLACK);
529        if n > max_dense_len {
530            return Err(format!(
531                "tokenizer.json: vocabulary ids are too sparse (dense length {n}, {} entries; maximum {max_dense_len})",
532                entry_count
533            ));
534        }
535        let mut id_to_token = vec![String::new(); n];
536        let mut token_to_id: HashMap<String, u32> = HashMap::with_capacity(n);
537        let mut attrs = vec![TokAttr::Normal; n];
538        for (tok, v) in vocab {
539            let id = u32::try_from(
540                v.as_u64()
541                    .ok_or("tokenizer.json: non-integer id in model.vocab")?,
542            )
543            .map_err(|_| "tokenizer.json: vocabulary id exceeds u32")?;
544            id_to_token[id as usize] = tok.clone();
545            token_to_id.entry(tok.clone()).or_insert(id);
546        }
547        // added_tokens: register content + special flag. special=true -> Control (the class
548        // that is split out before BPE and hidden by decode_special(.., false)).
549        for a in added {
550            let id = u32::try_from(
551                a.get("id")
552                    .and_then(|v| v.as_u64())
553                    .ok_or("tokenizer.json: added_tokens entry missing id")?,
554            )
555            .map_err(|_| "tokenizer.json: added token id exceeds u32")?;
556            let content = a
557                .get("content")
558                .and_then(|v| v.as_str())
559                .ok_or("tokenizer.json: added_tokens entry missing content")?;
560            if id_to_token[id as usize].is_empty() {
561                id_to_token[id as usize] = content.to_string();
562            }
563            token_to_id.entry(content.to_string()).or_insert(id);
564            if a.get("special").and_then(|v| v.as_bool()).unwrap_or(false) {
565                attrs[id as usize] = TokAttr::Control;
566            } else {
567                // HF's AddedVocabulary matches EVERY added token whole (special or not) before
568                // the BPE model runs; `special` only controls skip_special_tokens on decode.
569                // UserDefined = split whole before BPE but NOT hidden on decode — exactly the
570                // HF non-special class (Hy3's `<think:opensource>`/`<|reasoning_mode…|>` chat
571                // tokens are special=false and MUST encode as single ids, 2026-07-09).
572                attrs[id as usize] = TokAttr::UserDefined;
573            }
574        }
575
576        // ---- merges: array of "a b" strings OR [a, b] pairs (HF emits both). ----
577        let merges = model
578            .get("merges")
579            .and_then(|v| v.as_arr())
580            .ok_or("tokenizer.json: missing model.merges")?;
581        let mut bpe_ranks = HashMap::with_capacity(merges.len());
582        for (i, m) in merges.iter().enumerate() {
583            let (first, second) = match m {
584                json::Value::Str(s) => {
585                    // byte search for the separating space from byte 1 (same as the GGUF
586                    // path: pieces may contain multibyte chars like 'Ġ', the space is ASCII).
587                    let bytes = s.as_bytes();
588                    let pos = bytes
589                        .iter()
590                        .skip(1)
591                        .position(|&b| b == b' ')
592                        .map(|p| p + 1)
593                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] has no space"))?;
594                    (s[..pos].to_string(), s[pos + 1..].to_string())
595                }
596                json::Value::Arr(a) if a.len() == 2 => {
597                    let f = a[0]
598                        .as_str()
599                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
600                    let s2 = a[1]
601                        .as_str()
602                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
603                    (f.to_string(), s2.to_string())
604                }
605                _ => {
606                    return Err(format!(
607                        "tokenizer.json: merges[{i}] is neither \"a b\" string nor [a, b] pair"
608                    ));
609                }
610            };
611            bpe_ranks.insert((first, second), i as i32);
612        }
613
614        // special-token cache: same construction as from_gguf.
615        let mut special_tokens: Vec<u32> = (0..n as u32)
616            .filter(|&id| attrs[id as usize].is_special())
617            .collect();
618        special_tokens.sort_by(|&a, &b| {
619            id_to_token[b as usize]
620                .len()
621                .cmp(&id_to_token[a as usize].len())
622        });
623
624        // ---- sidecars: tokenizer_config.json + generation_config.json ----
625        let tc = std::fs::read_to_string(dir.join("tokenizer_config.json"))
626            .ok()
627            .and_then(|t| json::parse(&t).ok());
628        let gc = std::fs::read_to_string(dir.join("generation_config.json"))
629            .ok()
630            .and_then(|t| json::parse(&t).ok());
631
632        // eos_token/bos_token: plain string OR {"content": "..."} AddedToken object.
633        let tok_content = |v: &json::Value| -> Option<String> {
634            v.as_str().map(|s| s.to_string()).or_else(|| {
635                v.get("content")
636                    .and_then(|c| c.as_str())
637                    .map(|s| s.to_string())
638            })
639        };
640        let eos_from_cfg = tc
641            .as_ref()
642            .and_then(|c| c.get("eos_token"))
643            .and_then(&tok_content)
644            .and_then(|s| token_to_id.get(&s).copied());
645        // generation_config eos_token_id: int or an ARRAY of ints, and the array is the
646        // vendor's whole stop set — not a list whose first entry is the answer. Reading only
647        // `a.first()` here is what let GLM-5.3-Flash run past `<|user|>` (lane
648        // glm53-flash-bringup, 2026-08-27): ids 2 and 3 of a declared triple were dropped on
649        // the floor. `eos_id` (the SCALAR, consumed by the boot log, embed pooling, the
650        // grammar/tokrx bridges and every `eos_id()` caller) keeps its exact old selection —
651        // tokenizer_config `eos_token` first, else the FIRST generation_config id — and the
652        // rest of the declared ids ride `eos_ids` into `eog_ids()`.
653        let gen_eos_ids: Vec<u32> = gc
654            .as_ref()
655            .and_then(|c| c.get("eos_token_id"))
656            .map(|v| match v {
657                json::Value::Num(_) => v.as_u64().map(|x| x as u32).into_iter().collect(),
658                json::Value::Arr(a) => a
659                    .iter()
660                    .filter_map(|x| x.as_u64())
661                    .map(|x| x as u32)
662                    .collect(),
663                _ => Vec::new(),
664            })
665            .unwrap_or_default();
666        let eos_from_gen = gen_eos_ids.first().copied();
667        let eos_id = eos_from_cfg.or(eos_from_gen).ok_or(
668            "no eos token: need tokenizer_config.json eos_token or \
669             generation_config.json eos_token_id",
670        )?;
671        // Declared order, eos_id first, no dedup survivors. An id past the vocab is kept as
672        // declared rather than silently dropped: it is inert (nothing can sample an id the
673        // logit row has no column for) and dropping it would hide a bad checkpoint.
674        let mut eos_ids = vec![eos_id];
675        for id in gen_eos_ids {
676            if !eos_ids.contains(&id) {
677                eos_ids.push(id);
678            }
679        }
680        let bos_id = tc
681            .as_ref()
682            .and_then(|c| c.get("bos_token"))
683            .and_then(&tok_content)
684            .and_then(|s| token_to_id.get(&s).copied());
685        let add_bos = tc
686            .as_ref()
687            .and_then(|c| c.get("add_bos_token"))
688            .and_then(|v| v.as_bool())
689            .unwrap_or(false);
690
691        // chat template: tokenizer_config chat_template string, else chat_template.jinja file.
692        let chat_template = tc
693            .as_ref()
694            .and_then(|c| c.get("chat_template"))
695            .and_then(|v| v.as_str())
696            .map(|s| s.to_string())
697            .or_else(|| std::fs::read_to_string(dir.join("chat_template.jinja")).ok());
698        // Pre-tokenizer identification, in order of authority:
699        //   1. `tokenizer_config.json`'s `pretokenize_regex` (Qwen ships it explicitly), and
700        //   2. the `Split` step regexes of `tokenizer.json`'s own `pre_tokenizer`.
701        // (2) was missing until 2026-08-19, so ONLY Qwen checkpoints could ever be identified
702        // here and everything else fell to `default` -> the silent qwen35 fallback. The Hy3
703        // checkpoints were being mis-tokenized that way while `split_deepseek_v3` — the exact
704        // splitter their own tokenizer.json asks for — already shipped in this crate.
705        let cfg_regex = tc
706            .as_ref()
707            .and_then(|c| c.get("pretokenize_regex"))
708            .and_then(|v| v.as_str());
709        let mut tj_regexes: Vec<String> = Vec::new();
710        collect_split_regexes(pre_tok, &mut tj_regexes);
711        let pre = cfg_regex
712            .and_then(|r| pre_from_split_regexes(std::slice::from_ref(&r.to_string())))
713            .or_else(|| pre_from_split_regexes(&tj_regexes))
714            .unwrap_or("default");
715        let split = PreSplit::resolve(pre, false).map_err(|e| {
716            if pre == "default" {
717                format!(
718                    "{e}\n  (HF checkpoint {}: tokenizer_config.json pretokenize_regex = {:?}, \
719                     tokenizer.json pre_tokenizer Split regexes = {:?} — neither matched a known \
720                     family)",
721                    dir.display(),
722                    cfg_regex,
723                    tj_regexes,
724                )
725            } else {
726                e.to_string()
727            }
728        })?;
729
730        // deepseek-v4 encoding revision from the checkpoint's own config.json (dspark_* key
731        // census — the only artifact-level marker; tokenizer/template files are byte-identical
732        // across the preview and 0731 checkpoints). Missing/unparseable config.json (e.g. a
733        // tokenizer-only ref dir) = unknown; a PARTIAL dspark key set is a corrupt config and
734        // refuses the load rather than guessing an effort ladder.
735        let dsv4_encoding = dsv4_encoding_from_config(dir)?;
736
737        Ok(Tokenizer {
738            id_to_token,
739            token_to_id,
740            attrs,
741            bpe_ranks,
742            special_tokens,
743            eos_id,
744            eos_ids,
745            bos_id,
746            add_bos,
747            pre: pre.to_string(),
748            split,
749            chat_template,
750            spm_style: false,
751            dsv4_encoding,
752        })
753    }
754
755    pub fn eos_id(&self) -> u32 {
756        self.eos_id
757    }
758    /// Every eos id the checkpoint declares, `eos_id` first. One entry for GGUF and for any
759    /// checkpoint whose `generation_config.json` names a single id; the whole declared array
760    /// otherwise. Prefer `eog_ids()` for a stop set — it adds the name-keyed backstop.
761    pub fn eos_ids(&self) -> &[u32] {
762        &self.eos_ids
763    }
764    /// Exact-piece id lookup (vision special tokens etc.). None = not in the vocab.
765    pub fn id_of(&self, piece: &str) -> Option<u32> {
766        self.token_to_id.get(piece).copied()
767    }
768    /// Every end-of-generation id the CHECKPOINT declares (`eos_ids`), plus the common
769    /// turn-end control tokens present in the vocab (llama's special_eog set — <|im_end|>
770    /// chatml, <turn|>/<end_of_turn> gemma) as a name-keyed backstop for checkpoints whose
771    /// metadata names only one.
772    ///
773    /// This is the serve path's stop set (`worker::run` unions it into `GenParams::eos`).
774    pub fn eog_ids(&self) -> Vec<u32> {
775        let mut ids = self.eos_ids.clone();
776        for t in ["<|im_end|>", "<turn|>", "<end_of_turn>"] {
777            if let Some(&id) = self.token_to_id.get(t)
778                && !ids.contains(&id)
779            {
780                ids.push(id);
781            }
782        }
783        ids
784    }
785    pub fn bos_id(&self) -> Option<u32> {
786        self.bos_id
787    }
788    pub fn vocab_size(&self) -> usize {
789        self.id_to_token.len()
790    }
791    pub fn pre(&self) -> &str {
792        &self.pre
793    }
794    /// The split `pre` resolved to. `UnknownFallbackQwen35` means the env opt-out is engaged and
795    /// this tokenizer's ids are NOT exact — a serve gate can refuse on it.
796    pub fn split(&self) -> PreSplit {
797        self.split
798    }
799    pub fn chat_template(&self) -> Option<&str> {
800        self.chat_template.as_deref()
801    }
802    /// deepseek-v4 encoding revision detected at load (config.json dspark_* census);
803    /// None = unknown. Meaningful only for dsv4-template artifacts.
804    pub fn dsv4_encoding(&self) -> Option<chat::Dsv4Encoding> {
805        self.dsv4_encoding
806    }
807
808    #[inline]
809    fn text_to_token(&self, s: &str) -> Option<u32> {
810        self.token_to_id.get(s).copied()
811    }
812
813    fn find_bpe_rank(&self, left: &str, right: &str) -> i32 {
814        self.bpe_ranks
815            .get(&(left.to_string(), right.to_string()))
816            .copied()
817            .unwrap_or(-1)
818    }
819
820    /// Encode text -> token ids.
821    ///
822    /// `add_special` controls whether a BOS is prepended when the model asks for it.
823    /// `parse_special` (always true here) splits control/user-defined/unknown tokens
824    /// (e.g. `<|im_start|>`) out before BPE — matching llama's default tokenize().
825    pub fn encode(&self, text: &str, add_special: bool) -> Vec<u32> {
826        self.encode_special(text, add_special, true)
827    }
828
829    pub fn encode_special(&self, text: &str, add_special: bool, parse_special: bool) -> Vec<u32> {
830        let mut output: Vec<u32> = Vec::new();
831        if add_special
832            && self.add_bos
833            && let Some(b) = self.bos_id
834        {
835            output.push(b);
836        }
837        if text.is_empty() {
838            return output;
839        }
840
841        // fragment buffer: alternate raw-text spans and resolved special-token ids.
842        for frag in self.st_partition(text, parse_special) {
843            match frag {
844                Fragment::Token(id) => output.push(id),
845                Fragment::Text(span) => self.bpe_tokenize(&span, &mut output),
846            }
847        }
848        output
849    }
850
851    /// `tokenizer_st_partition` — split out special tokens (longest first) before BPE.
852    fn st_partition(&self, text: &str, parse_special: bool) -> Vec<Fragment> {
853        let mut frags = vec![Fragment::Text(text.to_string())];
854        for &sid in &self.special_tokens {
855            let attr = self.attrs[sid as usize];
856            // when parse_special is false, skip CONTROL/UNKNOWN (user-defined still split).
857            if !parse_special && matches!(attr, TokAttr::Control | TokAttr::Unknown) {
858                continue;
859            }
860            let needle = &self.id_to_token[sid as usize];
861            if needle.is_empty() {
862                continue;
863            }
864            let mut next: Vec<Fragment> = Vec::with_capacity(frags.len());
865            for f in frags.drain(..) {
866                match f {
867                    Fragment::Token(id) => next.push(Fragment::Token(id)),
868                    Fragment::Text(s) => {
869                        let mut rest: &str = &s;
870                        let mut acc = String::new();
871                        while let Some(m) = rest.find(needle.as_str()) {
872                            acc.push_str(&rest[..m]);
873                            if !acc.is_empty() {
874                                next.push(Fragment::Text(std::mem::take(&mut acc)));
875                            }
876                            next.push(Fragment::Token(sid));
877                            rest = &rest[m + needle.len()..];
878                        }
879                        acc.push_str(rest);
880                        if !acc.is_empty() {
881                            next.push(Fragment::Text(acc));
882                        }
883                    }
884                }
885            }
886            frags = next;
887        }
888        frags
889    }
890
891    /// Core BPE over one raw-text fragment (`llm_tokenizer_bpe_session::tokenize`).
892    fn bpe_tokenize(&self, text: &str, output: &mut Vec<u32>) {
893        if self.spm_style {
894            // gemma4 (llama PRE_TYPE_GEMMA4): escape spaces to \u2581 on the raw fragment,
895            // split whole lines ([^\n]+|[\n]+), run BPE on raw UTF-8 chars.
896            let escaped: String = text
897                .chars()
898                .map(|c| if c == ' ' { '\u{2581}' } else { c })
899                .collect();
900            let mut words: Vec<String> = Vec::new();
901            let mut cur = String::new();
902            let mut cur_nl: Option<bool> = None;
903            for c in escaped.chars() {
904                let nl = c == '\n';
905                if cur_nl != Some(nl) && !cur.is_empty() {
906                    words.push(std::mem::take(&mut cur));
907                }
908                cur_nl = Some(nl);
909                cur.push(c);
910            }
911            if !cur.is_empty() {
912                words.push(cur);
913            }
914            for word in &words {
915                // newline-run fix (llama PR #21343): whole-word vocab hit short-circuits BPE.
916                if word.chars().all(|c| c == '\n')
917                    && let Some(tok) = self.text_to_token(word)
918                {
919                    output.push(tok);
920                    continue;
921                }
922                self.bpe_merge_word(word, output);
923            }
924            return;
925        }
926        // 1) pre-tokenizer split, then 2) GPT-2 byte-encode each word.
927        //
928        // Exhaustive on `PreSplit` and has NO fall-through arm: the "we do not know how to split
929        // this" case was resolved (and refused) at load, so it cannot arrive here. Adding a
930        // `PreSplit` variant must fail to compile until this match handles it.
931        let words: Vec<String> = match self.split {
932            // qwen35 also serves qwen2: llama.cpp's qwen2 regex differs from qwen35's only in
933            // [\p{L}\p{M}]+ vs \p{L}+, which the qwen35 state machine covers.
934            PreSplit::Qwen35 => unicode::split_qwen35(text),
935            // Step-3.5/3.7-Flash and the DeepSeek-V3 family
936            // (llama.cpp LLAMA_VOCAB_PRE_TYPE_DEEPSEEK3_LLM). Materially different from qwen2:
937            // \p{N}{1,3} digit grouping, an isolated CJK/kana pass, and \p{P}/\p{S}-only runs.
938            PreSplit::DeepseekV3 => unicode::split_deepseek_v3(text),
939            // zai-org GLM-4.x / GLM-5.x (llama.cpp LLAMA_VOCAB_PRE_TYPE_CHATGLM4): qwen2's
940            // pattern with `\p{N}{1,3}` digit grouping, and literal `\p{L}` letter runs that
941            // do NOT fold combining marks the way the qwen35 machine does.
942            PreSplit::Glm4 => unicode::split_glm4(text),
943            // MEMRA_ALLOW_UNKNOWN_PRETOKENIZER=1 — the operator asked for wrong ids. The WARN
944            // was printed at load; do not repeat it once per fragment.
945            PreSplit::UnknownFallbackQwen35 => unicode::split_qwen35(text),
946            // Unreachable: `spm_style` short-circuits above, and `PreSplit::Spm` is only
947            // produced together with it.
948            PreSplit::Spm => unreachable!("PreSplit::Spm implies spm_style, handled above"),
949        };
950
951        for word in &words {
952            let word = unicode::byte_encode(word);
953            self.bpe_merge_word(&word, output);
954        }
955    }
956
957    /// BPE merge over one pre-split word (symbols = unicode chars), emitting token ids with
958    /// byte fallback (gpt2 single-char byte tokens, or SPM <0xXX> tokens when spm_style).
959    fn bpe_merge_word(&self, word: &str, output: &mut Vec<u32>) {
960        {
961            let word = word.to_string();
962
963            // build the symbol chain, one symbol per unicode char initially.
964            let chars: Vec<char> = word.chars().collect();
965            let mut symbols: Vec<Symbol> = Vec::with_capacity(chars.len());
966            for (i, &c) in chars.iter().enumerate() {
967                symbols.push(Symbol {
968                    text: c.to_string(),
969                    prev: i as i32 - 1,
970                    next: if i + 1 == chars.len() {
971                        -1
972                    } else {
973                        i as i32 + 1
974                    },
975                    n: 1,
976                });
977            }
978
979            // seed the work queue with adjacent bigrams.
980            let mut queue: BinaryHeap<Bigram> = BinaryHeap::new();
981            for i in 1..symbols.len() {
982                self.add_bigram(&symbols, i as i32 - 1, i as i32, &mut queue);
983            }
984
985            // merge by rank.
986            while let Some(bigram) = queue.pop() {
987                let li = bigram.left as usize;
988                let ri = bigram.right as usize;
989                if symbols[li].n == 0 || symbols[ri].n == 0 {
990                    continue;
991                }
992                let combined = format!("{}{}", symbols[li].text, symbols[ri].text);
993                if combined != bigram.text {
994                    continue; // outdated bigram
995                }
996                // merge right into left
997                symbols[li].text = combined;
998                symbols[li].n += symbols[ri].n;
999                symbols[ri].n = 0;
1000                let r_next = symbols[ri].next;
1001                symbols[li].next = r_next;
1002                if r_next >= 0 {
1003                    symbols[r_next as usize].prev = bigram.left;
1004                }
1005                let l_prev = symbols[li].prev;
1006                let l_next = symbols[li].next;
1007                self.add_bigram(&symbols, l_prev, bigram.left, &mut queue);
1008                self.add_bigram(&symbols, bigram.left, l_next, &mut queue);
1009            }
1010
1011            // emit final symbols in chain order, with byte-level fallback.
1012            for sym in &symbols {
1013                if sym.n == 0 {
1014                    continue;
1015                }
1016                match self.text_to_token(&sym.text) {
1017                    Some(tok) => output.push(tok),
1018                    None => {
1019                        // byte fallback: each *byte* of the piece must be its own token.
1020                        for b in sym.text.bytes() {
1021                            let bs = if self.spm_style {
1022                                format!("<0x{b:02X}>") // SPM-style byte tokens (gemma4)
1023                            } else {
1024                                (b as char).to_string()
1025                            };
1026                            if let Some(t) = self.text_to_token(&bs) {
1027                                output.push(t);
1028                            }
1029                        }
1030                    }
1031                }
1032            }
1033        }
1034    }
1035
1036    fn add_bigram(
1037        &self,
1038        symbols: &[Symbol],
1039        left: i32,
1040        right: i32,
1041        queue: &mut BinaryHeap<Bigram>,
1042    ) {
1043        if left == -1 || right == -1 {
1044            return;
1045        }
1046        let lt = &symbols[left as usize].text;
1047        let rt = &symbols[right as usize].text;
1048        let rank = self.find_bpe_rank(lt, rt);
1049        if rank < 0 {
1050            return;
1051        }
1052        queue.push(Bigram {
1053            left,
1054            right,
1055            rank,
1056            text: format!("{lt}{rt}"),
1057        });
1058    }
1059
1060    /// Decode token ids -> String. `special=false` drops control tokens (chat tags);
1061    /// `special=true` renders them as their literal text.
1062    pub fn decode(&self, ids: &[u32]) -> String {
1063        self.decode_special(ids, true)
1064    }
1065
1066    /// True for Control/Unknown tokens — vocab entries that are protocol markers, not text.
1067    /// External vocab consumers (llguidance's toktrie, constrained decoding) must not let a
1068    /// grammar match these as literal bytes (a JSON string could otherwise smuggle
1069    /// `<|im_start|>`); they substitute a non-text marker form instead.
1070    pub fn token_is_control(&self, id: u32) -> bool {
1071        matches!(
1072            self.attrs.get(id as usize),
1073            Some(TokAttr::Control) | Some(TokAttr::Unknown)
1074        )
1075    }
1076
1077    /// True iff `id` is in the special set `st_partition` splits out of raw text (control /
1078    /// user-defined / unknown) — i.e. an id a plain-text LITERAL can produce during encode.
1079    /// The vision special-id intake guard (memra-server) keys on this: an ordinary vocab
1080    /// entry that merely looks like a marker is honest text and must not be policed.
1081    pub fn token_is_special(&self, id: u32) -> bool {
1082        self.special_tokens.contains(&id)
1083    }
1084
1085    pub fn decode_special(&self, ids: &[u32], special: bool) -> String {
1086        String::from_utf8_lossy(&self.decode_bytes_special(ids, special)).into_owned()
1087    }
1088
1089    /// Decode token ids to their exact byte stream. Streaming callers must retain incomplete
1090    /// UTF-8 suffixes across token boundaries instead of replacing them prematurely.
1091    pub fn decode_bytes_special(&self, ids: &[u32], special: bool) -> Vec<u8> {
1092        let mut bytes: Vec<u8> = Vec::new();
1093        for &id in ids {
1094            let i = id as usize;
1095            if i >= self.id_to_token.len() {
1096                continue;
1097            }
1098            let attr = self.attrs[i];
1099            let piece = &self.id_to_token[i];
1100            match attr {
1101                TokAttr::Normal | TokAttr::Byte => {
1102                    if self.spm_style {
1103                        // gemma4: <0xXX> byte tokens -> raw byte; else unescape \u2581 -> space.
1104                        if (matches!(attr, TokAttr::Byte)
1105                            || (piece.len() == 6
1106                                && piece.starts_with("<0x")
1107                                && piece.ends_with('>')))
1108                            && let Ok(b) = u8::from_str_radix(&piece[3..5], 16)
1109                        {
1110                            bytes.push(b);
1111                            continue;
1112                        }
1113                        for c in piece.chars() {
1114                            if c == '\u{2581}' {
1115                                bytes.push(b' ');
1116                            } else {
1117                                let mut buf = [0u8; 4];
1118                                bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
1119                            }
1120                        }
1121                    } else {
1122                        // undo GPT-2 byte encoding: each char -> one raw byte.
1123                        self.piece_to_bytes(piece, &mut bytes);
1124                    }
1125                }
1126                TokAttr::UserDefined => {
1127                    // user-defined tokens are literal text (not byte-encoded).
1128                    bytes.extend_from_slice(piece.as_bytes());
1129                }
1130                TokAttr::Control | TokAttr::Unknown => {
1131                    if special {
1132                        bytes.extend_from_slice(piece.as_bytes());
1133                    }
1134                    // else: render nothing
1135                }
1136                TokAttr::Other => {}
1137            }
1138        }
1139        bytes
1140    }
1141
1142    fn piece_to_bytes(&self, piece: &str, out: &mut Vec<u8>) {
1143        for c in piece.chars() {
1144            match unicode::unicode_to_byte(c) {
1145                Some(b) => out.push(b),
1146                None => {
1147                    // not in the byte map — emit the char's utf-8 bytes verbatim.
1148                    let mut buf = [0u8; 4];
1149                    out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
1150                }
1151            }
1152        }
1153    }
1154
1155    /// Apply the chat template (from GGUF, or a chatml fallback) to a list of
1156    /// (role, content) turns, producing the prompt string. Then `encode` it.
1157    pub fn apply_chat_template(
1158        &self,
1159        messages: &[(&str, &str)],
1160        add_generation_prompt: bool,
1161    ) -> String {
1162        chat::apply_chat_template_enc(
1163            self.chat_template.as_deref(),
1164            messages,
1165            add_generation_prompt,
1166            self.dsv4_encoding,
1167        )
1168        // the only Err arm is the dsv4 effort/tool validation, unreachable on this
1169        // plain-messages path (Default think, no effort, no tools)
1170        .expect("plain chat render cannot fail")
1171    }
1172
1173    /// Does this tokenizer's chat template carry the Qwen3.8 reasoning-effort ladder
1174    /// (`chat::template_has_qwen_effort`)? Load-bearing for the serve path's plain-render
1175    /// fast-path decision: on a ladder template the UNSET case renders the vendor's own
1176    /// `xhigh` default (docs/SERVING.md, reasoning-schema lane 2026-08-23), and only the
1177    /// tools-capable renderer injects it — `apply_chat_template` reproduces the historical
1178    /// no-instruction bytes, which on this template are the accepted-and-ignored defect
1179    /// that lane removed, not a behaviour to preserve.
1180    pub fn has_qwen_effort_ladder(&self) -> bool {
1181        self.chat_template
1182            .as_deref()
1183            .is_some_and(chat::template_has_qwen_effort)
1184    }
1185
1186    /// Tools-capable chat rendering (OpenAI `tools` / `tool_calls` / role:"tool" surface +
1187    /// the think-tail switch + the per-dialect `reasoning_effort` string). Plain requests
1188    /// render byte-identically to `apply_chat_template`; see `chat::apply_chat_template_tools`.
1189    /// The dsv4 encoding revision this tokenizer detected at load rides along, so a dsv4
1190    /// effort request renders the correct ladder for THIS artifact.
1191    pub fn apply_chat_template_tools(
1192        &self,
1193        turns: &[chat::Turn],
1194        add_generation_prompt: bool,
1195        tools_json: &[String],
1196        think: chat::ThinkMode,
1197        reasoning_effort: Option<&str>,
1198    ) -> Result<String, String> {
1199        chat::apply_chat_template_tools_ex(
1200            self.chat_template.as_deref(),
1201            turns,
1202            add_generation_prompt,
1203            tools_json,
1204            &[],
1205            think,
1206            reasoning_effort,
1207            self.dsv4_encoding,
1208        )
1209    }
1210
1211    /// `apply_chat_template_tools` plus the gemma4 arm's structured tool `function` objects
1212    /// (`tools_struct`). The serve path uses this so gemma4 tool DEFINITIONS render into the
1213    /// tooluse dialect; every non-gemma dialect ignores `tools_struct`. The dsv4 encoding
1214    /// revision rides from the tokenizer (see `dsv4_encoding`).
1215    #[allow(clippy::too_many_arguments)]
1216    pub fn apply_chat_template_tools_ex(
1217        &self,
1218        turns: &[chat::Turn],
1219        add_generation_prompt: bool,
1220        tools_json: &[String],
1221        tools_struct: &[chat::Val],
1222        think: chat::ThinkMode,
1223        reasoning_effort: Option<&str>,
1224    ) -> Result<String, String> {
1225        chat::apply_chat_template_tools_ex(
1226            self.chat_template.as_deref(),
1227            turns,
1228            add_generation_prompt,
1229            tools_json,
1230            tools_struct,
1231            think,
1232            reasoning_effort,
1233            self.dsv4_encoding,
1234        )
1235    }
1236}
1237
1238enum Fragment {
1239    Text(String),
1240    Token(u32),
1241}
1242
1243/// deepseek-v4 encoding-revision census over the checkpoint's config.json (0731 re-gate,
1244/// research/dsv4-template-20260818/ENCODING-DIFF.md). The 0731 checkpoint added exactly
1245/// these four keys in the same revision that remapped the reasoning-effort ladder, and
1246/// they are the ONLY artifact-level marker (tokenizer.json / tokenizer_config.json /
1247/// generation_config.json are byte-identical across preview and 0731):
1248///
1249///   - `model_type == "deepseek_v4"` with all four present -> `Some(V0731)`
1250///   - `model_type == "deepseek_v4"` with none present      -> `Some(Preview)`
1251///   - config.json absent/unparseable, or a different model family -> `None` (unknown;
1252///     dsv4 renders then refuse the effort levels whose bytes differ across revisions)
1253///   - a PARTIAL set -> `Err` (a hand-edited/corrupt config; refuse the load rather
1254///     than guess an effort ladder)
1255///
1256/// Detection reads config CONTENT via the json parser — never filenames or template text.
1257fn dsv4_encoding_from_config(dir: &std::path::Path) -> Result<Option<chat::Dsv4Encoding>, String> {
1258    const DSPARK_KEYS: [&str; 4] = [
1259        "dspark_block_size",
1260        "dspark_markov_rank",
1261        "dspark_noise_token_id",
1262        "dspark_target_layer_ids",
1263    ];
1264    let cfg_path = dir.join("config.json");
1265    let Ok(text) = std::fs::read_to_string(&cfg_path) else {
1266        return Ok(None);
1267    };
1268    let Ok(cfg) = json::parse(&text) else {
1269        // A config.json the model loader cannot read either; the tokenizer stays honest
1270        // with "unknown" instead of failing a load the loader may report better.
1271        return Ok(None);
1272    };
1273    if cfg.get("model_type").and_then(|v| v.as_str()) != Some("deepseek_v4") {
1274        // Not this family's config: no encoding claim (a dsv4 TEMPLATE over a foreign
1275        // config is a franken artifact — effort-differing renders refuse).
1276        return Ok(None);
1277    }
1278    let present: Vec<&str> = DSPARK_KEYS
1279        .iter()
1280        .copied()
1281        .filter(|k| cfg.get(k).is_some())
1282        .collect();
1283    match present.len() {
1284        0 => Ok(Some(chat::Dsv4Encoding::Preview)),
1285        4 => Ok(Some(chat::Dsv4Encoding::V0731)),
1286        _ => Err(format!(
1287            "{}: partial dspark_* key set {:?} (expected none or all of {:?}) — cannot \
1288             determine the deepseek-v4 encoding revision; refusing rather than guessing \
1289             the reasoning-effort ladder",
1290            cfg_path.display(),
1291            present,
1292            DSPARK_KEYS
1293        )),
1294    }
1295}
1296
1297/// True when an HF `pre_tokenizer` object is byte-level BPE: type == "ByteLevel", or a
1298/// "Sequence" whose pretokenizers include a ByteLevel step (the common Split+ByteLevel combo).
1299/// Collect the regexes of every `Split` step in an HF `pre_tokenizer`, in serialization order.
1300/// A `Sequence` is walked depth-first; non-`Split` steps (ByteLevel, Digits, …) contribute
1301/// nothing. `{"pattern": {"String": …}}` is not a regex and is skipped.
1302fn collect_split_regexes(pt: &json::Value, out: &mut Vec<String>) {
1303    match pt.get("type").and_then(|v| v.as_str()) {
1304        Some("Sequence") => {
1305            if let Some(arr) = pt.get("pretokenizers").and_then(|v| v.as_arr()) {
1306                for step in arr {
1307                    collect_split_regexes(step, out);
1308                }
1309            }
1310        }
1311        Some("Split") => {
1312            if let Some(r) = pt
1313                .get("pattern")
1314                .and_then(|p| p.get("Regex"))
1315                .and_then(|v| v.as_str())
1316            {
1317                out.push(r.to_string());
1318            }
1319        }
1320        _ => {}
1321    }
1322}
1323
1324/// Map an ordered set of pre-tokenizer split regexes onto a `tokenizer.ggml.pre` id.
1325/// Byte-exact comparison against the shipped constants — a near-match is a different splitter
1326/// (qwen2 vs qwen35 differ by two character classes and produce different ids on marks), so
1327/// there is deliberately no fuzzy path. `None` = no known family.
1328fn pre_from_split_regexes(regexes: &[String]) -> Option<&'static str> {
1329    match regexes {
1330        [one] if one == QWEN35_PRETOKENIZE_REGEX => Some("qwen35"),
1331        [one] if one == QWEN2_PRETOKENIZE_REGEX => Some("qwen2"),
1332        // GLM-5.3-Flash's `tokenizer.ggml.pre` is `default`; this arm is the ONLY thing that
1333        // identifies it, and the one-atom `{1,3}` delta from qwen2 is why the comparison has
1334        // to stay byte-exact.
1335        [one] if one == GLM4_PRETOKENIZE_REGEX => Some("glm4"),
1336        [a, b, c]
1337            if a == DEEPSEEK_V3_SPLIT_REGEXES[0]
1338                && b == DEEPSEEK_V3_SPLIT_REGEXES[1]
1339                && c == DEEPSEEK_V3_SPLIT_REGEXES[2] =>
1340        {
1341            Some("deepseek-v3")
1342        }
1343        _ => None,
1344    }
1345}
1346
1347fn pre_tokenizer_is_byte_level(pt: &json::Value) -> bool {
1348    match pt.get("type").and_then(|v| v.as_str()) {
1349        Some("ByteLevel") => true,
1350        Some("Sequence") => pt
1351            .get("pretokenizers")
1352            .and_then(|v| v.as_arr())
1353            .map(|arr| arr.iter().any(pre_tokenizer_is_byte_level))
1354            .unwrap_or(false),
1355        _ => false,
1356    }
1357}
1358
1359#[cfg(test)]
1360mod pretokenizer_tests {
1361    use super::*;
1362
1363    /// Every id on the shipped allowlist still resolves — the regression guard for the flip from
1364    /// warn-and-fall-through to hard-refuse. `gemma4` is the SPM path and pairs with the gemma4
1365    /// vocab model; the other three are gpt2-vocab splits.
1366    #[test]
1367    fn every_supported_pre_resolves() {
1368        assert_eq!(
1369            PreSplit::resolve_with("qwen35", false, false),
1370            Ok(PreSplit::Qwen35)
1371        );
1372        assert_eq!(
1373            PreSplit::resolve_with("qwen2", false, false),
1374            Ok(PreSplit::Qwen35)
1375        );
1376        assert_eq!(
1377            PreSplit::resolve_with("deepseek-v3", false, false),
1378            Ok(PreSplit::DeepseekV3)
1379        );
1380        assert_eq!(
1381            PreSplit::resolve_with("glm4", false, false),
1382            Ok(PreSplit::Glm4)
1383        );
1384        assert_eq!(
1385            PreSplit::resolve_with("gemma4", true, false),
1386            Ok(PreSplit::Spm)
1387        );
1388        // and the allowlist constant is exactly that set, so the error text cannot drift from
1389        // what the code accepts
1390        assert_eq!(
1391            SUPPORTED_PRETOKENIZERS,
1392            &["qwen35", "qwen2", "deepseek-v3", "gemma4", "glm4"]
1393        );
1394    }
1395
1396    /// An unknown `pre` is a typed error, not a warning and not a wrong split.
1397    #[test]
1398    fn unknown_pre_is_a_typed_error() {
1399        let err =
1400            PreSplit::resolve_with("llama4", false, false).expect_err("llama4 has no ported split");
1401        assert_eq!(
1402            err,
1403            UnknownPretokenizer {
1404                pre: "llama4".into(),
1405                spm_style: false
1406            }
1407        );
1408        let msg = err.to_string();
1409        // names the offending value, lists what IS supported, and points at the opt-out
1410        assert!(msg.contains("'llama4'"), "{msg}");
1411        for supported in SUPPORTED_PRETOKENIZERS {
1412            assert!(
1413                msg.contains(supported),
1414                "error must list {supported}: {msg}"
1415            );
1416        }
1417        assert!(msg.contains(ALLOW_UNKNOWN_PRETOKENIZER_ENV), "{msg}");
1418        // and it is a real std::error::Error, so `?` from a loader keeps the type
1419        let _: &dyn std::error::Error = &err;
1420    }
1421
1422    /// A `pre`/vocab-model disagreement is its own fault: an SPM vocab with a gpt2 `pre`, or a
1423    /// gpt2 vocab claiming the gemma4 SPM pre, must not silently pick one side.
1424    #[test]
1425    fn pre_and_vocab_model_must_agree() {
1426        assert!(PreSplit::resolve_with("qwen35", true, false).is_err());
1427        assert!(PreSplit::resolve_with("gemma4", false, false).is_err());
1428        // the historical GGUF/HF sentinels for "no pre declared" are refusals, not qwen35
1429        assert!(PreSplit::resolve_with("default", false, false).is_err());
1430        assert!(PreSplit::resolve_with("", false, false).is_err());
1431    }
1432
1433    /// The opt-out loads, and it declares itself in the resolved split so a gate can refuse it.
1434    #[test]
1435    fn opt_out_loads_with_a_fallback_marker() {
1436        assert_eq!(
1437            PreSplit::resolve_with("llama4", false, true),
1438            Ok(PreSplit::UnknownFallbackQwen35)
1439        );
1440        // ... including for an SPM-model disagreement
1441        assert_eq!(
1442            PreSplit::resolve_with("qwen35", true, true),
1443            Ok(PreSplit::UnknownFallbackQwen35)
1444        );
1445    }
1446
1447    /// The env name is the one documented, and only an exact `1` engages it (so a stale
1448    /// `=0`/`=false` in a launcher does not silently turn wrong ids back on).
1449    #[test]
1450    fn opt_out_env_gate() {
1451        let _env = pretokenizer_env_lock();
1452        // SAFETY: the lock above makes this thread the only one touching this variable, and
1453        // the resolve paths every other test uses take the decision as a parameter.
1454        unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
1455        assert!(!allow_unknown_pretokenizer());
1456        unsafe { std::env::set_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV, "0") };
1457        assert!(!allow_unknown_pretokenizer());
1458        unsafe { std::env::set_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV, "1") };
1459        assert!(allow_unknown_pretokenizer());
1460        assert_eq!(
1461            PreSplit::resolve("llama4", false),
1462            Ok(PreSplit::UnknownFallbackQwen35)
1463        );
1464        unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
1465        assert!(PreSplit::resolve("llama4", false).is_err());
1466    }
1467
1468    /// Regex identification is byte-exact and order-sensitive: a near-miss is a DIFFERENT
1469    /// splitter, and a partial deepseek Sequence is not the deepseek Sequence.
1470    #[test]
1471    fn split_regex_identification_is_exact() {
1472        let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1473        assert_eq!(
1474            pre_from_split_regexes(&s(&[QWEN35_PRETOKENIZE_REGEX])),
1475            Some("qwen35")
1476        );
1477        assert_eq!(
1478            pre_from_split_regexes(&s(&[QWEN2_PRETOKENIZE_REGEX])),
1479            Some("qwen2")
1480        );
1481        assert_eq!(
1482            pre_from_split_regexes(&s(&DEEPSEEK_V3_SPLIT_REGEXES)),
1483            Some("deepseek-v3")
1484        );
1485        // GLM-5.3-Flash: `tokenizer.ggml.pre` is `default`, so the tokenizer.json Split regex
1486        // is the ONLY identifier the checkpoint carries.
1487        assert_eq!(
1488            pre_from_split_regexes(&s(&[GLM4_PRETOKENIZE_REGEX])),
1489            Some("glm4")
1490        );
1491        // order matters
1492        assert_eq!(
1493            pre_from_split_regexes(&s(&[
1494                DEEPSEEK_V3_SPLIT_REGEXES[1],
1495                DEEPSEEK_V3_SPLIT_REGEXES[0],
1496                DEEPSEEK_V3_SPLIT_REGEXES[2],
1497            ])),
1498            None
1499        );
1500        // a truncated Sequence is not the family
1501        assert_eq!(
1502            pre_from_split_regexes(&s(&[
1503                DEEPSEEK_V3_SPLIT_REGEXES[0],
1504                DEEPSEEK_V3_SPLIT_REGEXES[1]
1505            ])),
1506            None
1507        );
1508        // one character off is a different splitter
1509        let mut near = QWEN35_PRETOKENIZE_REGEX.to_string();
1510        near.push('x');
1511        assert_eq!(pre_from_split_regexes(&s(&[&near])), None);
1512        assert_eq!(pre_from_split_regexes(&[]), None);
1513        // qwen2 and qwen35 are NOT the same string (the two-class delta is real)
1514        assert_ne!(QWEN2_PRETOKENIZE_REGEX, QWEN35_PRETOKENIZE_REGEX);
1515        // glm4 is qwen2's pattern with ONE atom changed — assert that literally, so nobody can
1516        // "simplify" the two constants into one and silently route GLM through qwen2's split.
1517        assert_ne!(GLM4_PRETOKENIZE_REGEX, QWEN2_PRETOKENIZE_REGEX);
1518        assert_eq!(
1519            QWEN2_PRETOKENIZE_REGEX.replacen(r"|\p{N}|", r"|\p{N}{1,3}|", 1),
1520            GLM4_PRETOKENIZE_REGEX,
1521            "glm4 must differ from qwen2 in exactly the digit-run atom"
1522        );
1523        // and dropping the {1,3} back off is qwen2, not glm4
1524        assert_eq!(
1525            pre_from_split_regexes(&s(&[&GLM4_PRETOKENIZE_REGEX.replacen(
1526                r"\p{N}{1,3}",
1527                r"\p{N}",
1528                1
1529            )])),
1530            Some("qwen2")
1531        );
1532    }
1533
1534    /// DictaLM-3.0 (Mistral tekken vocab, 131072) carries NO `pretokenize_regex` in its
1535    /// tokenizer_config, so its `tokenizer.json` `pre_tokenizer` is the only identifier — and
1536    /// that object, pinned here verbatim from dicta-il/DictaLM-3.0-24B-Thinking's own
1537    /// tokenizer.json, is `Sequence[Split(Regex), ByteLevel(use_regex=false)]` whose regex is
1538    /// BYTE-IDENTICAL to `GLM4_PRETOKENIZE_REGEX`. So the family needs no new splitter: it
1539    /// resolves onto the already-ported `glm4` state machine. If a future edit to either
1540    /// constant breaks that identity, this test says so instead of the model silently
1541    /// tokenizing through qwen35's mark-folding classes.
1542    #[test]
1543    fn dictalm_tekken_pre_tokenizer_is_the_glm4_split() {
1544        const DICTALM_PRE_TOKENIZER: &str = r#"{"type": "Sequence", "pretokenizers": [{"type": "Split", "pattern": {"Regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"}, "behavior": "Isolated", "invert": false}, {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": false}]}"#;
1545        let pt = json::parse(DICTALM_PRE_TOKENIZER).expect("pinned pre_tokenizer parses");
1546        let mut regexes = Vec::new();
1547        collect_split_regexes(&pt, &mut regexes);
1548        assert_eq!(
1549            regexes.as_slice(),
1550            &[GLM4_PRETOKENIZE_REGEX.to_string()],
1551            "the tekken Split regex must stay byte-identical to the glm4 constant"
1552        );
1553        assert_eq!(pre_from_split_regexes(&regexes), Some("glm4"));
1554        assert_eq!(PreSplit::resolve("glm4", false), Ok(PreSplit::Glm4));
1555        // and it is a byte-level vocab, not SPM
1556        assert!(pre_tokenizer_is_byte_level(&pt));
1557    }
1558
1559    /// `collect_split_regexes` walks a Sequence in order and ignores non-Split steps and
1560    /// `{"String": …}` patterns (gemma's `Split{String:" "}` is not a regex).
1561    #[test]
1562    fn collect_split_regexes_walks_in_order() {
1563        let src = r#"{"type":"Sequence","pretokenizers":[
1564            {"type":"Split","pattern":{"Regex":"A"},"behavior":"Isolated"},
1565            {"type":"Split","pattern":{"String":" "},"behavior":"Isolated"},
1566            {"type":"Digits","individual_digits":true},
1567            {"type":"Sequence","pretokenizers":[
1568                {"type":"Split","pattern":{"Regex":"B"},"behavior":"Isolated"}
1569            ]},
1570            {"type":"ByteLevel","add_prefix_space":false}
1571        ]}"#;
1572        let v = json::parse(src).unwrap();
1573        let mut out = Vec::new();
1574        collect_split_regexes(&v, &mut out);
1575        assert_eq!(out, vec!["A".to_string(), "B".to_string()]);
1576    }
1577}
1578
1579#[cfg(test)]
1580mod hf_tests {
1581    use super::*;
1582
1583    /// Inline tokenizer.json fixture: byte-level BPE, ~20 tokens incl one special added
1584    /// token, merges deliberately MIXED between the "a b" string format and the [a, b]
1585    /// pair format (HF emits both across tokenizers versions).
1586    ///
1587    /// The `Split` step carries the REAL qwen35 regex (it was an empty string until
1588    /// 2026-08-19). That is what a shipped Qwen checkpoint looks like, and it is what lets the
1589    /// no-`tokenizer_config.json` test below identify a pre-tokenizer at all — the empty-regex
1590    /// fixture only loaded because an unidentified pre-tokenizer used to fall through silently.
1591    const TOKENIZER_JSON: &str = r#"{
1592      "version": "1.0",
1593      "added_tokens": [
1594        {"id": 15, "content": "<|end|>", "special": true},
1595        {"id": 16, "content": "<think>", "special": false}
1596      ],
1597      "pre_tokenizer": {
1598        "type": "Sequence",
1599        "pretokenizers": [
1600          {"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"},
1601          {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false}
1602        ]
1603      },
1604      "model": {
1605        "type": "BPE",
1606        "vocab": {
1607          "h": 0, "e": 1, "l": 2, "o": 3, "Ġ": 4, "w": 5, "r": 6, "d": 7,
1608          "he": 8, "ll": 9, "hell": 10, "hello": 11, "Ġw": 12, "or": 13, "!": 14
1609        },
1610        "merges": [
1611          "h e",
1612          ["l", "l"],
1613          "he ll",
1614          ["hell", "o"],
1615          ["Ġ", "w"],
1616          "o r"
1617        ]
1618      }
1619    }"#;
1620
1621    fn write_fixture(
1622        name: &str,
1623        tokenizer_config: Option<&str>,
1624        generation_config: Option<&str>,
1625        jinja: Option<&str>,
1626    ) -> std::path::PathBuf {
1627        let dir = std::env::temp_dir().join(format!("memra-tok-hf-{name}-{}", std::process::id()));
1628        let _ = std::fs::remove_dir_all(&dir);
1629        std::fs::create_dir_all(&dir).unwrap();
1630        std::fs::write(dir.join("tokenizer.json"), TOKENIZER_JSON).unwrap();
1631        if let Some(tc) = tokenizer_config {
1632            std::fs::write(dir.join("tokenizer_config.json"), tc).unwrap();
1633        }
1634        if let Some(gc) = generation_config {
1635            std::fs::write(dir.join("generation_config.json"), gc).unwrap();
1636        }
1637        if let Some(j) = jinja {
1638            std::fs::write(dir.join("chat_template.jinja"), j).unwrap();
1639        }
1640        dir
1641    }
1642
1643    /// A GLM-5.3-Flash-shaped checkpoint dir: the shared fixture's BPE with the artifact's
1644    /// three declared eos tokens spliced into `added_tokens` at their REAL ids, plus the two
1645    /// real vendor sidecars byte-for-byte. What is under test is which declared ids survive
1646    /// the load, so the vocab/merges/pre-tokenizer stay the shared fixture's.
1647    fn write_glm53_fixture() -> std::path::PathBuf {
1648        let base_added = r#""added_tokens": [
1649        {"id": 15, "content": "<|end|>", "special": true},
1650        {"id": 16, "content": "<think>", "special": false}
1651      ],"#;
1652        // ids straight off the artifact's own added_tokens table (box, 2026-08-28).
1653        let glm_added = r#""added_tokens": [
1654        {"id": 154820, "content": "<|endoftext|>", "special": true},
1655        {"id": 154826, "content": "<|system|>", "special": true},
1656        {"id": 154827, "content": "<|user|>", "special": true},
1657        {"id": 154828, "content": "<|assistant|>", "special": true},
1658        {"id": 154829, "content": "<|observation|>", "special": true}
1659      ],"#;
1660        assert!(
1661            TOKENIZER_JSON.contains(base_added),
1662            "fixture added_tokens block moved"
1663        );
1664        let tj = TOKENIZER_JSON.replace(base_added, glm_added);
1665        // DENSITY. `from_hf_dir` refuses a tokenizer.json whose ids are sparse relative to its
1666        // entry count (`MAX_TOKENIZER_SPARSE_*`, PR #57) — a real defense: `id_to_token` is a
1667        // DENSE Vec, so a 20-entry file declaring id 154829 would allocate a 154k-slot table
1668        // from a few hundred bytes of attacker-chosen JSON. The GLM artifact itself is dense
1669        // (154830 ids, ~154k entries); it is this FIXTURE that was sparse, having borrowed a
1670        // 17-id vocab and bolted the artifact's real high ids onto it. Fill the gap so the
1671        // fixture has the artifact's SHAPE and the guard sees what it would see in production.
1672        // Ids 17..154820 only: the five added_tokens above own 154820..154829, and 0..16 are
1673        // the shared fixture's own vocab.
1674        let anchor = "\"!\": 14\n";
1675        assert!(tj.contains(anchor), "fixture vocab tail moved");
1676        let mut filler = String::with_capacity(4 << 20);
1677        filler.push_str("\"!\": 14");
1678        for id in 17u32..154_820 {
1679            // A name that can never collide with a real piece, a merge operand, or any of the
1680            // eos NAMES the name-keyed backstop looks for: no merge rule can ever produce it.
1681            filler.push_str(&format!(",\n          \"##fill{id}\": {id}"));
1682        }
1683        filler.push('\n');
1684        let tj = tj.replace(anchor, &filler);
1685
1686        let dir = std::env::temp_dir().join(format!("memra-tok-glm53-{}", std::process::id()));
1687        let _ = std::fs::remove_dir_all(&dir);
1688        std::fs::create_dir_all(&dir).unwrap();
1689        std::fs::write(dir.join("tokenizer.json"), tj).unwrap();
1690        std::fs::write(dir.join("tokenizer_config.json"), GLM53_TOKENIZER_CONFIG).unwrap();
1691        std::fs::write(dir.join("generation_config.json"), GLM53_GENERATION_CONFIG).unwrap();
1692        dir
1693    }
1694
1695    #[test]
1696    fn hf_dir_encode_decode_roundtrip_and_specials() {
1697        // eos as an AddedToken OBJECT + chat_template string in tokenizer_config.
1698        let tc = r#"{
1699          "eos_token": {"content": "<|end|>", "lstrip": false},
1700          "add_bos_token": false,
1701          "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+",
1702          "chat_template": "{{ messages }}<|end|>"
1703        }"#;
1704        let dir = write_fixture("full", Some(tc), None, None);
1705        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1706
1707        assert_eq!(tok.eos_id(), 15);
1708        assert_eq!(tok.bos_id(), None);
1709        assert_eq!(tok.pre(), "qwen35");
1710        assert_eq!(tok.vocab_size(), 17); // ids 0..16 (added tokens extend the table)
1711        assert_eq!(tok.chat_template(), Some("{{ messages }}<|end|>"));
1712
1713        // BPE over both merge formats: "hello world" -> hello(11) Ġw(12) or(13) l(2) d(7).
1714        // The 'hello' chain exercises string merges (h e / he ll), the pair merges
1715        // ([l,l] / [hell,o] / [Ġ,w]) fire inside the same words -> both formats load.
1716        let ids = tok.encode("hello world", true);
1717        assert_eq!(ids, vec![11, 12, 13, 2, 7]);
1718        assert_eq!(tok.decode(&ids), "hello world");
1719
1720        // special handling: <|end|> (Control) is split out BEFORE BPE and never byte-merged.
1721        let ids = tok.encode("hello<|end|> world", true);
1722        assert_eq!(ids, vec![11, 15, 12, 13, 2, 7]);
1723        // decode with specials rendered vs dropped
1724        assert_eq!(tok.decode_special(&ids, true), "hello<|end|> world");
1725        assert_eq!(tok.decode_special(&ids, false), "hello world");
1726
1727        // non-special added token stays Normal: decodes as literal text.
1728        assert_eq!(tok.decode(&[16]), "<think>");
1729        let _ = std::fs::remove_dir_all(&dir);
1730    }
1731
1732    #[test]
1733    fn hf_dir_generation_config_eos_fallback_and_jinja() {
1734        // no tokenizer_config eos -> generation_config eos_token_id (array form) must win;
1735        // chat template comes from chat_template.jinja.
1736        let gc = r#"{"eos_token_id": [15, 14]}"#;
1737        let dir = write_fixture("genconf", None, Some(gc), Some("JINJA {{ messages }}"));
1738        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1739        assert_eq!(tok.eos_id(), 15);
1740        // DELIBERATE CHANGE, 2026-08-27 (lane glm53-flash-bringup): the scalar still takes
1741        // the array's first entry — unchanged — but id 14 is no longer dropped. Every id in
1742        // the vendor's array is a declared stop; keeping only the first is the GLM-5.3 bug.
1743        assert_eq!(tok.eos_ids(), &[15, 14]);
1744        assert_eq!(tok.eog_ids(), vec![15, 14]);
1745        assert!(!tok.encode("hello", true).is_empty());
1746        assert_eq!(tok.chat_template(), Some("JINJA {{ messages }}"));
1747        let _ = std::fs::remove_dir_all(&dir);
1748    }
1749
1750    /// GLM-5.3-Flash's REAL banked sidecars, byte-for-byte.
1751    ///
1752    ///   research/glm53-flash-bringup-20260827/generation_config.json
1753    ///     sha256 230c30609ecbbb9e6583bedde8e7bdda0c6eb8fe5fad0eaeb3d1b293d751cb4f
1754    ///   research/glm53-flash-bringup-20260827/tokenizer_config.json
1755    ///     sha256 98b1271574f41abf89427ae2dda030d94dc9478f0edc5a8bd240db213c6fd5fc
1756    ///
1757    /// Inlined rather than `include_str!`d: these crates publish to crates.io and `research/`
1758    /// is outside every package, so a path include would compile here and break `cargo
1759    /// publish`. The lane dir holds the same bytes for provenance.
1760    const GLM53_GENERATION_CONFIG: &str = r#"{
1761  "_from_model_config": true,
1762  "eos_token_id": [
1763    154820,
1764    154827,
1765    154829
1766  ],
1767  "pad_token_id": 154820,
1768  "temperature": 1.0,
1769  "top_p": 0.95,
1770  "transformers_version": "5.16.0"
1771}
1772"#;
1773    const GLM53_TOKENIZER_CONFIG: &str = r#"{
1774  "backend": "tokenizers",
1775  "clean_up_tokenization_spaces": false,
1776  "do_lower_case": false,
1777  "eos_token": "<|endoftext|>",
1778  "extra_special_tokens": ["<|endoftext|>", "[MASK]", "[gMASK]", "[sMASK]", "<sop>", "<eop>",
1779    "<|system|>", "<|user|>", "<|assistant|>", "<|observation|>", "<|begin_of_image|>",
1780    "<|end_of_image|>", "<|begin_of_video|>", "<|end_of_video|>", "<|begin_of_audio|>",
1781    "<|end_of_audio|>", "<|begin_of_transcription|>", "<|end_of_transcription|>"],
1782  "is_local": true,
1783  "model_max_length": 1048576,
1784  "pad_token": "<|endoftext|>",
1785  "padding_side": "left",
1786  "remove_space": false,
1787  "tokenizer_class": "TokenizersBackend"
1788}"#;
1789
1790    /// THE GLM-5.3-FLASH TRIPLE-EOS PIN (lane glm53-flash-bringup, 2026-08-27).
1791    ///
1792    /// What was broken: `generation_config.json` declares three eos ids and the engine carried
1793    /// one, so a finished answer ran straight through `<|user|>` into a hallucinated
1794    /// multi-turn transcript and the OpenAI surface reported `finish_reason: "length"`
1795    /// (receipts: research/glm53-flash-bringup-20260827/forward-bisect-receipts/).
1796    ///
1797    /// Why <|user|> and <|observation|> are legitimately stops and not arbitrary specials:
1798    /// they are the artifact's TURN BOUNDARIES. chat_template.jinja emits `<|user|>` as the
1799    /// user-turn prefix (line 139) and `<|observation|>` as the tool-result prefix (line 167),
1800    /// with `<|assistant|>` opening the generation the server asked for (line 256); an
1801    /// assistant that emits either has left its own turn. Both are `special: true` added
1802    /// tokens in the artifact's tokenizer.json (ids 154827 / 154829, verified on the box
1803    /// 2026-08-28), i.e. control tokens that can never fall out of ordinary BPE text.
1804    ///
1805    /// The ids come from the vendor sidecar, so the fixture's `tokenizer.json` only has to
1806    /// carry them; the pre-tokenizer here is the shared qwen35 fixture and is not under test.
1807    #[test]
1808    fn hf_dir_glm53_flash_carries_all_three_declared_eos_ids() {
1809        let dir = write_glm53_fixture();
1810        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1811
1812        // The SCALAR is unchanged by this lane: tokenizer_config's eos_token still wins, and
1813        // it is <|endoftext|> — the boot log, embed pooling and the grammar bridges all keep
1814        // seeing exactly what they saw before.
1815        assert_eq!(tok.eos_id(), 154_820);
1816        assert_eq!(tok.id_of("<|endoftext|>"), Some(154_820));
1817        assert_eq!(tok.id_of("<|user|>"), Some(154_827));
1818        assert_eq!(tok.id_of("<|observation|>"), Some(154_829));
1819
1820        // The SET is what the serve path stops on (`worker::run` unions `eog_ids()` into
1821        // `GenParams::eos`). Set EQUALITY, not containment: this vocab has no <|im_end|> /
1822        // <turn|> / <end_of_turn>, so the name-keyed backstop must contribute nothing and the
1823        // three declared ids must be all of it.
1824        assert_eq!(tok.eos_ids(), &[154_820, 154_827, 154_829]);
1825        assert_eq!(tok.eog_ids(), vec![154_820, 154_827, 154_829]);
1826        let _ = std::fs::remove_dir_all(&dir);
1827    }
1828
1829    /// REGRESSION PIN: a single-eos family does not move.
1830    ///
1831    /// Both single-id shapes that ship today — `tokenizer_config.eos_token` with no
1832    /// generation_config at all, and hy3's scalar `generation_config.eos_token_id` — must
1833    /// produce a one-id `eos_ids` and an `eog_ids` identical to the pre-lane
1834    /// `vec![eos_id] + name-keyed backstop`.
1835    #[test]
1836    fn hf_dir_single_eos_family_stop_set_unchanged() {
1837        // shape 1: tokenizer_config eos_token only (no generation_config).
1838        let tc = r#"{"eos_token": "<|end|>", "add_bos_token": false}"#;
1839        let dir = write_fixture("single-tc", Some(tc), None, None);
1840        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1841        assert_eq!(tok.eos_id(), 15);
1842        assert_eq!(tok.eos_ids(), &[15]);
1843        assert_eq!(tok.eog_ids(), vec![15]);
1844        let _ = std::fs::remove_dir_all(&dir);
1845
1846        // shape 2: hy3 — tokenizer_config eos_token AND a scalar generation_config id that
1847        // agrees. One id in, one id out; the scalar arm must not become a one-element array
1848        // that then re-enters as a duplicate.
1849        let dir = write_fixture("single-gc", Some(tc), Some(r#"{"eos_token_id": 15}"#), None);
1850        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1851        assert_eq!(tok.eos_id(), 15);
1852        assert_eq!(tok.eos_ids(), &[15]);
1853        assert_eq!(tok.eog_ids(), vec![15]);
1854        let _ = std::fs::remove_dir_all(&dir);
1855    }
1856
1857    /// THE CENSUS DELTA, made explicit rather than discovered in production.
1858    ///
1859    /// Reading the whole array changes the stop set of every from_hf_dir family whose vendor
1860    /// declares more than one id. Measured 2026-08-27 over the artifacts on the rig:
1861    ///
1862    ///   qwen36/qwen38 27B  eos_token_id [248046, 248044]  -> +248044 `<|endoftext|>`
1863    ///                      (248046 `<|im_end|>` was already in via the name backstop)
1864    ///   qwen35 9B          scalar 248044, tc `<|im_end|>` -> +248044 `<|endoftext|>`
1865    ///   qwen3 1.7B         [151645, 151643]               -> +151643 `<|endoftext|>`
1866    ///   gemma4 26B NVFP4   [1, 106, 50]                   -> +50 `<|tool_response>`
1867    ///                      (1 `<eos>` is the scalar, 106 `<turn|>` was in via the backstop)
1868    ///   hy3                scalar 120025                  -> no change
1869    ///   m3                 scalar 200020                  -> no change
1870    ///   step35 NVFP4       [1, 2, 128007]                 -> +1 end-of-sentence, +2 pad
1871    ///                      (128007 `<|im_end|>` is the scalar; the vendor lists its PAD
1872    ///                       token as an eos — a control token either way)
1873    ///   every GGUF family                                 -> no change by construction
1874    ///   dsv4                                              -> no change (dsv4_serve.rs builds
1875    ///                                                        its stop set from `eos_id()`)
1876    ///
1877    /// Every delta is a `special: true` control token the vendor itself names as a stop, and
1878    /// none can be produced by ordinary BPE over text. This pin holds the qwen shape so the
1879    /// delta is a decision with a test behind it, not a surprise.
1880    #[test]
1881    fn hf_dir_multi_eos_array_extends_the_stop_set_beyond_the_first_id() {
1882        // qwen shape: tokenizer_config names the turn-end token, generation_config's array
1883        // adds the raw end-of-text id after it.
1884        let tc = r#"{"eos_token": "<|end|>", "add_bos_token": false}"#;
1885        let gc = r#"{"eos_token_id": [15, 14]}"#;
1886        let dir = write_fixture("multi", Some(tc), Some(gc), None);
1887        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1888        assert_eq!(
1889            tok.eos_id(),
1890            15,
1891            "the scalar keeps tokenizer_config's choice"
1892        );
1893        assert_eq!(tok.eos_ids(), &[15, 14]);
1894        assert_eq!(tok.eog_ids(), vec![15, 14]);
1895        let _ = std::fs::remove_dir_all(&dir);
1896    }
1897
1898    /// The three-Split deepseek-v3 pre-tokenizer Sequence, byte-for-byte as HF serializes it
1899    /// (Hy3 / Step-3.7-Flash). This is the case that used to land on `default` -> the silent
1900    /// qwen35 fallback even though `unicode::split_deepseek_v3` already existed.
1901    #[test]
1902    fn hf_dir_identifies_deepseek_v3_from_tokenizer_json() {
1903        let dsv3_pt = r##""pre_tokenizer": {
1904        "type": "Sequence",
1905        "pretokenizers": [
1906          {"type": "Split", "pattern": {"Regex": "\\p{N}{1,3}"}, "behavior": "Isolated"},
1907          {"type": "Split", "pattern": {"Regex": "[一-龥぀-ゟ゠-ヿ]+"}, "behavior": "Isolated"},
1908          {"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"},
1909          {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": false}
1910        ]
1911      },"##;
1912        // splice the deepseek pre_tokenizer into the shared fixture in place of the qwen one
1913        let open = TOKENIZER_JSON.find(r#""pre_tokenizer""#).unwrap();
1914        let close = TOKENIZER_JSON.find(r#""model""#).unwrap();
1915        let json = format!(
1916            "{}{}\n      {}",
1917            &TOKENIZER_JSON[..open],
1918            dsv3_pt,
1919            &TOKENIZER_JSON[close..]
1920        );
1921        let dir = std::env::temp_dir().join(format!("memra-tok-hf-dsv3-{}", std::process::id()));
1922        let _ = std::fs::remove_dir_all(&dir);
1923        std::fs::create_dir_all(&dir).unwrap();
1924        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1925        std::fs::write(
1926            dir.join("generation_config.json"),
1927            r#"{"eos_token_id": 15}"#,
1928        )
1929        .unwrap();
1930        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1931        assert_eq!(tok.pre(), "deepseek-v3");
1932        assert_eq!(tok.split(), PreSplit::DeepseekV3);
1933        let _ = std::fs::remove_dir_all(&dir);
1934    }
1935
1936    /// The `qwen2` regex differs from qwen35 by two character classes and must be identified as
1937    /// qwen2, not silently mistaken for qwen35 (they share a state machine but not an id).
1938    #[test]
1939    fn hf_dir_identifies_qwen2_regex() {
1940        let json = TOKENIZER_JSON.replace(
1941            r"[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+",
1942            r"[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+",
1943        );
1944        assert_ne!(json, TOKENIZER_JSON, "the qwen2 substitution must apply");
1945        let dir = std::env::temp_dir().join(format!("memra-tok-hf-qwen2-{}", std::process::id()));
1946        let _ = std::fs::remove_dir_all(&dir);
1947        std::fs::create_dir_all(&dir).unwrap();
1948        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1949        std::fs::write(
1950            dir.join("generation_config.json"),
1951            r#"{"eos_token_id": 15}"#,
1952        )
1953        .unwrap();
1954        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1955        assert_eq!(tok.pre(), "qwen2");
1956        assert_eq!(
1957            tok.split(),
1958            PreSplit::Qwen35,
1959            "qwen2 rides the qwen35 split"
1960        );
1961        let _ = std::fs::remove_dir_all(&dir);
1962    }
1963
1964    /// GLM-5.3-Flash's own shape: `tokenizer.ggml.pre` is absent (the GGUF sentinel is
1965    /// `default`), the checkpoint carries no `pretokenize_regex` sidecar, and the
1966    /// tokenizer.json Split regex is the ONLY identifier. It differs from qwen2 by the single
1967    /// `\p{N}` -> `\p{N}{1,3}` atom, so the byte-exact comparison is the whole gate: a fuzzy
1968    /// match here would route GLM through qwen2's split and every id downstream would be wrong.
1969    #[test]
1970    fn hf_dir_identifies_glm4_regex() {
1971        let json = TOKENIZER_JSON.replace(
1972            r"[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+",
1973            r"[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]+",
1974        );
1975        assert_ne!(json, TOKENIZER_JSON, "the glm4 substitution must apply");
1976        let dir = std::env::temp_dir().join(format!("memra-tok-hf-glm4-{}", std::process::id()));
1977        let _ = std::fs::remove_dir_all(&dir);
1978        std::fs::create_dir_all(&dir).unwrap();
1979        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1980        std::fs::write(
1981            dir.join("generation_config.json"),
1982            r#"{"eos_token_id": 15}"#,
1983        )
1984        .unwrap();
1985        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1986        assert_eq!(tok.pre(), "glm4");
1987        assert_eq!(
1988            tok.split(),
1989            PreSplit::Glm4,
1990            "glm4 gets its OWN split, not qwen35's"
1991        );
1992        // and the SAME fixture with qwen2's `\p{N}` instead resolves elsewhere — the one-atom
1993        // delta is the whole identification, so both directions are pinned here.
1994        let qjson = TOKENIZER_JSON.replace(
1995            r"[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+",
1996            r"[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+",
1997        );
1998        let qwen_dir =
1999            std::env::temp_dir().join(format!("memra-tok-hf-glm4-ctl-{}", std::process::id()));
2000        let _ = std::fs::remove_dir_all(&qwen_dir);
2001        std::fs::create_dir_all(&qwen_dir).unwrap();
2002        std::fs::write(qwen_dir.join("tokenizer.json"), &qjson).unwrap();
2003        std::fs::write(
2004            qwen_dir.join("generation_config.json"),
2005            r#"{"eos_token_id": 15}"#,
2006        )
2007        .unwrap();
2008        let qtok = Tokenizer::from_hf_dir(&qwen_dir).expect("from_hf_dir(qwen2 control)");
2009        assert_eq!(qtok.pre(), "qwen2");
2010        assert_eq!(qtok.split(), PreSplit::Qwen35);
2011        assert_ne!(tok.split(), qtok.split());
2012        // the two splits disagree on a 4-digit run — the reason the variant exists
2013        assert_eq!(unicode::split_glm4("1234"), ["123", "4"]);
2014        assert_eq!(unicode::split_qwen35("1234"), ["1", "2", "3", "4"]);
2015        let _ = std::fs::remove_dir_all(&dir);
2016        let _ = std::fs::remove_dir_all(&qwen_dir);
2017    }
2018
2019    /// An HF checkpoint whose pre-tokenizer matches nothing known is REFUSED, and the error
2020    /// names both observations so the next porter knows what to implement.
2021    #[test]
2022    fn hf_dir_refuses_unidentifiable_pretokenizer() {
2023        // This test's whole point is the REFUSAL path, which the opt-out env var disables, so
2024        // it holds the same lock as `opt_out_env_gate` and clears the variable first: a panic
2025        // partway through that test can otherwise leave the opt-out set.
2026        let _env = pretokenizer_env_lock();
2027        // SAFETY: the lock above makes this thread the only one touching this variable.
2028        unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
2029        let json = TOKENIZER_JSON.replace(r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|", "SOMETHING-ELSE|");
2030        assert_ne!(json, TOKENIZER_JSON);
2031        let dir = std::env::temp_dir().join(format!("memra-tok-hf-unk-{}", std::process::id()));
2032        let _ = std::fs::remove_dir_all(&dir);
2033        std::fs::create_dir_all(&dir).unwrap();
2034        std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
2035        std::fs::write(
2036            dir.join("generation_config.json"),
2037            r#"{"eos_token_id": 15}"#,
2038        )
2039        .unwrap();
2040        let err = match Tokenizer::from_hf_dir(&dir) {
2041            Ok(_) => panic!("unidentifiable pre must refuse to load"),
2042            Err(e) => e,
2043        };
2044        assert!(
2045            err.contains("unsupported tokenizer.ggml.pre 'default'"),
2046            "{err}"
2047        );
2048        assert!(
2049            err.contains("SOMETHING-ELSE"),
2050            "error must quote the regex: {err}"
2051        );
2052        assert!(err.contains("MEMRA_ALLOW_UNKNOWN_PRETOKENIZER"), "{err}");
2053        let _ = std::fs::remove_dir_all(&dir);
2054    }
2055
2056    /// Artifact-backed: real vendor `tokenizer.json` files must resolve to the right split.
2057    /// Per-entry skip when a checkpoint is not staged (same posture as tests/llama_parity.rs) —
2058    /// this is the gate that pins the regex constants against real vendor serializations rather
2059    /// than against our own fixture. Every one of these landed on the silent qwen35 fallback
2060    /// before 2026-08-19 except the Qwen entry (which carries `pretokenize_regex`).
2061    #[test]
2062    fn staged_checkpoints_resolve_their_own_pretokenizer() {
2063        let cases: &[(&str, &str)] = &[
2064            // ships the deepseek-v3 Sequence verbatim; was mis-tokenized as qwen35
2065            (
2066                "/data/ai-ml/hf-models/hy3-layer103p5-sparse-source",
2067                "deepseek-v3",
2068            ),
2069            // qwen2 regex in tokenizer.json, no `pretokenize_regex` sidecar
2070            ("/data/ai-ml/hf-models/qwen3-1.7b-blk128fp8-synth", "qwen2"),
2071            // the control: `pretokenize_regex` present and byte-equal
2072            ("/data/ai-ml/hf-models/qwen35-9b-hf", "qwen35"),
2073        ];
2074        let mut ran = 0;
2075        for (path, want) in cases {
2076            let dir = std::path::Path::new(path);
2077            if !dir.join("tokenizer.json").exists() {
2078                eprintln!("skip: {path} not staged");
2079                continue;
2080            }
2081            let tok = Tokenizer::from_hf_dir(dir).unwrap_or_else(|e| panic!("{path}: {e}"));
2082            assert_eq!(tok.pre(), *want, "{path}");
2083            ran += 1;
2084        }
2085        eprintln!("staged_checkpoints_resolve_their_own_pretokenizer: {ran}/3 cases ran");
2086    }
2087
2088    #[test]
2089    fn hf_dir_rejects_non_byte_level() {
2090        let dir = std::env::temp_dir().join(format!("memra-tok-hf-nonbl-{}", std::process::id()));
2091        let _ = std::fs::remove_dir_all(&dir);
2092        std::fs::create_dir_all(&dir).unwrap();
2093        let bad = TOKENIZER_JSON.replace("\"ByteLevel\"", "\"Metaspace\"");
2094        std::fs::write(dir.join("tokenizer.json"), bad).unwrap();
2095        assert!(Tokenizer::from_hf_dir(&dir).is_err());
2096        let _ = std::fs::remove_dir_all(&dir);
2097    }
2098
2099    /// deepseek-v4 encoding-revision detection from config.json (0731 re-gate,
2100    /// ENCODING-DIFF.md): the dspark_* key census — never a filename — decides the ladder.
2101    #[test]
2102    fn hf_dir_dsv4_encoding_detection() {
2103        let gc = r#"{"eos_token_id": [15]}"#;
2104        let full_dspark = r#""dspark_block_size": 5, "dspark_markov_rank": 256,
2105             "dspark_noise_token_id": 128799, "dspark_target_layer_ids": [40, 41, 42]"#;
2106
2107        // no config.json -> unknown (tokenizer-only ref dirs).
2108        let dir = write_fixture("dsv4-none", None, Some(gc), None);
2109        let tok = Tokenizer::from_hf_dir(&dir).unwrap();
2110        assert_eq!(tok.dsv4_encoding(), None);
2111        let _ = std::fs::remove_dir_all(&dir);
2112
2113        // deepseek_v4 config without dspark keys -> Preview.
2114        let dir = write_fixture("dsv4-preview", None, Some(gc), None);
2115        std::fs::write(
2116            dir.join("config.json"),
2117            r#"{"model_type": "deepseek_v4", "num_hidden_layers": 43}"#,
2118        )
2119        .unwrap();
2120        let tok = Tokenizer::from_hf_dir(&dir).unwrap();
2121        assert_eq!(tok.dsv4_encoding(), Some(chat::Dsv4Encoding::Preview));
2122        let _ = std::fs::remove_dir_all(&dir);
2123
2124        // deepseek_v4 config with ALL FOUR dspark keys -> V0731.
2125        let dir = write_fixture("dsv4-0731", None, Some(gc), None);
2126        std::fs::write(
2127            dir.join("config.json"),
2128            format!(r#"{{"model_type": "deepseek_v4", {full_dspark}}}"#),
2129        )
2130        .unwrap();
2131        let tok = Tokenizer::from_hf_dir(&dir).unwrap();
2132        assert_eq!(tok.dsv4_encoding(), Some(chat::Dsv4Encoding::V0731));
2133        let _ = std::fs::remove_dir_all(&dir);
2134
2135        // a PARTIAL dspark key set is ambiguous -> the load refuses.
2136        let dir = write_fixture("dsv4-partial", None, Some(gc), None);
2137        std::fs::write(
2138            dir.join("config.json"),
2139            r#"{"model_type": "deepseek_v4", "dspark_block_size": 5}"#,
2140        )
2141        .unwrap();
2142        let err = match Tokenizer::from_hf_dir(&dir) {
2143            Err(e) => e,
2144            Ok(_) => panic!("a partial dspark_* config must refuse the load"),
2145        };
2146        assert!(err.contains("partial dspark_*"), "{err}");
2147        let _ = std::fs::remove_dir_all(&dir);
2148
2149        // another family's config makes no dsv4 encoding claim -> unknown.
2150        let dir = write_fixture("dsv4-foreign", None, Some(gc), None);
2151        std::fs::write(dir.join("config.json"), r#"{"model_type": "qwen3"}"#).unwrap();
2152        let tok = Tokenizer::from_hf_dir(&dir).unwrap();
2153        assert_eq!(tok.dsv4_encoding(), None);
2154        let _ = std::fs::remove_dir_all(&dir);
2155    }
2156}