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(®exes), 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}