1pub mod chat;
13mod json;
14mod unicode;
15mod unicode_data;
16
17pub use chat::apply_chat_template_str;
18
19use memra_gguf::{GgufFile, MetaValue};
20use std::cmp::Ordering;
21use std::collections::{BinaryHeap, HashMap};
22
23const TT_UNKNOWN: i64 = 2;
25const TT_CONTROL: i64 = 3;
26const TT_USER_DEFINED: i64 = 4;
27const TT_BYTE: i64 = 6;
28const QWEN35_PRETOKENIZE_REGEX: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
29const 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+";
33const DEEPSEEK_V3_SPLIT_REGEXES: [&str; 3] = [
38 r"\p{N}{1,3}",
39 "[\u{4e00}-\u{9fa5}\u{3040}-\u{309f}\u{30a0}-\u{30ff}]+",
40 "[!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~][A-Za-z]+|[^\r\n\\p{L}\\p{P}\\p{S}]?[\\p{L}\\p{M}]+| ?[\\p{P}\\p{S}]+[\r\n]*|\\s*[\r\n]+|\\s+(?!\\S)|\\s+",
41];
42
43pub const SUPPORTED_PRETOKENIZERS: &[&str] = &["qwen35", "qwen2", "deepseek-v3", "gemma4"];
46
47pub const ALLOW_UNKNOWN_PRETOKENIZER_ENV: &str = "MEMRA_ALLOW_UNKNOWN_PRETOKENIZER";
50
51fn allow_unknown_pretokenizer() -> bool {
52 std::env::var(ALLOW_UNKNOWN_PRETOKENIZER_ENV).as_deref() == Ok("1")
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct UnknownPretokenizer {
64 pub pre: String,
67 pub spm_style: bool,
70}
71
72impl std::fmt::Display for UnknownPretokenizer {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 write!(
75 f,
76 "unsupported tokenizer.ggml.pre '{}' (vocab model is {}) — memra has no exact \
77 pre-tokenizer split for it and token ids would NOT be exact. Supported: {}. \
78 Set {}=1 to load anyway for deliberate experimentation (token ids will be wrong).",
79 self.pre,
80 if self.spm_style { "SPM/gemma4" } else { "gpt2" },
81 SUPPORTED_PRETOKENIZERS.join(", "),
82 ALLOW_UNKNOWN_PRETOKENIZER_ENV,
83 )
84 }
85}
86
87impl std::error::Error for UnknownPretokenizer {}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum PreSplit {
94 Qwen35,
96 DeepseekV3,
98 Spm,
101 UnknownFallbackQwen35,
104}
105
106impl PreSplit {
107 pub fn resolve(pre: &str, spm_style: bool) -> Result<Self, UnknownPretokenizer> {
110 Self::resolve_with(pre, spm_style, allow_unknown_pretokenizer())
111 }
112
113 fn resolve_with(
116 pre: &str,
117 spm_style: bool,
118 allow_unknown: bool,
119 ) -> Result<Self, UnknownPretokenizer> {
120 match (pre, spm_style) {
124 ("qwen35" | "qwen2", false) => Ok(PreSplit::Qwen35),
125 ("deepseek-v3", false) => Ok(PreSplit::DeepseekV3),
126 ("gemma4", true) => Ok(PreSplit::Spm),
127 _ => {
128 let err = UnknownPretokenizer {
129 pre: pre.to_string(),
130 spm_style,
131 };
132 if allow_unknown {
133 eprintln!(
136 "memra-tokenizer: WARNING {ALLOW_UNKNOWN_PRETOKENIZER_ENV}=1 — loading \
137 with {err} FALLING BACK to the qwen35 split. Token ids are NOT exact: \
138 goldens, parity fixtures, acceptance counts and quality numbers taken \
139 on this model are all invalid."
140 );
141 Ok(PreSplit::UnknownFallbackQwen35)
142 } else {
143 Err(err)
144 }
145 }
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum TokAttr {
152 Normal,
153 Unknown,
154 Control,
155 UserDefined,
156 Byte,
157 Other,
158}
159
160impl TokAttr {
161 fn from_toktype(t: i64) -> Self {
162 match t {
163 TT_UNKNOWN => TokAttr::Unknown,
164 TT_CONTROL => TokAttr::Control,
165 TT_USER_DEFINED => TokAttr::UserDefined,
166 TT_BYTE => TokAttr::Byte,
167 1 => TokAttr::Normal,
168 _ => TokAttr::Other,
169 }
170 }
171 fn is_special(self) -> bool {
174 matches!(
175 self,
176 TokAttr::Control | TokAttr::UserDefined | TokAttr::Unknown
177 )
178 }
179}
180
181pub struct Tokenizer {
182 id_to_token: Vec<String>,
184 token_to_id: HashMap<String, u32>,
186 attrs: Vec<TokAttr>,
188 bpe_ranks: HashMap<(String, String), i32>,
190 special_tokens: Vec<u32>,
192 eos_id: u32,
193 bos_id: Option<u32>,
194 add_bos: bool,
195 pre: String,
196 split: PreSplit,
199 chat_template: Option<String>,
200 spm_style: bool,
202}
203
204#[derive(Clone, Eq, PartialEq)]
209struct Bigram {
210 left: i32,
211 right: i32,
212 rank: i32,
213 text: String,
214}
215
216impl Ord for Bigram {
217 fn cmp(&self, other: &Self) -> Ordering {
218 match other.rank.cmp(&self.rank) {
221 Ordering::Equal => other.left.cmp(&self.left),
222 o => o,
223 }
224 }
225}
226impl PartialOrd for Bigram {
227 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
228 Some(self.cmp(other))
229 }
230}
231
232struct Symbol {
234 text: String,
235 prev: i32,
236 next: i32,
237 n: usize, }
239
240impl Tokenizer {
241 pub fn from_gguf(g: &GgufFile) -> Result<Self, String> {
243 let model = g
244 .metadata
245 .get("tokenizer.ggml.model")
246 .and_then(|v| v.as_str())
247 .ok_or("missing tokenizer.ggml.model")?;
248 if model != "gpt2" && model != "gemma4" {
249 return Err(format!(
250 "unsupported tokenizer model '{model}' (only gpt2/gemma4)"
251 ));
252 }
253 let spm_style = model == "gemma4";
257 let pre = g
258 .metadata
259 .get("tokenizer.ggml.pre")
260 .and_then(|v| v.as_str())
261 .unwrap_or(if spm_style { "gemma4" } else { "default" })
262 .to_string();
263 let split = PreSplit::resolve(&pre, spm_style).map_err(|e| e.to_string())?;
266
267 let tokens = match g.metadata.get("tokenizer.ggml.tokens") {
269 Some(MetaValue::Array(a)) => a,
270 _ => return Err("missing tokenizer.ggml.tokens array".into()),
271 };
272 let n = tokens.len();
273 let mut id_to_token = Vec::with_capacity(n);
274 let mut token_to_id = HashMap::with_capacity(n);
275 for (i, t) in tokens.iter().enumerate() {
276 let s = t.as_str().ok_or("non-string in tokens[]")?.to_string();
277 token_to_id.entry(s.clone()).or_insert(i as u32);
279 id_to_token.push(s);
280 }
281
282 let mut attrs = vec![TokAttr::Normal; n];
284 if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.token_type") {
285 for (i, v) in a.iter().enumerate().take(n) {
286 if let Some(t) = v.as_u64() {
287 attrs[i] = TokAttr::from_toktype(t as i64);
288 } else if let MetaValue::I32(t) = v {
289 attrs[i] = TokAttr::from_toktype(*t as i64);
290 }
291 }
292 }
293
294 let mut bpe_ranks = HashMap::new();
296 if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.merges") {
297 for (i, v) in a.iter().enumerate() {
298 let word = v.as_str().ok_or("non-string in merges[]")?;
299 let bytes = word.as_bytes();
303 if let Some(pos) = bytes.iter().skip(1).position(|&b| b == b' ').map(|p| p + 1) {
304 let first = word[..pos].to_string();
305 let second = word[pos + 1..].to_string();
306 bpe_ranks.insert((first, second), i as i32);
307 }
308 }
309 } else {
310 return Err("missing tokenizer.ggml.merges array".into());
311 }
312
313 let mut special_tokens: Vec<u32> = (0..n as u32)
315 .filter(|&id| attrs[id as usize].is_special())
316 .collect();
317 special_tokens.sort_by(|&a, &b| {
318 id_to_token[b as usize]
319 .len()
320 .cmp(&id_to_token[a as usize].len())
321 });
322
323 let eos_id = g
324 .metadata
325 .get("tokenizer.ggml.eos_token_id")
326 .and_then(|v| v.as_u64())
327 .map(|v| v as u32)
328 .ok_or("missing tokenizer.ggml.eos_token_id")?;
329 let bos_id = g
330 .metadata
331 .get("tokenizer.ggml.bos_token_id")
332 .and_then(|v| v.as_u64())
333 .map(|v| v as u32);
334 let add_bos = g
335 .metadata
336 .get("tokenizer.ggml.add_bos_token")
337 .and_then(|v| match v {
338 MetaValue::Bool(b) => Some(*b),
339 _ => v.as_u64().map(|x| x != 0),
340 })
341 .unwrap_or(false);
342 let add_bos = add_bos || spm_style;
343
344 let chat_template = g
345 .metadata
346 .get("tokenizer.chat_template")
347 .and_then(|v| v.as_str())
348 .map(|s| s.to_string());
349
350 Ok(Tokenizer {
351 id_to_token,
352 token_to_id,
353 attrs,
354 bpe_ranks,
355 special_tokens,
356 eos_id,
357 bos_id,
358 add_bos,
359 pre,
360 split,
361 chat_template,
362 spm_style,
363 })
364 }
365
366 pub fn from_hf_dir(dir: &std::path::Path) -> Result<Self, String> {
384 let tj_path = dir.join("tokenizer.json");
385 let text = std::fs::read_to_string(&tj_path)
386 .map_err(|e| format!("read {}: {e}", tj_path.display()))?;
387 let tj = json::parse(&text).map_err(|e| format!("{}: {e}", tj_path.display()))?;
388
389 let model = tj.get("model").ok_or("tokenizer.json: missing model")?;
390 if let Some(t) = model.get("type").and_then(|v| v.as_str()) {
391 if t != "BPE" {
392 return Err(format!(
393 "unsupported tokenizer.json model type '{t}' (only BPE)"
394 ));
395 }
396 }
397 let pre_tok = tj
399 .get("pre_tokenizer")
400 .ok_or("tokenizer.json: missing pre_tokenizer")?;
401 if !pre_tokenizer_is_byte_level(pre_tok) {
402 return Err(
403 "tokenizer.json: pre_tokenizer is not ByteLevel — only byte-level \
404 BPE is supported"
405 .into(),
406 );
407 }
408
409 let vocab = model
411 .get("vocab")
412 .and_then(|v| v.as_obj())
413 .ok_or("tokenizer.json: missing model.vocab")?;
414 let empty: Vec<json::Value> = Vec::new();
415 let added = tj
416 .get("added_tokens")
417 .and_then(|v| v.as_arr())
418 .unwrap_or(&empty);
419 let mut max_id = 0u32;
420 for v in vocab.values() {
421 let id =
422 v.as_u64()
423 .ok_or("tokenizer.json: non-integer id in model.vocab")? as u32;
424 max_id = max_id.max(id);
425 }
426 for a in added {
427 if let Some(id) = a.get("id").and_then(|v| v.as_u64()) {
428 max_id = max_id.max(id as u32);
429 }
430 }
431 let n = max_id as usize + 1;
432 let mut id_to_token = vec![String::new(); n];
433 let mut token_to_id: HashMap<String, u32> = HashMap::with_capacity(n);
434 let mut attrs = vec![TokAttr::Normal; n];
435 for (tok, v) in vocab {
436 let id = v.as_u64().unwrap() as u32;
437 id_to_token[id as usize] = tok.clone();
438 token_to_id.entry(tok.clone()).or_insert(id);
439 }
440 for a in added {
443 let id =
444 a.get("id")
445 .and_then(|v| v.as_u64())
446 .ok_or("tokenizer.json: added_tokens entry missing id")? as u32;
447 let content = a
448 .get("content")
449 .and_then(|v| v.as_str())
450 .ok_or("tokenizer.json: added_tokens entry missing content")?;
451 if id_to_token[id as usize].is_empty() {
452 id_to_token[id as usize] = content.to_string();
453 }
454 token_to_id.entry(content.to_string()).or_insert(id);
455 if a.get("special").and_then(|v| v.as_bool()).unwrap_or(false) {
456 attrs[id as usize] = TokAttr::Control;
457 } else {
458 attrs[id as usize] = TokAttr::UserDefined;
464 }
465 }
466
467 let merges = model
469 .get("merges")
470 .and_then(|v| v.as_arr())
471 .ok_or("tokenizer.json: missing model.merges")?;
472 let mut bpe_ranks = HashMap::with_capacity(merges.len());
473 for (i, m) in merges.iter().enumerate() {
474 let (first, second) = match m {
475 json::Value::Str(s) => {
476 let bytes = s.as_bytes();
479 let pos = bytes
480 .iter()
481 .skip(1)
482 .position(|&b| b == b' ')
483 .map(|p| p + 1)
484 .ok_or_else(|| format!("tokenizer.json: merges[{i}] has no space"))?;
485 (s[..pos].to_string(), s[pos + 1..].to_string())
486 }
487 json::Value::Arr(a) if a.len() == 2 => {
488 let f = a[0]
489 .as_str()
490 .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
491 let s2 = a[1]
492 .as_str()
493 .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
494 (f.to_string(), s2.to_string())
495 }
496 _ => {
497 return Err(format!(
498 "tokenizer.json: merges[{i}] is neither \"a b\" string nor [a, b] pair"
499 ));
500 }
501 };
502 bpe_ranks.insert((first, second), i as i32);
503 }
504
505 let mut special_tokens: Vec<u32> = (0..n as u32)
507 .filter(|&id| attrs[id as usize].is_special())
508 .collect();
509 special_tokens.sort_by(|&a, &b| {
510 id_to_token[b as usize]
511 .len()
512 .cmp(&id_to_token[a as usize].len())
513 });
514
515 let tc = std::fs::read_to_string(dir.join("tokenizer_config.json"))
517 .ok()
518 .and_then(|t| json::parse(&t).ok());
519 let gc = std::fs::read_to_string(dir.join("generation_config.json"))
520 .ok()
521 .and_then(|t| json::parse(&t).ok());
522
523 let tok_content = |v: &json::Value| -> Option<String> {
525 v.as_str().map(|s| s.to_string()).or_else(|| {
526 v.get("content")
527 .and_then(|c| c.as_str())
528 .map(|s| s.to_string())
529 })
530 };
531 let eos_from_cfg = tc
532 .as_ref()
533 .and_then(|c| c.get("eos_token"))
534 .and_then(&tok_content)
535 .and_then(|s| token_to_id.get(&s).copied());
536 let eos_from_gen = gc
538 .as_ref()
539 .and_then(|c| c.get("eos_token_id"))
540 .and_then(|v| match v {
541 json::Value::Num(_) => v.as_u64(),
542 json::Value::Arr(a) => a.first().and_then(|x| x.as_u64()),
543 _ => None,
544 })
545 .map(|v| v as u32);
546 let eos_id = eos_from_cfg.or(eos_from_gen).ok_or(
547 "no eos token: need tokenizer_config.json eos_token or \
548 generation_config.json eos_token_id",
549 )?;
550 let bos_id = tc
551 .as_ref()
552 .and_then(|c| c.get("bos_token"))
553 .and_then(&tok_content)
554 .and_then(|s| token_to_id.get(&s).copied());
555 let add_bos = tc
556 .as_ref()
557 .and_then(|c| c.get("add_bos_token"))
558 .and_then(|v| v.as_bool())
559 .unwrap_or(false);
560
561 let chat_template = tc
563 .as_ref()
564 .and_then(|c| c.get("chat_template"))
565 .and_then(|v| v.as_str())
566 .map(|s| s.to_string())
567 .or_else(|| std::fs::read_to_string(dir.join("chat_template.jinja")).ok());
568 let cfg_regex = tc
576 .as_ref()
577 .and_then(|c| c.get("pretokenize_regex"))
578 .and_then(|v| v.as_str());
579 let mut tj_regexes: Vec<String> = Vec::new();
580 collect_split_regexes(pre_tok, &mut tj_regexes);
581 let pre = cfg_regex
582 .and_then(|r| pre_from_split_regexes(std::slice::from_ref(&r.to_string())))
583 .or_else(|| pre_from_split_regexes(&tj_regexes))
584 .unwrap_or("default");
585 let split = PreSplit::resolve(pre, false).map_err(|e| {
586 if pre == "default" {
587 format!(
588 "{e}\n (HF checkpoint {}: tokenizer_config.json pretokenize_regex = {:?}, \
589 tokenizer.json pre_tokenizer Split regexes = {:?} — neither matched a known \
590 family)",
591 dir.display(),
592 cfg_regex,
593 tj_regexes,
594 )
595 } else {
596 e.to_string()
597 }
598 })?;
599
600 Ok(Tokenizer {
601 id_to_token,
602 token_to_id,
603 attrs,
604 bpe_ranks,
605 special_tokens,
606 eos_id,
607 bos_id,
608 add_bos,
609 pre: pre.to_string(),
610 split,
611 chat_template,
612 spm_style: false,
613 })
614 }
615
616 pub fn eos_id(&self) -> u32 {
617 self.eos_id
618 }
619 pub fn id_of(&self, piece: &str) -> Option<u32> {
621 self.token_to_id.get(piece).copied()
622 }
623 pub fn eog_ids(&self) -> Vec<u32> {
626 let mut ids = vec![self.eos_id];
627 for t in ["<|im_end|>", "<turn|>", "<end_of_turn>"] {
628 if let Some(&id) = self.token_to_id.get(t) {
629 if !ids.contains(&id) {
630 ids.push(id);
631 }
632 }
633 }
634 ids
635 }
636 pub fn bos_id(&self) -> Option<u32> {
637 self.bos_id
638 }
639 pub fn vocab_size(&self) -> usize {
640 self.id_to_token.len()
641 }
642 pub fn pre(&self) -> &str {
643 &self.pre
644 }
645 pub fn split(&self) -> PreSplit {
648 self.split
649 }
650 pub fn chat_template(&self) -> Option<&str> {
651 self.chat_template.as_deref()
652 }
653
654 #[inline]
655 fn text_to_token(&self, s: &str) -> Option<u32> {
656 self.token_to_id.get(s).copied()
657 }
658
659 fn find_bpe_rank(&self, left: &str, right: &str) -> i32 {
660 self.bpe_ranks
661 .get(&(left.to_string(), right.to_string()))
662 .copied()
663 .unwrap_or(-1)
664 }
665
666 pub fn encode(&self, text: &str, add_special: bool) -> Vec<u32> {
672 self.encode_special(text, add_special, true)
673 }
674
675 pub fn encode_special(&self, text: &str, add_special: bool, parse_special: bool) -> Vec<u32> {
676 let mut output: Vec<u32> = Vec::new();
677 if add_special && self.add_bos {
678 if let Some(b) = self.bos_id {
679 output.push(b);
680 }
681 }
682 if text.is_empty() {
683 return output;
684 }
685
686 for frag in self.st_partition(text, parse_special) {
688 match frag {
689 Fragment::Token(id) => output.push(id),
690 Fragment::Text(span) => self.bpe_tokenize(&span, &mut output),
691 }
692 }
693 output
694 }
695
696 fn st_partition(&self, text: &str, parse_special: bool) -> Vec<Fragment> {
698 let mut frags = vec![Fragment::Text(text.to_string())];
699 for &sid in &self.special_tokens {
700 let attr = self.attrs[sid as usize];
701 if !parse_special && matches!(attr, TokAttr::Control | TokAttr::Unknown) {
703 continue;
704 }
705 let needle = &self.id_to_token[sid as usize];
706 if needle.is_empty() {
707 continue;
708 }
709 let mut next: Vec<Fragment> = Vec::with_capacity(frags.len());
710 for f in frags.drain(..) {
711 match f {
712 Fragment::Token(id) => next.push(Fragment::Token(id)),
713 Fragment::Text(s) => {
714 let mut rest: &str = &s;
715 let mut acc = String::new();
716 while let Some(m) = rest.find(needle.as_str()) {
717 acc.push_str(&rest[..m]);
718 if !acc.is_empty() {
719 next.push(Fragment::Text(std::mem::take(&mut acc)));
720 }
721 next.push(Fragment::Token(sid));
722 rest = &rest[m + needle.len()..];
723 }
724 acc.push_str(rest);
725 if !acc.is_empty() {
726 next.push(Fragment::Text(acc));
727 }
728 }
729 }
730 }
731 frags = next;
732 }
733 frags
734 }
735
736 fn bpe_tokenize(&self, text: &str, output: &mut Vec<u32>) {
738 if self.spm_style {
739 let escaped: String = text
742 .chars()
743 .map(|c| if c == ' ' { '\u{2581}' } else { c })
744 .collect();
745 let mut words: Vec<String> = Vec::new();
746 let mut cur = String::new();
747 let mut cur_nl: Option<bool> = None;
748 for c in escaped.chars() {
749 let nl = c == '\n';
750 if cur_nl != Some(nl) && !cur.is_empty() {
751 words.push(std::mem::take(&mut cur));
752 }
753 cur_nl = Some(nl);
754 cur.push(c);
755 }
756 if !cur.is_empty() {
757 words.push(cur);
758 }
759 for word in &words {
760 if word.chars().all(|c| c == '\n') {
762 if let Some(tok) = self.text_to_token(word) {
763 output.push(tok);
764 continue;
765 }
766 }
767 self.bpe_merge_word(word, output);
768 }
769 return;
770 }
771 let words: Vec<String> = match self.split {
777 PreSplit::Qwen35 => unicode::split_qwen35(text),
780 PreSplit::DeepseekV3 => unicode::split_deepseek_v3(text),
784 PreSplit::UnknownFallbackQwen35 => unicode::split_qwen35(text),
787 PreSplit::Spm => unreachable!("PreSplit::Spm implies spm_style, handled above"),
790 };
791
792 for word in &words {
793 let word = unicode::byte_encode(word);
794 self.bpe_merge_word(&word, output);
795 }
796 }
797
798 fn bpe_merge_word(&self, word: &str, output: &mut Vec<u32>) {
801 {
802 let word = word.to_string();
803
804 let chars: Vec<char> = word.chars().collect();
806 let mut symbols: Vec<Symbol> = Vec::with_capacity(chars.len());
807 for (i, &c) in chars.iter().enumerate() {
808 symbols.push(Symbol {
809 text: c.to_string(),
810 prev: i as i32 - 1,
811 next: if i + 1 == chars.len() {
812 -1
813 } else {
814 i as i32 + 1
815 },
816 n: 1,
817 });
818 }
819
820 let mut queue: BinaryHeap<Bigram> = BinaryHeap::new();
822 for i in 1..symbols.len() {
823 self.add_bigram(&symbols, i as i32 - 1, i as i32, &mut queue);
824 }
825
826 while let Some(bigram) = queue.pop() {
828 let li = bigram.left as usize;
829 let ri = bigram.right as usize;
830 if symbols[li].n == 0 || symbols[ri].n == 0 {
831 continue;
832 }
833 let combined = format!("{}{}", symbols[li].text, symbols[ri].text);
834 if combined != bigram.text {
835 continue; }
837 symbols[li].text = combined;
839 symbols[li].n += symbols[ri].n;
840 symbols[ri].n = 0;
841 let r_next = symbols[ri].next;
842 symbols[li].next = r_next;
843 if r_next >= 0 {
844 symbols[r_next as usize].prev = bigram.left;
845 }
846 let l_prev = symbols[li].prev;
847 let l_next = symbols[li].next;
848 self.add_bigram(&symbols, l_prev, bigram.left, &mut queue);
849 self.add_bigram(&symbols, bigram.left, l_next, &mut queue);
850 }
851
852 for sym in &symbols {
854 if sym.n == 0 {
855 continue;
856 }
857 match self.text_to_token(&sym.text) {
858 Some(tok) => output.push(tok),
859 None => {
860 for b in sym.text.bytes() {
862 let bs = if self.spm_style {
863 format!("<0x{b:02X}>") } else {
865 (b as char).to_string()
866 };
867 if let Some(t) = self.text_to_token(&bs) {
868 output.push(t);
869 }
870 }
871 }
872 }
873 }
874 }
875 }
876
877 fn add_bigram(
878 &self,
879 symbols: &[Symbol],
880 left: i32,
881 right: i32,
882 queue: &mut BinaryHeap<Bigram>,
883 ) {
884 if left == -1 || right == -1 {
885 return;
886 }
887 let lt = &symbols[left as usize].text;
888 let rt = &symbols[right as usize].text;
889 let rank = self.find_bpe_rank(lt, rt);
890 if rank < 0 {
891 return;
892 }
893 queue.push(Bigram {
894 left,
895 right,
896 rank,
897 text: format!("{lt}{rt}"),
898 });
899 }
900
901 pub fn decode(&self, ids: &[u32]) -> String {
904 self.decode_special(ids, true)
905 }
906
907 pub fn token_is_control(&self, id: u32) -> bool {
912 match self.attrs.get(id as usize) {
913 Some(TokAttr::Control) | Some(TokAttr::Unknown) => true,
914 _ => false,
915 }
916 }
917
918 pub fn decode_special(&self, ids: &[u32], special: bool) -> String {
919 String::from_utf8_lossy(&self.decode_bytes_special(ids, special)).into_owned()
920 }
921
922 pub fn decode_bytes_special(&self, ids: &[u32], special: bool) -> Vec<u8> {
925 let mut bytes: Vec<u8> = Vec::new();
926 for &id in ids {
927 let i = id as usize;
928 if i >= self.id_to_token.len() {
929 continue;
930 }
931 let attr = self.attrs[i];
932 let piece = &self.id_to_token[i];
933 match attr {
934 TokAttr::Normal | TokAttr::Byte => {
935 if self.spm_style {
936 if matches!(attr, TokAttr::Byte)
938 || (piece.len() == 6
939 && piece.starts_with("<0x")
940 && piece.ends_with('>'))
941 {
942 if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
943 bytes.push(b);
944 continue;
945 }
946 }
947 for c in piece.chars() {
948 if c == '\u{2581}' {
949 bytes.push(b' ');
950 } else {
951 let mut buf = [0u8; 4];
952 bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
953 }
954 }
955 } else {
956 self.piece_to_bytes(piece, &mut bytes);
958 }
959 }
960 TokAttr::UserDefined => {
961 bytes.extend_from_slice(piece.as_bytes());
963 }
964 TokAttr::Control | TokAttr::Unknown => {
965 if special {
966 bytes.extend_from_slice(piece.as_bytes());
967 }
968 }
970 TokAttr::Other => {}
971 }
972 }
973 bytes
974 }
975
976 fn piece_to_bytes(&self, piece: &str, out: &mut Vec<u8>) {
977 for c in piece.chars() {
978 match unicode::unicode_to_byte(c) {
979 Some(b) => out.push(b),
980 None => {
981 let mut buf = [0u8; 4];
983 out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
984 }
985 }
986 }
987 }
988
989 pub fn apply_chat_template(
992 &self,
993 messages: &[(&str, &str)],
994 add_generation_prompt: bool,
995 ) -> String {
996 chat::apply_chat_template_str(
997 self.chat_template.as_deref(),
998 messages,
999 add_generation_prompt,
1000 )
1001 }
1002
1003 pub fn apply_chat_template_tools(
1007 &self,
1008 turns: &[chat::Turn],
1009 add_generation_prompt: bool,
1010 tools_json: &[String],
1011 think: chat::ThinkMode,
1012 reasoning_effort: Option<&str>,
1013 ) -> Result<String, String> {
1014 chat::apply_chat_template_tools(
1015 self.chat_template.as_deref(),
1016 turns,
1017 add_generation_prompt,
1018 tools_json,
1019 think,
1020 reasoning_effort,
1021 )
1022 }
1023
1024 #[allow(clippy::too_many_arguments)]
1028 pub fn apply_chat_template_tools_ex(
1029 &self,
1030 turns: &[chat::Turn],
1031 add_generation_prompt: bool,
1032 tools_json: &[String],
1033 tools_struct: &[chat::Val],
1034 think: chat::ThinkMode,
1035 reasoning_effort: Option<&str>,
1036 ) -> Result<String, String> {
1037 chat::apply_chat_template_tools_ex(
1038 self.chat_template.as_deref(),
1039 turns,
1040 add_generation_prompt,
1041 tools_json,
1042 tools_struct,
1043 think,
1044 reasoning_effort,
1045 )
1046 }
1047}
1048
1049enum Fragment {
1050 Text(String),
1051 Token(u32),
1052}
1053
1054fn collect_split_regexes(pt: &json::Value, out: &mut Vec<String>) {
1060 match pt.get("type").and_then(|v| v.as_str()) {
1061 Some("Sequence") => {
1062 if let Some(arr) = pt.get("pretokenizers").and_then(|v| v.as_arr()) {
1063 for step in arr {
1064 collect_split_regexes(step, out);
1065 }
1066 }
1067 }
1068 Some("Split") => {
1069 if let Some(r) = pt
1070 .get("pattern")
1071 .and_then(|p| p.get("Regex"))
1072 .and_then(|v| v.as_str())
1073 {
1074 out.push(r.to_string());
1075 }
1076 }
1077 _ => {}
1078 }
1079}
1080
1081fn pre_from_split_regexes(regexes: &[String]) -> Option<&'static str> {
1086 match regexes {
1087 [one] if one == QWEN35_PRETOKENIZE_REGEX => Some("qwen35"),
1088 [one] if one == QWEN2_PRETOKENIZE_REGEX => Some("qwen2"),
1089 [a, b, c]
1090 if a == DEEPSEEK_V3_SPLIT_REGEXES[0]
1091 && b == DEEPSEEK_V3_SPLIT_REGEXES[1]
1092 && c == DEEPSEEK_V3_SPLIT_REGEXES[2] =>
1093 {
1094 Some("deepseek-v3")
1095 }
1096 _ => None,
1097 }
1098}
1099
1100fn pre_tokenizer_is_byte_level(pt: &json::Value) -> bool {
1101 match pt.get("type").and_then(|v| v.as_str()) {
1102 Some("ByteLevel") => true,
1103 Some("Sequence") => pt
1104 .get("pretokenizers")
1105 .and_then(|v| v.as_arr())
1106 .map(|arr| arr.iter().any(pre_tokenizer_is_byte_level))
1107 .unwrap_or(false),
1108 _ => false,
1109 }
1110}
1111
1112#[cfg(test)]
1113mod pretokenizer_tests {
1114 use super::*;
1115
1116 #[test]
1120 fn every_supported_pre_resolves() {
1121 assert_eq!(
1122 PreSplit::resolve_with("qwen35", false, false),
1123 Ok(PreSplit::Qwen35)
1124 );
1125 assert_eq!(
1126 PreSplit::resolve_with("qwen2", false, false),
1127 Ok(PreSplit::Qwen35)
1128 );
1129 assert_eq!(
1130 PreSplit::resolve_with("deepseek-v3", false, false),
1131 Ok(PreSplit::DeepseekV3)
1132 );
1133 assert_eq!(
1134 PreSplit::resolve_with("gemma4", true, false),
1135 Ok(PreSplit::Spm)
1136 );
1137 assert_eq!(
1140 SUPPORTED_PRETOKENIZERS,
1141 &["qwen35", "qwen2", "deepseek-v3", "gemma4"]
1142 );
1143 }
1144
1145 #[test]
1147 fn unknown_pre_is_a_typed_error() {
1148 let err =
1149 PreSplit::resolve_with("llama4", false, false).expect_err("llama4 has no ported split");
1150 assert_eq!(
1151 err,
1152 UnknownPretokenizer {
1153 pre: "llama4".into(),
1154 spm_style: false
1155 }
1156 );
1157 let msg = err.to_string();
1158 assert!(msg.contains("'llama4'"), "{msg}");
1160 for supported in SUPPORTED_PRETOKENIZERS {
1161 assert!(
1162 msg.contains(supported),
1163 "error must list {supported}: {msg}"
1164 );
1165 }
1166 assert!(msg.contains(ALLOW_UNKNOWN_PRETOKENIZER_ENV), "{msg}");
1167 let _: &dyn std::error::Error = &err;
1169 }
1170
1171 #[test]
1174 fn pre_and_vocab_model_must_agree() {
1175 assert!(PreSplit::resolve_with("qwen35", true, false).is_err());
1176 assert!(PreSplit::resolve_with("gemma4", false, false).is_err());
1177 assert!(PreSplit::resolve_with("default", false, false).is_err());
1179 assert!(PreSplit::resolve_with("", false, false).is_err());
1180 }
1181
1182 #[test]
1184 fn opt_out_loads_with_a_fallback_marker() {
1185 assert_eq!(
1186 PreSplit::resolve_with("llama4", false, true),
1187 Ok(PreSplit::UnknownFallbackQwen35)
1188 );
1189 assert_eq!(
1191 PreSplit::resolve_with("qwen35", true, true),
1192 Ok(PreSplit::UnknownFallbackQwen35)
1193 );
1194 }
1195
1196 #[test]
1199 fn opt_out_env_gate() {
1200 unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
1203 assert!(!allow_unknown_pretokenizer());
1204 unsafe { std::env::set_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV, "0") };
1205 assert!(!allow_unknown_pretokenizer());
1206 unsafe { std::env::set_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV, "1") };
1207 assert!(allow_unknown_pretokenizer());
1208 assert_eq!(
1209 PreSplit::resolve("llama4", false),
1210 Ok(PreSplit::UnknownFallbackQwen35)
1211 );
1212 unsafe { std::env::remove_var(ALLOW_UNKNOWN_PRETOKENIZER_ENV) };
1213 assert!(PreSplit::resolve("llama4", false).is_err());
1214 }
1215
1216 #[test]
1219 fn split_regex_identification_is_exact() {
1220 let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1221 assert_eq!(
1222 pre_from_split_regexes(&s(&[QWEN35_PRETOKENIZE_REGEX])),
1223 Some("qwen35")
1224 );
1225 assert_eq!(
1226 pre_from_split_regexes(&s(&[QWEN2_PRETOKENIZE_REGEX])),
1227 Some("qwen2")
1228 );
1229 assert_eq!(
1230 pre_from_split_regexes(&s(&DEEPSEEK_V3_SPLIT_REGEXES)),
1231 Some("deepseek-v3")
1232 );
1233 assert_eq!(
1235 pre_from_split_regexes(&s(&[
1236 DEEPSEEK_V3_SPLIT_REGEXES[1],
1237 DEEPSEEK_V3_SPLIT_REGEXES[0],
1238 DEEPSEEK_V3_SPLIT_REGEXES[2],
1239 ])),
1240 None
1241 );
1242 assert_eq!(
1244 pre_from_split_regexes(&s(&[
1245 DEEPSEEK_V3_SPLIT_REGEXES[0],
1246 DEEPSEEK_V3_SPLIT_REGEXES[1]
1247 ])),
1248 None
1249 );
1250 let mut near = QWEN35_PRETOKENIZE_REGEX.to_string();
1252 near.push('x');
1253 assert_eq!(pre_from_split_regexes(&s(&[&near])), None);
1254 assert_eq!(pre_from_split_regexes(&[]), None);
1255 assert_ne!(QWEN2_PRETOKENIZE_REGEX, QWEN35_PRETOKENIZE_REGEX);
1257 }
1258
1259 #[test]
1262 fn collect_split_regexes_walks_in_order() {
1263 let src = r#"{"type":"Sequence","pretokenizers":[
1264 {"type":"Split","pattern":{"Regex":"A"},"behavior":"Isolated"},
1265 {"type":"Split","pattern":{"String":" "},"behavior":"Isolated"},
1266 {"type":"Digits","individual_digits":true},
1267 {"type":"Sequence","pretokenizers":[
1268 {"type":"Split","pattern":{"Regex":"B"},"behavior":"Isolated"}
1269 ]},
1270 {"type":"ByteLevel","add_prefix_space":false}
1271 ]}"#;
1272 let v = json::parse(src).unwrap();
1273 let mut out = Vec::new();
1274 collect_split_regexes(&v, &mut out);
1275 assert_eq!(out, vec!["A".to_string(), "B".to_string()]);
1276 }
1277}
1278
1279#[cfg(test)]
1280mod hf_tests {
1281 use super::*;
1282
1283 const TOKENIZER_JSON: &str = r#"{
1292 "version": "1.0",
1293 "added_tokens": [
1294 {"id": 15, "content": "<|end|>", "special": true},
1295 {"id": 16, "content": "<think>", "special": false}
1296 ],
1297 "pre_tokenizer": {
1298 "type": "Sequence",
1299 "pretokenizers": [
1300 {"type": "Split", "pattern": {"Regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"}, "behavior": "Isolated"},
1301 {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false}
1302 ]
1303 },
1304 "model": {
1305 "type": "BPE",
1306 "vocab": {
1307 "h": 0, "e": 1, "l": 2, "o": 3, "Ġ": 4, "w": 5, "r": 6, "d": 7,
1308 "he": 8, "ll": 9, "hell": 10, "hello": 11, "Ġw": 12, "or": 13, "!": 14
1309 },
1310 "merges": [
1311 "h e",
1312 ["l", "l"],
1313 "he ll",
1314 ["hell", "o"],
1315 ["Ġ", "w"],
1316 "o r"
1317 ]
1318 }
1319 }"#;
1320
1321 fn write_fixture(
1322 name: &str,
1323 tokenizer_config: Option<&str>,
1324 generation_config: Option<&str>,
1325 jinja: Option<&str>,
1326 ) -> std::path::PathBuf {
1327 let dir = std::env::temp_dir().join(format!("memra-tok-hf-{name}-{}", std::process::id()));
1328 let _ = std::fs::remove_dir_all(&dir);
1329 std::fs::create_dir_all(&dir).unwrap();
1330 std::fs::write(dir.join("tokenizer.json"), TOKENIZER_JSON).unwrap();
1331 if let Some(tc) = tokenizer_config {
1332 std::fs::write(dir.join("tokenizer_config.json"), tc).unwrap();
1333 }
1334 if let Some(gc) = generation_config {
1335 std::fs::write(dir.join("generation_config.json"), gc).unwrap();
1336 }
1337 if let Some(j) = jinja {
1338 std::fs::write(dir.join("chat_template.jinja"), j).unwrap();
1339 }
1340 dir
1341 }
1342
1343 #[test]
1344 fn hf_dir_encode_decode_roundtrip_and_specials() {
1345 let tc = r#"{
1347 "eos_token": {"content": "<|end|>", "lstrip": false},
1348 "add_bos_token": false,
1349 "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
1350 "chat_template": "{{ messages }}<|end|>"
1351 }"#;
1352 let dir = write_fixture("full", Some(tc), None, None);
1353 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1354
1355 assert_eq!(tok.eos_id(), 15);
1356 assert_eq!(tok.bos_id(), None);
1357 assert_eq!(tok.pre(), "qwen35");
1358 assert_eq!(tok.vocab_size(), 17); assert_eq!(tok.chat_template(), Some("{{ messages }}<|end|>"));
1360
1361 let ids = tok.encode("hello world", true);
1365 assert_eq!(ids, vec![11, 12, 13, 2, 7]);
1366 assert_eq!(tok.decode(&ids), "hello world");
1367
1368 let ids = tok.encode("hello<|end|> world", true);
1370 assert_eq!(ids, vec![11, 15, 12, 13, 2, 7]);
1371 assert_eq!(tok.decode_special(&ids, true), "hello<|end|> world");
1373 assert_eq!(tok.decode_special(&ids, false), "hello world");
1374
1375 assert_eq!(tok.decode(&[16]), "<think>");
1377 let _ = std::fs::remove_dir_all(&dir);
1378 }
1379
1380 #[test]
1381 fn hf_dir_generation_config_eos_fallback_and_jinja() {
1382 let gc = r#"{"eos_token_id": [15, 14]}"#;
1385 let dir = write_fixture("genconf", None, Some(gc), Some("JINJA {{ messages }}"));
1386 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1387 assert_eq!(tok.eos_id(), 15);
1388 assert!(!tok.encode("hello", true).is_empty());
1389 assert_eq!(tok.chat_template(), Some("JINJA {{ messages }}"));
1390 let _ = std::fs::remove_dir_all(&dir);
1391 }
1392
1393 #[test]
1397 fn hf_dir_identifies_deepseek_v3_from_tokenizer_json() {
1398 let dsv3_pt = r##""pre_tokenizer": {
1399 "type": "Sequence",
1400 "pretokenizers": [
1401 {"type": "Split", "pattern": {"Regex": "\\p{N}{1,3}"}, "behavior": "Isolated"},
1402 {"type": "Split", "pattern": {"Regex": "[一-龥-ゟ゠-ヿ]+"}, "behavior": "Isolated"},
1403 {"type": "Split", "pattern": {"Regex": "[!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~][A-Za-z]+|[^\r\n\\p{L}\\p{P}\\p{S}]?[\\p{L}\\p{M}]+| ?[\\p{P}\\p{S}]+[\r\n]*|\\s*[\r\n]+|\\s+(?!\\S)|\\s+"}, "behavior": "Isolated"},
1404 {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": false}
1405 ]
1406 },"##;
1407 let open = TOKENIZER_JSON.find(r#""pre_tokenizer""#).unwrap();
1409 let close = TOKENIZER_JSON.find(r#""model""#).unwrap();
1410 let json = format!(
1411 "{}{}\n {}",
1412 &TOKENIZER_JSON[..open],
1413 dsv3_pt,
1414 &TOKENIZER_JSON[close..]
1415 );
1416 let dir = std::env::temp_dir().join(format!("memra-tok-hf-dsv3-{}", std::process::id()));
1417 let _ = std::fs::remove_dir_all(&dir);
1418 std::fs::create_dir_all(&dir).unwrap();
1419 std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1420 std::fs::write(
1421 dir.join("generation_config.json"),
1422 r#"{"eos_token_id": 15}"#,
1423 )
1424 .unwrap();
1425 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1426 assert_eq!(tok.pre(), "deepseek-v3");
1427 assert_eq!(tok.split(), PreSplit::DeepseekV3);
1428 let _ = std::fs::remove_dir_all(&dir);
1429 }
1430
1431 #[test]
1434 fn hf_dir_identifies_qwen2_regex() {
1435 let json = TOKENIZER_JSON.replace(
1436 r"[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+",
1437 r"[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+",
1438 );
1439 assert_ne!(json, TOKENIZER_JSON, "the qwen2 substitution must apply");
1440 let dir = std::env::temp_dir().join(format!("memra-tok-hf-qwen2-{}", std::process::id()));
1441 let _ = std::fs::remove_dir_all(&dir);
1442 std::fs::create_dir_all(&dir).unwrap();
1443 std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1444 std::fs::write(
1445 dir.join("generation_config.json"),
1446 r#"{"eos_token_id": 15}"#,
1447 )
1448 .unwrap();
1449 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
1450 assert_eq!(tok.pre(), "qwen2");
1451 assert_eq!(
1452 tok.split(),
1453 PreSplit::Qwen35,
1454 "qwen2 rides the qwen35 split"
1455 );
1456 let _ = std::fs::remove_dir_all(&dir);
1457 }
1458
1459 #[test]
1462 fn hf_dir_refuses_unidentifiable_pretokenizer() {
1463 let json = TOKENIZER_JSON.replace(r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|", "SOMETHING-ELSE|");
1464 assert_ne!(json, TOKENIZER_JSON);
1465 let dir = std::env::temp_dir().join(format!("memra-tok-hf-unk-{}", std::process::id()));
1466 let _ = std::fs::remove_dir_all(&dir);
1467 std::fs::create_dir_all(&dir).unwrap();
1468 std::fs::write(dir.join("tokenizer.json"), &json).unwrap();
1469 std::fs::write(
1470 dir.join("generation_config.json"),
1471 r#"{"eos_token_id": 15}"#,
1472 )
1473 .unwrap();
1474 let err = match Tokenizer::from_hf_dir(&dir) {
1475 Ok(_) => panic!("unidentifiable pre must refuse to load"),
1476 Err(e) => e,
1477 };
1478 assert!(
1479 err.contains("unsupported tokenizer.ggml.pre 'default'"),
1480 "{err}"
1481 );
1482 assert!(
1483 err.contains("SOMETHING-ELSE"),
1484 "error must quote the regex: {err}"
1485 );
1486 assert!(err.contains("MEMRA_ALLOW_UNKNOWN_PRETOKENIZER"), "{err}");
1487 let _ = std::fs::remove_dir_all(&dir);
1488 }
1489
1490 #[test]
1496 fn staged_checkpoints_resolve_their_own_pretokenizer() {
1497 let cases: &[(&str, &str)] = &[
1498 (
1500 "/data/ai-ml/hf-models/hy3-layer103p5-sparse-source",
1501 "deepseek-v3",
1502 ),
1503 ("/data/ai-ml/hf-models/qwen3-1.7b-blk128fp8-synth", "qwen2"),
1505 ("/data/ai-ml/hf-models/qwen35-9b-hf", "qwen35"),
1507 ];
1508 let mut ran = 0;
1509 for (path, want) in cases {
1510 let dir = std::path::Path::new(path);
1511 if !dir.join("tokenizer.json").exists() {
1512 eprintln!("skip: {path} not staged");
1513 continue;
1514 }
1515 let tok = Tokenizer::from_hf_dir(dir).unwrap_or_else(|e| panic!("{path}: {e}"));
1516 assert_eq!(tok.pre(), *want, "{path}");
1517 ran += 1;
1518 }
1519 eprintln!("staged_checkpoints_resolve_their_own_pretokenizer: {ran}/3 cases ran");
1520 }
1521
1522 #[test]
1523 fn hf_dir_rejects_non_byte_level() {
1524 let dir = std::env::temp_dir().join(format!("memra-tok-hf-nonbl-{}", std::process::id()));
1525 let _ = std::fs::remove_dir_all(&dir);
1526 std::fs::create_dir_all(&dir).unwrap();
1527 let bad = TOKENIZER_JSON.replace("\"ByteLevel\"", "\"Metaspace\"");
1528 std::fs::write(dir.join("tokenizer.json"), bad).unwrap();
1529 assert!(Tokenizer::from_hf_dir(&dir).is_err());
1530 let _ = std::fs::remove_dir_all(&dir);
1531 }
1532}