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