1use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use unicode_normalization::UnicodeNormalization;
16
17const DEFAULT_SPLIT: &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+";
20
21pub struct Tokenizer {
23 vocab: HashMap<String, u32>,
25 id_to_token: Vec<String>,
27 ranks: HashMap<(String, String), u32>,
29 added: Vec<(String, u32)>,
31 added_ids: HashSet<u32>,
33 special_ids: HashSet<u32>,
35 split_res: Vec<fancy_regex::Regex>,
39 sp_prepend: bool,
42 sp_prepend_first: bool,
48 metaspace: bool,
51 nfc: bool,
54 byte_to_char: [char; 256],
56 char_to_byte: HashMap<char, u8>,
58 pub bos_token_id: Option<u32>,
60 pub eos_token_id: Option<u32>,
61 pub pad_token_id: Option<u32>,
62 pub im_start_id: Option<u32>,
64 pub im_end_id: Option<u32>,
65 pub chat_template: Option<String>,
68 pub extra_eos: HashSet<u32>,
70 pub add_bos: bool,
72}
73
74impl std::fmt::Debug for Tokenizer {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("Tokenizer")
77 .field("vocab", &self.vocab.len())
78 .field("merges", &self.ranks.len())
79 .field("added", &self.added.len())
80 .finish()
81 }
82}
83
84fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
87 let mut b2c = ['\0'; 256];
88 let mut c2b = HashMap::with_capacity(256);
89 let mut n = 0u32;
90 for b in 0..=255u16 {
91 let printable =
92 (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
93 let c = if printable {
94 char::from_u32(b as u32).unwrap()
95 } else {
96 let c = char::from_u32(256 + n).unwrap();
97 n += 1;
98 c
99 };
100 b2c[b as usize] = c;
101 c2b.insert(c, b as u8);
102 }
103 (b2c, c2b)
104}
105
106#[derive(Deserialize)]
108struct HfTokenizerJson {
109 model: HfModel,
110 #[serde(default)]
111 added_tokens: Vec<HfAddedToken>,
112 #[serde(default)]
113 pre_tokenizer: Option<serde_json::Value>,
114 #[serde(default)]
115 normalizer: Option<serde_json::Value>,
116 #[serde(default)]
117 post_processor: Option<serde_json::Value>,
118}
119
120#[derive(Deserialize)]
121struct HfModel {
122 vocab: HashMap<String, u32>,
123 #[serde(default)]
124 merges: Vec<HfMerge>,
125 #[serde(default)]
126 byte_fallback: bool,
127}
128
129#[derive(Deserialize)]
132#[serde(untagged)]
133enum HfMerge {
134 Pair([String; 2]),
135 Text(String),
136}
137
138#[derive(Deserialize)]
139struct HfAddedToken {
140 id: u32,
141 content: String,
142 special: bool,
143}
144
145fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
154 if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
155 if let Some(r) = pt
156 .get("pattern")
157 .and_then(|p| p.get("Regex"))
158 .and_then(|r| r.as_str())
159 {
160 out.push(r.to_string());
161 }
162 return;
163 }
164 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
165 for p in list {
166 collect_split_patterns(p, out);
167 }
168 }
169}
170
171fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
174 if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
175 return pt
176 .get("prepend_scheme")
177 .and_then(|p| p.as_str())
178 .map(String::from);
179 }
180 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
181 return list.iter().find_map(find_prepend_scheme);
182 }
183 None
184}
185
186impl Tokenizer {
187 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
189 let data = std::fs::read_to_string(path.as_ref())
190 .map_err(|e| TokenizerError::Io(e.to_string()))?;
191 Self::from_json(&data)
192 }
193
194 pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
196 let s = std::str::from_utf8(bytes)
197 .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
198 Self::from_json(s)
199 }
200
201 pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
203 let hf: HfTokenizerJson =
204 serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
205
206 let mut vocab = hf.model.vocab;
207 let mut ranks = HashMap::new();
208 for (rank, m) in hf.model.merges.into_iter().enumerate() {
209 let (a, b) = match m {
210 HfMerge::Pair([a, b]) => (a, b),
211 HfMerge::Text(s) => {
212 let mut it = s.splitn(2, ' ');
213 match (it.next(), it.next()) {
214 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
215 _ => continue,
216 }
217 }
218 };
219 ranks.insert((a, b), rank as u32);
220 }
221
222 let mut saw_gemma_bos = false;
227 let add_bos_detected = hf
228 .post_processor
229 .as_ref()
230 .map(|p| {
231 let pp = p.to_string();
232 pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
233 })
234 .unwrap_or(false);
235 let nfc = hf
236 .normalizer
237 .as_ref()
238 .map(|n| n.to_string().contains("NFC"))
239 .unwrap_or(false);
240 let metaspace = hf.model.byte_fallback
241 || hf
242 .normalizer
243 .as_ref()
244 .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
245 .unwrap_or(false);
246 let sp_prepend = hf
247 .normalizer
248 .as_ref()
249 .map(|n| n.to_string().contains("Prepend"))
250 .unwrap_or(false);
251 let (sp_prepend, sp_prepend_first) = if sp_prepend {
255 (true, false)
256 } else {
257 match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
258 Some(s) if s == "always" => (true, false),
259 Some(s) if s == "first" => (false, true),
260 _ => (false, false),
261 }
262 };
263 let split_res = if metaspace {
264 Vec::new()
265 } else {
266 let mut pats = Vec::new();
267 if let Some(pt) = hf.pre_tokenizer.as_ref() {
268 collect_split_patterns(pt, &mut pats);
269 }
270 if pats.is_empty() {
271 pats.push(DEFAULT_SPLIT.to_string());
272 }
273 pats.iter()
274 .map(|p| {
275 fancy_regex::Regex::new(p)
276 .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
277 })
278 .collect::<Result<Vec<_>, _>>()?
279 };
280
281 let mut bos_token_id = None;
283 let mut eos_token_id = None;
284 let mut pad_token_id = None;
285 let mut im_start_id = None;
286 let mut im_end_id = None;
287 let mut special_ids = HashSet::new();
288 let mut added_ids = HashSet::new();
289 let mut added = Vec::new();
290
291 for at in &hf.added_tokens {
292 vocab.insert(at.content.clone(), at.id);
293 added.push((at.content.clone(), at.id));
294 added_ids.insert(at.id);
295 if at.special {
296 special_ids.insert(at.id);
297 }
298 match at.content.as_str() {
299 "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
300 "<|im_start|>" => im_start_id = Some(at.id),
301 "<|im_end|>" => im_end_id = Some(at.id),
302 "<s>" | "[BOS]" => bos_token_id = Some(at.id),
303 "<bos>" => {
307 bos_token_id = Some(at.id);
308 saw_gemma_bos = true;
309 }
310 "<pad>" => pad_token_id = Some(at.id),
311 _ => {}
312 }
313 }
314 added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
315
316 let gemma_family = saw_gemma_bos
323 || vocab.contains_key("<start_of_turn>")
324 || added.iter().any(|(c, _)| c == "<start_of_turn>");
325
326 if let Some(pp) = hf.post_processor.as_ref() {
330 let pp = pp.to_string();
331 for name in ["<bos>", "<s>"] {
332 if pp.contains(&format!("\"{name}\"")) {
333 if let Some(&id) = vocab.get(name) {
334 bos_token_id = Some(id);
335 }
336 break;
337 }
338 }
339 }
340
341 let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
343 let mut id_to_token = vec![String::new(); max_id + 1];
344 for (token, &id) in &vocab {
345 if (id as usize) < id_to_token.len() {
346 id_to_token[id as usize] = token.clone();
347 }
348 }
349
350 let (byte_to_char, char_to_byte) = bytes_to_unicode();
351
352 tracing::info!(
353 "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
354 vocab.len(),
355 ranks.len(),
356 added.len(),
357 eos_token_id
358 );
359
360 Ok(Self {
361 vocab,
362 id_to_token,
363 ranks,
364 added,
365 added_ids,
366 special_ids,
367 split_res,
368 metaspace,
369 sp_prepend,
370 sp_prepend_first,
371 nfc,
372 byte_to_char,
373 char_to_byte,
374 bos_token_id,
375 eos_token_id,
376 pad_token_id,
377 im_start_id,
378 im_end_id,
379 chat_template: None,
380 extra_eos: HashSet::new(),
381 add_bos: add_bos_detected || gemma_family,
382 })
383 }
384
385 pub fn byte_level() -> Self {
387 let mut vocab = HashMap::new();
388 let mut id_to_token = Vec::with_capacity(256);
389 for i in 0..256u32 {
390 let tok = format!("<0x{:02X}>", i);
391 vocab.insert(tok.clone(), i);
392 id_to_token.push(tok);
393 }
394 let (byte_to_char, char_to_byte) = bytes_to_unicode();
395 Self {
396 vocab,
397 id_to_token,
398 ranks: HashMap::new(),
399 added: Vec::new(),
400 added_ids: HashSet::new(),
401 special_ids: HashSet::new(),
402 split_res: Vec::new(),
403 metaspace: false,
404 sp_prepend: false,
405 sp_prepend_first: false,
406 nfc: false,
407 byte_to_char,
408 char_to_byte,
409 bos_token_id: None,
410 eos_token_id: None,
411 pad_token_id: None,
412 im_start_id: None,
413 im_end_id: None,
414 chat_template: None,
415 extra_eos: HashSet::new(),
416 add_bos: false,
417 }
418 }
419
420 pub fn encode(&self, text: &str) -> Vec<u32> {
422 let mut ids = Vec::new();
423 let mut rest = text;
425 let mut head = true;
430 'outer: while !rest.is_empty() {
431 let mut best: Option<(usize, usize, u32)> = None; for (content, id) in &self.added {
433 if let Some(pos) = rest.find(content.as_str()) {
434 let better = match best {
435 None => true,
436 Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
437 };
438 if better {
439 best = Some((pos, content.len(), *id));
440 }
441 if pos == 0 {
442 break; }
444 }
445 }
446 match best {
447 Some((pos, len, id)) => {
448 self.encode_segment_at(&rest[..pos], head, &mut ids);
449 ids.push(id);
450 rest = &rest[pos + len..];
451 head = false;
452 }
453 None => {
454 self.encode_segment_at(rest, head, &mut ids);
455 break 'outer;
456 }
457 }
458 }
459 ids
460 }
461
462 fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
466 if segment.is_empty() {
467 return;
468 }
469 let norm: String = if self.nfc {
470 segment.nfc().collect()
471 } else {
472 segment.to_string()
473 };
474 if self.metaspace {
475 let sp = if self.sp_prepend {
479 format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
482 } else {
483 let replaced = norm.replace(' ', "\u{2581}");
487 if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
488 format!("\u{2581}{replaced}")
489 } else {
490 replaced
491 }
492 };
493 self.bpe_piece_sp(&sp, out);
494 return;
495 }
496 if !self.split_res.is_empty() {
497 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
500 for re in &self.split_res {
501 let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
502 for (ps, pe) in pieces {
503 let seg = &norm[ps..pe];
504 let mut last = 0usize;
505 for m in re.find_iter(seg) {
506 let m = match m {
507 Ok(m) => m,
508 Err(e) => {
509 tracing::error!("pre-tokenizer regex failed: {e}");
510 break;
511 }
512 };
513 if m.start() > last {
514 next.push((ps + last, ps + m.start()));
515 }
516 if m.end() > m.start() {
517 next.push((ps + m.start(), ps + m.end()));
518 }
519 last = m.end();
520 }
521 if last < seg.len() {
522 next.push((ps + last, pe));
523 }
524 }
525 pieces = next;
526 }
527 for (ps, pe) in pieces {
528 self.bpe_piece(&norm[ps..pe], out);
529 }
530 } else {
531 {
532 for b in norm.bytes() {
534 let tok = format!("<0x{:02X}>", b);
535 if let Some(&id) = self.vocab.get(&tok) {
536 out.push(id);
537 }
538 }
539 }
540 }
541 }
542
543 fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
546 if piece.is_empty() {
547 return;
548 }
549 let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
550 loop {
551 let mut best: Option<(u32, usize)> = None;
552 for i in 0..sym.len().saturating_sub(1) {
553 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
554 if best.map(|(br, _)| r < br).unwrap_or(true) {
555 best = Some((r, i));
556 }
557 }
558 }
559 let Some((_, i)) = best else { break };
560 let merged = format!("{}{}", sym[i], sym[i + 1]);
561 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
562 let mut j = 0;
563 while j + 1 < sym.len() {
564 if sym[j] == left && sym[j + 1] == right {
565 sym[j] = merged.clone();
566 sym.remove(j + 1);
567 }
568 j += 1;
569 }
570 }
571 for t in &sym {
572 if let Some(&id) = self.vocab.get(t) {
573 out.push(id);
574 } else {
575 let mut ok = true;
576 for byte in t.bytes() {
577 let tok = format!("<0x{:02X}>", byte);
578 match self.vocab.get(&tok) {
579 Some(&id) => out.push(id),
580 None => {
581 ok = false;
582 break;
583 }
584 }
585 }
586 if !ok {
587 tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
588 }
589 }
590 }
591 }
592
593 fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
595 if piece.is_empty() {
596 return;
597 }
598 let mapped: Vec<String> = piece
599 .bytes()
600 .map(|b| self.byte_to_char[b as usize].to_string())
601 .collect();
602 let mut sym = mapped;
603
604 loop {
606 let mut best: Option<(u32, usize)> = None;
607 for i in 0..sym.len().saturating_sub(1) {
608 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
609 if best.map(|(br, _)| r < br).unwrap_or(true) {
610 best = Some((r, i));
611 }
612 }
613 }
614 let Some((_, i)) = best else { break };
615 let merged = format!("{}{}", sym[i], sym[i + 1]);
616 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
618 let mut j = 0;
619 while j + 1 < sym.len() {
620 if sym[j] == left && sym[j + 1] == right {
621 sym[j] = merged.clone();
622 sym.remove(j + 1);
623 }
624 j += 1;
625 }
626 }
627
628 for s in &sym {
629 if let Some(&id) = self.vocab.get(s) {
630 out.push(id);
631 } else {
632 let mut ok = true;
634 for ch in s.chars() {
635 let Some(&b) = self.char_to_byte.get(&ch) else {
636 ok = false;
637 break;
638 };
639 let tok = format!("<0x{:02X}>", b);
640 if let Some(&id) = self.vocab.get(&tok) {
641 out.push(id);
642 } else {
643 ok = false;
644 break;
645 }
646 }
647 if !ok {
648 tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
649 }
650 }
651 }
652 }
653
654 pub fn decode(&self, ids: &[u32]) -> String {
657 let mut bytes: Vec<u8> = Vec::new();
658 for &id in ids {
659 if self.special_ids.contains(&id) {
660 continue;
661 }
662 let idx = id as usize;
663 if idx >= self.id_to_token.len() {
664 continue;
665 }
666 let tok = &self.id_to_token[idx];
667 if self.added_ids.contains(&id) {
668 if self.metaspace && tok.contains('\u{2581}') {
671 bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
672 } else {
673 bytes.extend_from_slice(tok.as_bytes());
674 }
675 continue;
676 }
677 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
679 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
680 bytes.push(b);
681 continue;
682 }
683 }
684 if self.metaspace {
685 for ch in tok.chars() {
687 if ch == '\u{2581}' {
688 bytes.push(b' ');
689 } else {
690 let mut buf = [0u8; 4];
691 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
692 }
693 }
694 continue;
695 }
696 for ch in tok.chars() {
697 match self.char_to_byte.get(&ch) {
698 Some(&b) => bytes.push(b),
699 None => {
702 let mut buf = [0u8; 4];
703 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
704 }
705 }
706 }
707 }
708 let text = String::from_utf8_lossy(&bytes).into_owned();
709 if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
710 if let Some(stripped) = text.strip_prefix(' ') {
712 return stripped.to_string();
713 }
714 }
715 text
716 }
717
718 pub fn decode_token(&self, id: u32) -> String {
721 if self.special_ids.contains(&id) {
722 return String::new();
723 }
724 let idx = id as usize;
725 if idx >= self.id_to_token.len() {
726 return String::new();
727 }
728 let tok = &self.id_to_token[idx];
729 if self.added_ids.contains(&id) {
730 if self.metaspace && tok.contains('\u{2581}') {
731 return tok.replace('\u{2581}', " ");
732 }
733 return tok.clone();
734 }
735 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
736 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
737 return String::from_utf8_lossy(&[b]).into_owned();
738 }
739 }
740 if self.metaspace {
741 return tok.replace('\u{2581}', " ");
742 }
743 let mut bytes = Vec::new();
744 for ch in tok.chars() {
745 match self.char_to_byte.get(&ch) {
746 Some(&b) => bytes.push(b),
747 None => {
748 let mut buf = [0u8; 4];
749 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
750 }
751 }
752 }
753 String::from_utf8_lossy(&bytes).into_owned()
754 }
755
756 pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
760 self.apply_chat_template_opts(messages, None)
761 }
762
763 pub fn apply_chat_template_json(
780 &self,
781 messages: &[serde_json::Value],
782 tools: Option<&[serde_json::Value]>,
783 enable_thinking: Option<bool>,
784 ) -> Vec<u32> {
785 if let Some(tpl) = &self.chat_template {
786 match self.render_template_json(tpl, messages, tools, enable_thinking) {
787 Ok(text) => return self.with_bos(self.encode(&text)),
788 Err(e) => {
789 tracing::error!("chat template render failed ({e}); ChatML fallback");
790 }
791 }
792 }
793 let pairs: Vec<(String, String)> = messages
794 .iter()
795 .map(|m| {
796 (
797 m.get("role").and_then(|v| v.as_str()).unwrap_or("user").to_string(),
798 m.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(),
799 )
800 })
801 .collect();
802 self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
803 }
804
805 pub fn render_chat_json(
807 &self,
808 messages: &[serde_json::Value],
809 tools: Option<&[serde_json::Value]>,
810 enable_thinking: Option<bool>,
811 ) -> Option<String> {
812 let tpl = self.chat_template.as_ref()?;
813 match self.render_template_json(tpl, messages, tools, enable_thinking) {
814 Ok(t) => Some(t),
815 Err(e) => {
816 tracing::error!("chat template render (json): {e:#}");
817 eprintln!("chat template render (json): {e:#}");
818 None
819 }
820 }
821 }
822
823 fn render_template_json(
824 &self,
825 tpl: &str,
826 messages: &[serde_json::Value],
827 tools: Option<&[serde_json::Value]>,
828 enable_thinking: Option<bool>,
829 ) -> Result<String, minijinja::Error> {
830 let mut env = minijinja::Environment::new();
831 env.set_trim_blocks(true);
832 env.set_lstrip_blocks(true);
833 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
834 env.add_function("visible_text", |v: minijinja::Value| -> String {
840 if let Some(s) = v.as_str() {
841 return s.to_string();
842 }
843 if let Ok(iter) = v.try_iter() {
844 let mut out = Vec::new();
845 for item in iter {
846 if let Some(s) = item.as_str() {
847 out.push(s.to_string());
848 } else if let Ok(t) = item.get_attr("text") {
849 if let Some(s) = t.as_str() {
850 out.push(s.to_string());
851 }
852 }
853 }
854 return out.join("\n");
855 }
856 String::new()
857 });
858 env.add_template("chat", tpl)?;
859 let msgs: Vec<minijinja::Value> = messages
860 .iter()
861 .map(minijinja::Value::from_serialize)
862 .collect();
863 let tools_v: Option<Vec<minijinja::Value>> = tools.map(|ts| {
864 ts.iter().map(minijinja::Value::from_serialize).collect()
865 });
866 let tpl = env.get_template("chat")?;
867 let rendered = match (tools_v, enable_thinking) {
874 (Some(ts), Some(v)) => tpl.render(minijinja::context! {
875 messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
876 tool_call_format => "json",
877 })?,
878 (Some(ts), None) => tpl.render(minijinja::context! {
879 messages => msgs, tools => ts, add_generation_prompt => true,
880 tool_call_format => "json",
881 })?,
882 (None, Some(v)) => tpl.render(minijinja::context! {
883 messages => msgs, add_generation_prompt => true, enable_thinking => v,
884 tool_call_format => "json",
885 })?,
886 (None, None) => tpl.render(minijinja::context! {
887 messages => msgs, add_generation_prompt => true,
888 tool_call_format => "json",
889 })?,
890 };
891 Ok(rendered)
892 }
893
894 pub fn apply_chat_template_opts(
895 &self,
896 messages: &[(String, String)],
897 enable_thinking: Option<bool>,
898 ) -> Vec<u32> {
899 if let Some(tpl) = &self.chat_template {
900 match self.render_template(tpl, messages, enable_thinking) {
901 Ok(text) => return self.with_bos(self.encode(&text)),
902 Err(e) => {
903 tracing::error!("chat template render failed ({e}); ChatML fallback");
904 }
905 }
906 }
907 self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
908 }
909
910 pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
912 if self.add_bos {
913 if let Some(b) = self.bos_token_id {
914 if ids.first() != Some(&b) {
915 ids.insert(0, b);
916 }
917 }
918 }
919 ids
920 }
921
922 pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
924 self.render_chat_opts(messages, None)
925 }
926
927 pub fn render_chat_opts(
929 &self,
930 messages: &[(String, String)],
931 enable_thinking: Option<bool>,
932 ) -> Option<String> {
933 let tpl = self.chat_template.as_ref()?;
934 match self.render_template(tpl, messages, enable_thinking) {
935 Ok(t) => Some(t),
936 Err(e) => {
937 tracing::error!("chat template render: {e:#}");
938 None
939 }
940 }
941 }
942
943 fn render_template(
944 &self,
945 tpl: &str,
946 messages: &[(String, String)],
947 enable_thinking: Option<bool>,
948 ) -> Result<String, minijinja::Error> {
949 let mut env = minijinja::Environment::new();
950 env.set_trim_blocks(true);
951 env.set_lstrip_blocks(true);
952 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
954 env.add_template("chat", tpl)?;
955 let msgs: Vec<minijinja::Value> = messages
956 .iter()
957 .map(|(role, content)| {
958 minijinja::context! { role => role, content => content }
959 })
960 .collect();
961 let rendered = match enable_thinking {
964 Some(v) => env.get_template("chat")?.render(minijinja::context! {
965 messages => msgs,
966 add_generation_prompt => true,
967 enable_thinking => v,
968 })?,
969 None => env.get_template("chat")?.render(minijinja::context! {
970 messages => msgs,
971 add_generation_prompt => true,
972 })?,
973 };
974 if enable_thinking == Some(false) && !rendered.contains("</think>") {
978 if let Some(pos) = rendered.rfind("assistant") {
979 let mut insert_at = pos + "assistant".len();
980 if let Some(idx) = rendered[insert_at..].find('\n') {
981 insert_at += idx + 1;
982 }
983 let mut out = String::with_capacity(rendered.len() + 24);
984 out.push_str(&rendered[..insert_at]);
985 if !out.ends_with('\n') {
986 out.push('\n');
987 }
988 out.push_str("<think>\n\n</think>\n\n");
989 out.push_str(&rendered[insert_at..]);
990 return Ok(out);
991 }
992 }
993 Ok(rendered)
994 }
995
996 fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
998 self.chatml_fallback_opts(messages, None)
999 }
1000
1001 fn chatml_fallback_opts(
1003 &self,
1004 messages: &[(String, String)],
1005 enable_thinking: Option<bool>,
1006 ) -> Vec<u32> {
1007 let mut tokens = Vec::new();
1008
1009 for (role, content) in messages {
1010 if let Some(start_id) = self.im_start_id {
1012 tokens.push(start_id);
1013 }
1014 tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1015 if let Some(end_id) = self.im_end_id {
1016 tokens.push(end_id);
1017 }
1018 tokens.extend(self.encode("\n"));
1019 }
1020
1021 if let Some(start_id) = self.im_start_id {
1023 tokens.push(start_id);
1024 }
1025 tokens.extend(self.encode("assistant\n"));
1026 if enable_thinking == Some(false) {
1027 tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1028 }
1029
1030 tokens
1031 }
1032
1033 pub fn vocab_size(&self) -> usize {
1035 self.id_to_token.len()
1036 }
1037
1038 pub fn is_eos(&self, id: u32) -> bool {
1040 self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1041 }
1042}
1043
1044#[derive(Debug, thiserror::Error)]
1045pub enum TokenizerError {
1046 #[error("IO error: {0}")]
1047 Io(String),
1048 #[error("Parse error: {0}")]
1049 Parse(String),
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 #[test]
1057 fn byte_unicode_bijection() {
1058 let (b2c, c2b) = bytes_to_unicode();
1059 for b in 0..=255u8 {
1060 assert_eq!(c2b[&b2c[b as usize]], b);
1061 }
1062 assert_eq!(b2c[b' ' as usize], 'Ġ');
1064 assert_eq!(b2c[b'\n' as usize], 'Ċ');
1065 }
1066
1067 #[test]
1068 fn byte_level_roundtrip_utf8() {
1069 let tok = Tokenizer::byte_level();
1070 let text = "hello 🌍 hi\n";
1071 let ids = tok.encode(text);
1072 assert_eq!(ids.len(), text.len()); assert_eq!(tok.decode(&ids), text);
1074 }
1075
1076 fn mini_json() -> String {
1079 let vocab: Vec<(&str, u32)> = vec![
1081 ("h", 0),
1082 ("e", 1),
1083 ("l", 2),
1084 ("o", 3),
1085 ("Ġ", 4),
1086 ("w", 5),
1087 ("r", 6),
1088 ("d", 7),
1089 ("he", 8),
1090 ("Ġw", 9),
1091 ];
1092 let vocab_json: String = vocab
1093 .iter()
1094 .map(|(t, i)| format!("\"{t}\": {i}"))
1095 .collect::<Vec<_>>()
1096 .join(", ");
1097 format!(
1098 r#"{{
1099 "model": {{
1100 "type": "BPE",
1101 "vocab": {{ {vocab_json} }},
1102 "merges": [["h", "e"], ["Ġ", "w"]]
1103 }},
1104 "added_tokens": [
1105 {{"id": 10, "content": "<|eot|>", "special": true}}
1106 ]
1107 }}"#
1108 )
1109 }
1110
1111 #[test]
1114 fn real_tokenizer_parity_when_available() {
1115 let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1116 return;
1117 };
1118 let t = Tokenizer::from_file(&path).expect("load");
1119 for (text, want) in [
1120 (
1121 "The capital of France is",
1122 vec![671u32, 6102, 294, 8760, 344],
1123 ),
1124 ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1125 ] {
1126 let got = t.encode(text);
1127 assert_eq!(got, want, "«{text}»");
1128 }
1129 }
1130
1131 #[test]
1137 fn every_split_in_a_sequence_is_applied() {
1138 let pt = serde_json::json!({
1139 "type": "Sequence",
1140 "pretokenizers": [
1141 {"type": "Split", "behavior": "Isolated",
1142 "pattern": {"Regex": r"\p{N}{1,3}"}},
1143 {"type": "Split", "behavior": "Isolated",
1144 "pattern": {"Regex": r" ?[\p{L}]+"}},
1145 {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1146 ]
1147 });
1148 let mut pats = Vec::new();
1149 collect_split_patterns(&pt, &mut pats);
1150 assert_eq!(
1151 pats.len(),
1152 2,
1153 "both Split stages must be collected: {pats:?}"
1154 );
1155 assert!(pats[0].contains("p{N}"), "digit rule first");
1156 assert!(pats[1].contains("p{L}"), "word rule second");
1157
1158 let re: Vec<fancy_regex::Regex> = pats
1162 .iter()
1163 .map(|p| fancy_regex::Regex::new(p).unwrap())
1164 .collect();
1165 let norm = "ab cd12";
1166 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1167 for r in &re {
1168 let mut next = Vec::new();
1169 for (ps, pe) in pieces {
1170 let seg = &norm[ps..pe];
1171 let mut last = 0;
1172 for m in r.find_iter(seg).flatten() {
1173 if m.start() > last {
1174 next.push((ps + last, ps + m.start()));
1175 }
1176 if m.end() > m.start() {
1177 next.push((ps + m.start(), ps + m.end()));
1178 }
1179 last = m.end();
1180 }
1181 if last < seg.len() {
1182 next.push((ps + last, pe));
1183 }
1184 }
1185 pieces = next;
1186 }
1187 let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1188 assert_eq!(
1189 got,
1190 vec!["ab", " cd", "12"],
1191 "staged split produced {got:?}"
1192 );
1193 }
1194
1195 #[test]
1196 fn full_pipeline_merges_and_added_tokens() {
1197 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1198 let ids = tok.encode("hello world");
1200 assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1201 assert_eq!(tok.decode(&ids), "hello world");
1202 let ids2 = tok.encode("he<|eot|>he");
1204 assert_eq!(ids2, vec![8, 10, 8]);
1205 assert_eq!(tok.decode(&ids2), "hehe");
1206 }
1207
1208 #[test]
1209 fn non_ascii_is_never_silently_dropped() {
1210 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1211 let ids = tok.encode("hello");
1214 assert!(!ids.is_empty());
1215 }
1216}