1pub mod chat;
11mod json;
12mod unicode;
13mod unicode_data;
14
15pub use chat::apply_chat_template_str;
16
17use memra_gguf::{GgufFile, MetaValue};
18use std::cmp::Ordering;
19use std::collections::{BinaryHeap, HashMap};
20
21const TT_UNKNOWN: i64 = 2;
23const TT_CONTROL: i64 = 3;
24const TT_USER_DEFINED: i64 = 4;
25const TT_BYTE: i64 = 6;
26const QWEN35_PRETOKENIZE_REGEX: &str =
27 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+";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum TokAttr {
31 Normal,
32 Unknown,
33 Control,
34 UserDefined,
35 Byte,
36 Other,
37}
38
39impl TokAttr {
40 fn from_toktype(t: i64) -> Self {
41 match t {
42 TT_UNKNOWN => TokAttr::Unknown,
43 TT_CONTROL => TokAttr::Control,
44 TT_USER_DEFINED => TokAttr::UserDefined,
45 TT_BYTE => TokAttr::Byte,
46 1 => TokAttr::Normal,
47 _ => TokAttr::Other,
48 }
49 }
50 fn is_special(self) -> bool {
53 matches!(self, TokAttr::Control | TokAttr::UserDefined | TokAttr::Unknown)
54 }
55}
56
57pub struct Tokenizer {
58 id_to_token: Vec<String>,
60 token_to_id: HashMap<String, u32>,
62 attrs: Vec<TokAttr>,
64 bpe_ranks: HashMap<(String, String), i32>,
66 special_tokens: Vec<u32>,
68 eos_id: u32,
69 bos_id: Option<u32>,
70 add_bos: bool,
71 pre: String,
72 chat_template: Option<String>,
73 spm_style: bool,
75}
76
77#[derive(Clone, Eq, PartialEq)]
82struct Bigram {
83 left: i32,
84 right: i32,
85 rank: i32,
86 text: String,
87}
88
89impl Ord for Bigram {
90 fn cmp(&self, other: &Self) -> Ordering {
91 match other.rank.cmp(&self.rank) {
94 Ordering::Equal => other.left.cmp(&self.left),
95 o => o,
96 }
97 }
98}
99impl PartialOrd for Bigram {
100 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
101 Some(self.cmp(other))
102 }
103}
104
105struct Symbol {
107 text: String,
108 prev: i32,
109 next: i32,
110 n: usize, }
112
113impl Tokenizer {
114 pub fn from_gguf(g: &GgufFile) -> Result<Self, String> {
116 let model = g
117 .metadata
118 .get("tokenizer.ggml.model")
119 .and_then(|v| v.as_str())
120 .ok_or("missing tokenizer.ggml.model")?;
121 if model != "gpt2" && model != "gemma4" {
122 return Err(format!("unsupported tokenizer model '{model}' (only gpt2/gemma4)"));
123 }
124 let spm_style = model == "gemma4";
128 let pre = g
129 .metadata
130 .get("tokenizer.ggml.pre")
131 .and_then(|v| v.as_str())
132 .unwrap_or(if spm_style { "gemma4" } else { "default" })
133 .to_string();
134
135 let tokens = match g.metadata.get("tokenizer.ggml.tokens") {
137 Some(MetaValue::Array(a)) => a,
138 _ => return Err("missing tokenizer.ggml.tokens array".into()),
139 };
140 let n = tokens.len();
141 let mut id_to_token = Vec::with_capacity(n);
142 let mut token_to_id = HashMap::with_capacity(n);
143 for (i, t) in tokens.iter().enumerate() {
144 let s = t.as_str().ok_or("non-string in tokens[]")?.to_string();
145 token_to_id.entry(s.clone()).or_insert(i as u32);
147 id_to_token.push(s);
148 }
149
150 let mut attrs = vec![TokAttr::Normal; n];
152 if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.token_type") {
153 for (i, v) in a.iter().enumerate().take(n) {
154 if let Some(t) = v.as_u64() {
155 attrs[i] = TokAttr::from_toktype(t as i64);
156 } else if let MetaValue::I32(t) = v {
157 attrs[i] = TokAttr::from_toktype(*t as i64);
158 }
159 }
160 }
161
162 let mut bpe_ranks = HashMap::new();
164 if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.merges") {
165 for (i, v) in a.iter().enumerate() {
166 let word = v.as_str().ok_or("non-string in merges[]")?;
167 let bytes = word.as_bytes();
171 if let Some(pos) = bytes.iter().skip(1).position(|&b| b == b' ').map(|p| p + 1) {
172 let first = word[..pos].to_string();
173 let second = word[pos + 1..].to_string();
174 bpe_ranks.insert((first, second), i as i32);
175 }
176 }
177 } else {
178 return Err("missing tokenizer.ggml.merges array".into());
179 }
180
181 let mut special_tokens: Vec<u32> = (0..n as u32)
183 .filter(|&id| attrs[id as usize].is_special())
184 .collect();
185 special_tokens.sort_by(|&a, &b| {
186 id_to_token[b as usize]
187 .len()
188 .cmp(&id_to_token[a as usize].len())
189 });
190
191 let eos_id = g
192 .metadata
193 .get("tokenizer.ggml.eos_token_id")
194 .and_then(|v| v.as_u64())
195 .map(|v| v as u32)
196 .ok_or("missing tokenizer.ggml.eos_token_id")?;
197 let bos_id = g
198 .metadata
199 .get("tokenizer.ggml.bos_token_id")
200 .and_then(|v| v.as_u64())
201 .map(|v| v as u32);
202 let add_bos = g
203 .metadata
204 .get("tokenizer.ggml.add_bos_token")
205 .and_then(|v| match v {
206 MetaValue::Bool(b) => Some(*b),
207 _ => v.as_u64().map(|x| x != 0),
208 })
209 .unwrap_or(false);
210 let add_bos = add_bos || spm_style;
211
212 let chat_template = g
213 .metadata
214 .get("tokenizer.chat_template")
215 .and_then(|v| v.as_str())
216 .map(|s| s.to_string());
217
218 Ok(Tokenizer {
219 id_to_token,
220 token_to_id,
221 attrs,
222 bpe_ranks,
223 special_tokens,
224 eos_id,
225 bos_id,
226 add_bos,
227 pre,
228 chat_template,
229 spm_style,
230 })
231 }
232
233 pub fn from_hf_dir(dir: &std::path::Path) -> Result<Self, String> {
250 let tj_path = dir.join("tokenizer.json");
251 let text = std::fs::read_to_string(&tj_path)
252 .map_err(|e| format!("read {}: {e}", tj_path.display()))?;
253 let tj = json::parse(&text).map_err(|e| format!("{}: {e}", tj_path.display()))?;
254
255 let model = tj.get("model").ok_or("tokenizer.json: missing model")?;
256 if let Some(t) = model.get("type").and_then(|v| v.as_str()) {
257 if t != "BPE" {
258 return Err(format!("unsupported tokenizer.json model type '{t}' (only BPE)"));
259 }
260 }
261 let pre_tok = tj.get("pre_tokenizer").ok_or("tokenizer.json: missing pre_tokenizer")?;
263 if !pre_tokenizer_is_byte_level(pre_tok) {
264 return Err("tokenizer.json: pre_tokenizer is not ByteLevel — only byte-level \
265 BPE is supported"
266 .into());
267 }
268
269 let vocab = model
271 .get("vocab")
272 .and_then(|v| v.as_obj())
273 .ok_or("tokenizer.json: missing model.vocab")?;
274 let empty: Vec<json::Value> = Vec::new();
275 let added = tj
276 .get("added_tokens")
277 .and_then(|v| v.as_arr())
278 .unwrap_or(&empty);
279 let mut max_id = 0u32;
280 for v in vocab.values() {
281 let id = v.as_u64().ok_or("tokenizer.json: non-integer id in model.vocab")? as u32;
282 max_id = max_id.max(id);
283 }
284 for a in added {
285 if let Some(id) = a.get("id").and_then(|v| v.as_u64()) {
286 max_id = max_id.max(id as u32);
287 }
288 }
289 let n = max_id as usize + 1;
290 let mut id_to_token = vec![String::new(); n];
291 let mut token_to_id: HashMap<String, u32> = HashMap::with_capacity(n);
292 let mut attrs = vec![TokAttr::Normal; n];
293 for (tok, v) in vocab {
294 let id = v.as_u64().unwrap() as u32;
295 id_to_token[id as usize] = tok.clone();
296 token_to_id.entry(tok.clone()).or_insert(id);
297 }
298 for a in added {
301 let id = a
302 .get("id")
303 .and_then(|v| v.as_u64())
304 .ok_or("tokenizer.json: added_tokens entry missing id")? as u32;
305 let content = a
306 .get("content")
307 .and_then(|v| v.as_str())
308 .ok_or("tokenizer.json: added_tokens entry missing content")?;
309 if id_to_token[id as usize].is_empty() {
310 id_to_token[id as usize] = content.to_string();
311 }
312 token_to_id.entry(content.to_string()).or_insert(id);
313 if a.get("special").and_then(|v| v.as_bool()).unwrap_or(false) {
314 attrs[id as usize] = TokAttr::Control;
315 } else {
316 attrs[id as usize] = TokAttr::UserDefined;
322 }
323 }
324
325 let merges = model
327 .get("merges")
328 .and_then(|v| v.as_arr())
329 .ok_or("tokenizer.json: missing model.merges")?;
330 let mut bpe_ranks = HashMap::with_capacity(merges.len());
331 for (i, m) in merges.iter().enumerate() {
332 let (first, second) = match m {
333 json::Value::Str(s) => {
334 let bytes = s.as_bytes();
337 let pos = bytes
338 .iter()
339 .skip(1)
340 .position(|&b| b == b' ')
341 .map(|p| p + 1)
342 .ok_or_else(|| format!("tokenizer.json: merges[{i}] has no space"))?;
343 (s[..pos].to_string(), s[pos + 1..].to_string())
344 }
345 json::Value::Arr(a) if a.len() == 2 => {
346 let f = a[0]
347 .as_str()
348 .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
349 let s2 = a[1]
350 .as_str()
351 .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
352 (f.to_string(), s2.to_string())
353 }
354 _ => {
355 return Err(format!(
356 "tokenizer.json: merges[{i}] is neither \"a b\" string nor [a, b] pair"
357 ));
358 }
359 };
360 bpe_ranks.insert((first, second), i as i32);
361 }
362
363 let mut special_tokens: Vec<u32> = (0..n as u32)
365 .filter(|&id| attrs[id as usize].is_special())
366 .collect();
367 special_tokens.sort_by(|&a, &b| {
368 id_to_token[b as usize]
369 .len()
370 .cmp(&id_to_token[a as usize].len())
371 });
372
373 let tc = std::fs::read_to_string(dir.join("tokenizer_config.json"))
375 .ok()
376 .and_then(|t| json::parse(&t).ok());
377 let gc = std::fs::read_to_string(dir.join("generation_config.json"))
378 .ok()
379 .and_then(|t| json::parse(&t).ok());
380
381 let tok_content = |v: &json::Value| -> Option<String> {
383 v.as_str()
384 .map(|s| s.to_string())
385 .or_else(|| v.get("content").and_then(|c| c.as_str()).map(|s| s.to_string()))
386 };
387 let eos_from_cfg = tc
388 .as_ref()
389 .and_then(|c| c.get("eos_token"))
390 .and_then(&tok_content)
391 .and_then(|s| token_to_id.get(&s).copied());
392 let eos_from_gen = gc
394 .as_ref()
395 .and_then(|c| c.get("eos_token_id"))
396 .and_then(|v| match v {
397 json::Value::Num(_) => v.as_u64(),
398 json::Value::Arr(a) => a.first().and_then(|x| x.as_u64()),
399 _ => None,
400 })
401 .map(|v| v as u32);
402 let eos_id = eos_from_cfg.or(eos_from_gen).ok_or(
403 "no eos token: need tokenizer_config.json eos_token or \
404 generation_config.json eos_token_id",
405 )?;
406 let bos_id = tc
407 .as_ref()
408 .and_then(|c| c.get("bos_token"))
409 .and_then(&tok_content)
410 .and_then(|s| token_to_id.get(&s).copied());
411 let add_bos = tc
412 .as_ref()
413 .and_then(|c| c.get("add_bos_token"))
414 .and_then(|v| v.as_bool())
415 .unwrap_or(false);
416
417 let chat_template = tc
419 .as_ref()
420 .and_then(|c| c.get("chat_template"))
421 .and_then(|v| v.as_str())
422 .map(|s| s.to_string())
423 .or_else(|| std::fs::read_to_string(dir.join("chat_template.jinja")).ok());
424 let pre = tc
425 .as_ref()
426 .and_then(|c| c.get("pretokenize_regex"))
427 .and_then(|v| v.as_str())
428 .filter(|regex| *regex == QWEN35_PRETOKENIZE_REGEX)
429 .map_or("default", |_| "qwen35");
430
431 Ok(Tokenizer {
432 id_to_token,
433 token_to_id,
434 attrs,
435 bpe_ranks,
436 special_tokens,
437 eos_id,
438 bos_id,
439 add_bos,
440 pre: pre.to_string(),
441 chat_template,
442 spm_style: false,
443 })
444 }
445
446 pub fn eos_id(&self) -> u32 {
447 self.eos_id
448 }
449 pub fn eog_ids(&self) -> Vec<u32> {
452 let mut ids = vec![self.eos_id];
453 for t in ["<|im_end|>", "<turn|>", "<end_of_turn>"] {
454 if let Some(&id) = self.token_to_id.get(t) {
455 if !ids.contains(&id) { ids.push(id); }
456 }
457 }
458 ids
459 }
460 pub fn bos_id(&self) -> Option<u32> {
461 self.bos_id
462 }
463 pub fn vocab_size(&self) -> usize {
464 self.id_to_token.len()
465 }
466 pub fn pre(&self) -> &str {
467 &self.pre
468 }
469 pub fn chat_template(&self) -> Option<&str> {
470 self.chat_template.as_deref()
471 }
472
473 #[inline]
474 fn text_to_token(&self, s: &str) -> Option<u32> {
475 self.token_to_id.get(s).copied()
476 }
477
478 fn find_bpe_rank(&self, left: &str, right: &str) -> i32 {
479 self.bpe_ranks
480 .get(&(left.to_string(), right.to_string()))
481 .copied()
482 .unwrap_or(-1)
483 }
484
485 pub fn encode(&self, text: &str, add_special: bool) -> Vec<u32> {
491 self.encode_special(text, add_special, true)
492 }
493
494 pub fn encode_special(&self, text: &str, add_special: bool, parse_special: bool) -> Vec<u32> {
495 let mut output: Vec<u32> = Vec::new();
496 if add_special && self.add_bos {
497 if let Some(b) = self.bos_id {
498 output.push(b);
499 }
500 }
501 if text.is_empty() {
502 return output;
503 }
504
505 for frag in self.st_partition(text, parse_special) {
507 match frag {
508 Fragment::Token(id) => output.push(id),
509 Fragment::Text(span) => self.bpe_tokenize(&span, &mut output),
510 }
511 }
512 output
513 }
514
515 fn st_partition(&self, text: &str, parse_special: bool) -> Vec<Fragment> {
517 let mut frags = vec![Fragment::Text(text.to_string())];
518 for &sid in &self.special_tokens {
519 let attr = self.attrs[sid as usize];
520 if !parse_special && matches!(attr, TokAttr::Control | TokAttr::Unknown) {
522 continue;
523 }
524 let needle = &self.id_to_token[sid as usize];
525 if needle.is_empty() {
526 continue;
527 }
528 let mut next: Vec<Fragment> = Vec::with_capacity(frags.len());
529 for f in frags.drain(..) {
530 match f {
531 Fragment::Token(id) => next.push(Fragment::Token(id)),
532 Fragment::Text(s) => {
533 let mut rest: &str = &s;
534 let mut acc = String::new();
535 while let Some(m) = rest.find(needle.as_str()) {
536 acc.push_str(&rest[..m]);
537 if !acc.is_empty() {
538 next.push(Fragment::Text(std::mem::take(&mut acc)));
539 }
540 next.push(Fragment::Token(sid));
541 rest = &rest[m + needle.len()..];
542 }
543 acc.push_str(rest);
544 if !acc.is_empty() {
545 next.push(Fragment::Text(acc));
546 }
547 }
548 }
549 }
550 frags = next;
551 }
552 frags
553 }
554
555 fn warn_unsupported_pre(pre: &str) {
559 use std::sync::OnceLock;
560 static WARNED: OnceLock<()> = OnceLock::new();
561 let pre = pre.to_string();
562 WARNED.get_or_init(move || {
563 eprintln!(
564 "memra-tokenizer: WARNING unsupported tokenizer.ggml.pre '{pre}' — falling back \
565 to the qwen35 pre-tokenizer split. Token ids will NOT be exact for this model."
566 );
567 });
568 }
569
570 fn bpe_tokenize(&self, text: &str, output: &mut Vec<u32>) {
572 if self.spm_style {
573 let escaped: String = text.chars().map(|c| if c == ' ' { '\u{2581}' } else { c }).collect();
576 let mut words: Vec<String> = Vec::new();
577 let mut cur = String::new();
578 let mut cur_nl: Option<bool> = None;
579 for c in escaped.chars() {
580 let nl = c == '\n';
581 if cur_nl != Some(nl) && !cur.is_empty() {
582 words.push(std::mem::take(&mut cur));
583 }
584 cur_nl = Some(nl);
585 cur.push(c);
586 }
587 if !cur.is_empty() { words.push(cur); }
588 for word in &words {
589 if word.chars().all(|c| c == '\n') {
591 if let Some(tok) = self.text_to_token(word) {
592 output.push(tok);
593 continue;
594 }
595 }
596 self.bpe_merge_word(word, output);
597 }
598 return;
599 }
600 let words: Vec<String> = match self.pre.as_str() {
602 "qwen35" => unicode::split_qwen35(text),
603 "deepseek-v3" => unicode::split_deepseek_v3(text),
607 "qwen2" => unicode::split_qwen35(text),
610 other => {
613 Self::warn_unsupported_pre(other);
614 unicode::split_qwen35(text)
615 }
616 };
617
618 for word in &words {
619 let word = unicode::byte_encode(word);
620 self.bpe_merge_word(&word, output);
621 }
622 }
623
624 fn bpe_merge_word(&self, word: &str, output: &mut Vec<u32>) {
627 {
628 let word = word.to_string();
629
630 let chars: Vec<char> = word.chars().collect();
632 let mut symbols: Vec<Symbol> = Vec::with_capacity(chars.len());
633 for (i, &c) in chars.iter().enumerate() {
634 symbols.push(Symbol {
635 text: c.to_string(),
636 prev: i as i32 - 1,
637 next: if i + 1 == chars.len() { -1 } else { i as i32 + 1 },
638 n: 1,
639 });
640 }
641
642 let mut queue: BinaryHeap<Bigram> = BinaryHeap::new();
644 for i in 1..symbols.len() {
645 self.add_bigram(&symbols, i as i32 - 1, i as i32, &mut queue);
646 }
647
648 while let Some(bigram) = queue.pop() {
650 let li = bigram.left as usize;
651 let ri = bigram.right as usize;
652 if symbols[li].n == 0 || symbols[ri].n == 0 {
653 continue;
654 }
655 let combined = format!("{}{}", symbols[li].text, symbols[ri].text);
656 if combined != bigram.text {
657 continue; }
659 symbols[li].text = combined;
661 symbols[li].n += symbols[ri].n;
662 symbols[ri].n = 0;
663 let r_next = symbols[ri].next;
664 symbols[li].next = r_next;
665 if r_next >= 0 {
666 symbols[r_next as usize].prev = bigram.left;
667 }
668 let l_prev = symbols[li].prev;
669 let l_next = symbols[li].next;
670 self.add_bigram(&symbols, l_prev, bigram.left, &mut queue);
671 self.add_bigram(&symbols, bigram.left, l_next, &mut queue);
672 }
673
674 for sym in &symbols {
676 if sym.n == 0 {
677 continue;
678 }
679 match self.text_to_token(&sym.text) {
680 Some(tok) => output.push(tok),
681 None => {
682 for b in sym.text.bytes() {
684 let bs = if self.spm_style {
685 format!("<0x{b:02X}>") } else {
687 (b as char).to_string()
688 };
689 if let Some(t) = self.text_to_token(&bs) {
690 output.push(t);
691 }
692 }
693 }
694 }
695 }
696 }
697 }
698
699 fn add_bigram(&self, symbols: &[Symbol], left: i32, right: i32, queue: &mut BinaryHeap<Bigram>) {
700 if left == -1 || right == -1 {
701 return;
702 }
703 let lt = &symbols[left as usize].text;
704 let rt = &symbols[right as usize].text;
705 let rank = self.find_bpe_rank(lt, rt);
706 if rank < 0 {
707 return;
708 }
709 queue.push(Bigram {
710 left,
711 right,
712 rank,
713 text: format!("{lt}{rt}"),
714 });
715 }
716
717 pub fn decode(&self, ids: &[u32]) -> String {
720 self.decode_special(ids, true)
721 }
722
723 pub fn token_is_control(&self, id: u32) -> bool {
728 match self.attrs.get(id as usize) {
729 Some(TokAttr::Control) | Some(TokAttr::Unknown) => true,
730 _ => false,
731 }
732 }
733
734 pub fn decode_special(&self, ids: &[u32], special: bool) -> String {
735 String::from_utf8_lossy(&self.decode_bytes_special(ids, special)).into_owned()
736 }
737
738 pub fn decode_bytes_special(&self, ids: &[u32], special: bool) -> Vec<u8> {
741 let mut bytes: Vec<u8> = Vec::new();
742 for &id in ids {
743 let i = id as usize;
744 if i >= self.id_to_token.len() {
745 continue;
746 }
747 let attr = self.attrs[i];
748 let piece = &self.id_to_token[i];
749 match attr {
750 TokAttr::Normal | TokAttr::Byte => {
751 if self.spm_style {
752 if matches!(attr, TokAttr::Byte)
754 || (piece.len() == 6 && piece.starts_with("<0x") && piece.ends_with('>')) {
755 if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
756 bytes.push(b);
757 continue;
758 }
759 }
760 for c in piece.chars() {
761 if c == '\u{2581}' { bytes.push(b' '); }
762 else {
763 let mut buf = [0u8; 4];
764 bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
765 }
766 }
767 } else {
768 self.piece_to_bytes(piece, &mut bytes);
770 }
771 }
772 TokAttr::UserDefined => {
773 bytes.extend_from_slice(piece.as_bytes());
775 }
776 TokAttr::Control | TokAttr::Unknown => {
777 if special {
778 bytes.extend_from_slice(piece.as_bytes());
779 }
780 }
782 TokAttr::Other => {}
783 }
784 }
785 bytes
786 }
787
788 fn piece_to_bytes(&self, piece: &str, out: &mut Vec<u8>) {
789 for c in piece.chars() {
790 match unicode::unicode_to_byte(c) {
791 Some(b) => out.push(b),
792 None => {
793 let mut buf = [0u8; 4];
795 out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
796 }
797 }
798 }
799 }
800
801 pub fn apply_chat_template(
804 &self,
805 messages: &[(&str, &str)],
806 add_generation_prompt: bool,
807 ) -> String {
808 chat::apply_chat_template_str(self.chat_template.as_deref(), messages, add_generation_prompt)
809 }
810
811 pub fn apply_chat_template_tools(
815 &self,
816 turns: &[chat::Turn],
817 add_generation_prompt: bool,
818 tools_json: &[String],
819 think: chat::ThinkMode,
820 reasoning_effort: Option<&str>,
821 ) -> Result<String, String> {
822 chat::apply_chat_template_tools(
823 self.chat_template.as_deref(), turns, add_generation_prompt, tools_json, think,
824 reasoning_effort)
825 }
826}
827
828enum Fragment {
829 Text(String),
830 Token(u32),
831}
832
833fn pre_tokenizer_is_byte_level(pt: &json::Value) -> bool {
836 match pt.get("type").and_then(|v| v.as_str()) {
837 Some("ByteLevel") => true,
838 Some("Sequence") => pt
839 .get("pretokenizers")
840 .and_then(|v| v.as_arr())
841 .map(|arr| arr.iter().any(pre_tokenizer_is_byte_level))
842 .unwrap_or(false),
843 _ => false,
844 }
845}
846
847#[cfg(test)]
848mod hf_tests {
849 use super::*;
850
851 const TOKENIZER_JSON: &str = r#"{
855 "version": "1.0",
856 "added_tokens": [
857 {"id": 15, "content": "<|end|>", "special": true},
858 {"id": 16, "content": "<think>", "special": false}
859 ],
860 "pre_tokenizer": {
861 "type": "Sequence",
862 "pretokenizers": [
863 {"type": "Split", "pattern": {"Regex": ""}, "behavior": "Isolated"},
864 {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false}
865 ]
866 },
867 "model": {
868 "type": "BPE",
869 "vocab": {
870 "h": 0, "e": 1, "l": 2, "o": 3, "Ġ": 4, "w": 5, "r": 6, "d": 7,
871 "he": 8, "ll": 9, "hell": 10, "hello": 11, "Ġw": 12, "or": 13, "!": 14
872 },
873 "merges": [
874 "h e",
875 ["l", "l"],
876 "he ll",
877 ["hell", "o"],
878 ["Ġ", "w"],
879 "o r"
880 ]
881 }
882 }"#;
883
884 fn write_fixture(name: &str, tokenizer_config: Option<&str>, generation_config: Option<&str>,
885 jinja: Option<&str>) -> std::path::PathBuf {
886 let dir = std::env::temp_dir().join(format!("memra-tok-hf-{name}-{}", std::process::id()));
887 let _ = std::fs::remove_dir_all(&dir);
888 std::fs::create_dir_all(&dir).unwrap();
889 std::fs::write(dir.join("tokenizer.json"), TOKENIZER_JSON).unwrap();
890 if let Some(tc) = tokenizer_config {
891 std::fs::write(dir.join("tokenizer_config.json"), tc).unwrap();
892 }
893 if let Some(gc) = generation_config {
894 std::fs::write(dir.join("generation_config.json"), gc).unwrap();
895 }
896 if let Some(j) = jinja {
897 std::fs::write(dir.join("chat_template.jinja"), j).unwrap();
898 }
899 dir
900 }
901
902 #[test]
903 fn hf_dir_encode_decode_roundtrip_and_specials() {
904 let tc = r#"{
906 "eos_token": {"content": "<|end|>", "lstrip": false},
907 "add_bos_token": false,
908 "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+",
909 "chat_template": "{{ messages }}<|end|>"
910 }"#;
911 let dir = write_fixture("full", Some(tc), None, None);
912 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
913
914 assert_eq!(tok.eos_id(), 15);
915 assert_eq!(tok.bos_id(), None);
916 assert_eq!(tok.pre(), "qwen35");
917 assert_eq!(tok.vocab_size(), 17); assert_eq!(tok.chat_template(), Some("{{ messages }}<|end|>"));
919
920 let ids = tok.encode("hello world", true);
924 assert_eq!(ids, vec![11, 12, 13, 2, 7]);
925 assert_eq!(tok.decode(&ids), "hello world");
926
927 let ids = tok.encode("hello<|end|> world", true);
929 assert_eq!(ids, vec![11, 15, 12, 13, 2, 7]);
930 assert_eq!(tok.decode_special(&ids, true), "hello<|end|> world");
932 assert_eq!(tok.decode_special(&ids, false), "hello world");
933
934 assert_eq!(tok.decode(&[16]), "<think>");
936 let _ = std::fs::remove_dir_all(&dir);
937 }
938
939 #[test]
940 fn hf_dir_generation_config_eos_fallback_and_jinja() {
941 let gc = r#"{"eos_token_id": [15, 14]}"#;
944 let dir = write_fixture("genconf", None, Some(gc), Some("JINJA {{ messages }}"));
945 let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
946 assert_eq!(tok.eos_id(), 15);
947 assert!(!tok.encode("hello", true).is_empty());
948 assert_eq!(tok.chat_template(), Some("JINJA {{ messages }}"));
949 let _ = std::fs::remove_dir_all(&dir);
950 }
951
952 #[test]
953 fn hf_dir_rejects_non_byte_level() {
954 let dir = std::env::temp_dir()
955 .join(format!("memra-tok-hf-nonbl-{}", std::process::id()));
956 let _ = std::fs::remove_dir_all(&dir);
957 std::fs::create_dir_all(&dir).unwrap();
958 let bad = TOKENIZER_JSON.replace("\"ByteLevel\"", "\"Metaspace\"");
959 std::fs::write(dir.join("tokenizer.json"), bad).unwrap();
960 assert!(Tokenizer::from_hf_dir(&dir).is_err());
961 let _ = std::fs::remove_dir_all(&dir);
962 }
963}