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_re: Option<fancy_regex::Regex>,
38 sp_prepend: bool,
41 metaspace: bool,
44 nfc: bool,
47 byte_to_char: [char; 256],
49 char_to_byte: HashMap<char, u8>,
51 pub bos_token_id: Option<u32>,
53 pub eos_token_id: Option<u32>,
54 pub pad_token_id: Option<u32>,
55 pub im_start_id: Option<u32>,
57 pub im_end_id: Option<u32>,
58 pub chat_template: Option<String>,
61 pub extra_eos: HashSet<u32>,
63 pub add_bos: bool,
65}
66
67impl std::fmt::Debug for Tokenizer {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("Tokenizer")
70 .field("vocab", &self.vocab.len())
71 .field("merges", &self.ranks.len())
72 .field("added", &self.added.len())
73 .finish()
74 }
75}
76
77fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
80 let mut b2c = ['\0'; 256];
81 let mut c2b = HashMap::with_capacity(256);
82 let mut n = 0u32;
83 for b in 0..=255u16 {
84 let printable =
85 (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
86 let c = if printable {
87 char::from_u32(b as u32).unwrap()
88 } else {
89 let c = char::from_u32(256 + n).unwrap();
90 n += 1;
91 c
92 };
93 b2c[b as usize] = c;
94 c2b.insert(c, b as u8);
95 }
96 (b2c, c2b)
97}
98
99#[derive(Deserialize)]
101struct HfTokenizerJson {
102 model: HfModel,
103 #[serde(default)]
104 added_tokens: Vec<HfAddedToken>,
105 #[serde(default)]
106 pre_tokenizer: Option<serde_json::Value>,
107 #[serde(default)]
108 normalizer: Option<serde_json::Value>,
109 #[serde(default)]
110 post_processor: Option<serde_json::Value>,
111}
112
113#[derive(Deserialize)]
114struct HfModel {
115 vocab: HashMap<String, u32>,
116 #[serde(default)]
117 merges: Vec<HfMerge>,
118 #[serde(default)]
119 byte_fallback: bool,
120}
121
122#[derive(Deserialize)]
125#[serde(untagged)]
126enum HfMerge {
127 Pair([String; 2]),
128 Text(String),
129}
130
131#[derive(Deserialize)]
132struct HfAddedToken {
133 id: u32,
134 content: String,
135 special: bool,
136}
137
138fn find_split_pattern(pt: &serde_json::Value) -> Option<String> {
141 if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
142 return pt
143 .get("pattern")
144 .and_then(|p| p.get("Regex"))
145 .and_then(|r| r.as_str())
146 .map(String::from);
147 }
148 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
149 return list.iter().find_map(find_split_pattern);
150 }
151 None
152}
153
154impl Tokenizer {
155 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
157 let data = std::fs::read_to_string(path.as_ref())
158 .map_err(|e| TokenizerError::Io(e.to_string()))?;
159 Self::from_json(&data)
160 }
161
162 pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
164 let s = std::str::from_utf8(bytes)
165 .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
166 Self::from_json(s)
167 }
168
169 pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
171 let hf: HfTokenizerJson =
172 serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
173
174 let mut vocab = hf.model.vocab;
175 let mut ranks = HashMap::new();
176 for (rank, m) in hf.model.merges.into_iter().enumerate() {
177 let (a, b) = match m {
178 HfMerge::Pair([a, b]) => (a, b),
179 HfMerge::Text(s) => {
180 let mut it = s.splitn(2, ' ');
181 match (it.next(), it.next()) {
182 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
183 _ => continue,
184 }
185 }
186 };
187 ranks.insert((a, b), rank as u32);
188 }
189
190 let add_bos = hf
195 .post_processor
196 .as_ref()
197 .map(|p| {
198 let pp = p.to_string();
199 pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
200 })
201 .unwrap_or(false);
202 let nfc = hf
203 .normalizer
204 .as_ref()
205 .map(|n| n.to_string().contains("NFC"))
206 .unwrap_or(false);
207 let metaspace = hf.model.byte_fallback
208 || hf
209 .normalizer
210 .as_ref()
211 .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
212 .unwrap_or(false);
213 let sp_prepend = hf
214 .normalizer
215 .as_ref()
216 .map(|n| n.to_string().contains("Prepend"))
217 .unwrap_or(false);
218 let split_re = if metaspace {
219 None
220 } else {
221 let pattern = hf
222 .pre_tokenizer
223 .as_ref()
224 .and_then(find_split_pattern)
225 .unwrap_or_else(|| DEFAULT_SPLIT.to_string());
226 Some(
227 fancy_regex::Regex::new(&pattern)
228 .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))?,
229 )
230 };
231
232 let mut bos_token_id = None;
234 let mut eos_token_id = None;
235 let mut pad_token_id = None;
236 let mut im_start_id = None;
237 let mut im_end_id = None;
238 let mut special_ids = HashSet::new();
239 let mut added_ids = HashSet::new();
240 let mut added = Vec::new();
241
242 for at in &hf.added_tokens {
243 vocab.insert(at.content.clone(), at.id);
244 added.push((at.content.clone(), at.id));
245 added_ids.insert(at.id);
246 if at.special {
247 special_ids.insert(at.id);
248 }
249 match at.content.as_str() {
250 "<|endoftext|>" | "</s>" => eos_token_id = Some(at.id),
251 "<|im_start|>" => im_start_id = Some(at.id),
252 "<|im_end|>" => im_end_id = Some(at.id),
253 "<s>" | "<bos>" => bos_token_id = Some(at.id),
254 "<pad>" => pad_token_id = Some(at.id),
255 _ => {}
256 }
257 }
258 added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
259
260 let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
262 let mut id_to_token = vec![String::new(); max_id + 1];
263 for (token, &id) in &vocab {
264 if (id as usize) < id_to_token.len() {
265 id_to_token[id as usize] = token.clone();
266 }
267 }
268
269 let (byte_to_char, char_to_byte) = bytes_to_unicode();
270
271 tracing::info!(
272 "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
273 vocab.len(),
274 ranks.len(),
275 added.len(),
276 eos_token_id
277 );
278
279 Ok(Self {
280 vocab,
281 id_to_token,
282 ranks,
283 added,
284 added_ids,
285 special_ids,
286 split_re,
287 metaspace,
288 sp_prepend,
289 nfc,
290 byte_to_char,
291 char_to_byte,
292 bos_token_id,
293 eos_token_id,
294 pad_token_id,
295 im_start_id,
296 im_end_id,
297 chat_template: None,
298 extra_eos: HashSet::new(),
299 add_bos,
300 })
301 }
302
303 pub fn byte_level() -> Self {
305 let mut vocab = HashMap::new();
306 let mut id_to_token = Vec::with_capacity(256);
307 for i in 0..256u32 {
308 let tok = format!("<0x{:02X}>", i);
309 vocab.insert(tok.clone(), i);
310 id_to_token.push(tok);
311 }
312 let (byte_to_char, char_to_byte) = bytes_to_unicode();
313 Self {
314 vocab,
315 id_to_token,
316 ranks: HashMap::new(),
317 added: Vec::new(),
318 added_ids: HashSet::new(),
319 special_ids: HashSet::new(),
320 split_re: None,
321 metaspace: false,
322 sp_prepend: false,
323 nfc: false,
324 byte_to_char,
325 char_to_byte,
326 bos_token_id: None,
327 eos_token_id: None,
328 pad_token_id: None,
329 im_start_id: None,
330 im_end_id: None,
331 chat_template: None,
332 extra_eos: HashSet::new(),
333 add_bos: false,
334 }
335 }
336
337 pub fn encode(&self, text: &str) -> Vec<u32> {
339 let mut ids = Vec::new();
340 let mut rest = text;
342 'outer: while !rest.is_empty() {
343 let mut best: Option<(usize, usize, u32)> = None; for (content, id) in &self.added {
345 if let Some(pos) = rest.find(content.as_str()) {
346 let better = match best {
347 None => true,
348 Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
349 };
350 if better {
351 best = Some((pos, content.len(), *id));
352 }
353 if pos == 0 {
354 break; }
356 }
357 }
358 match best {
359 Some((pos, len, id)) => {
360 self.encode_segment(&rest[..pos], &mut ids);
361 ids.push(id);
362 rest = &rest[pos + len..];
363 }
364 None => {
365 self.encode_segment(rest, &mut ids);
366 break 'outer;
367 }
368 }
369 }
370 ids
371 }
372
373 fn encode_segment(&self, segment: &str, out: &mut Vec<u32>) {
375 if segment.is_empty() {
376 return;
377 }
378 let norm: String = if self.nfc {
379 segment.nfc().collect()
380 } else {
381 segment.to_string()
382 };
383 if self.metaspace {
384 let sp = if self.sp_prepend {
388 format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
389 } else {
390 norm.replace(' ', "\u{2581}")
391 };
392 self.bpe_piece_sp(&sp, out);
393 return;
394 }
395 match &self.split_re {
396 Some(re) => {
397 let mut last = 0;
398 for m in re.find_iter(&norm) {
399 let m = match m {
400 Ok(m) => m,
401 Err(e) => {
402 tracing::error!("pre-tokenizer regex failed: {e}");
403 break;
404 }
405 };
406 if m.start() > last {
407 self.bpe_piece(&norm[last..m.start()], out);
409 }
410 self.bpe_piece(m.as_str(), out);
411 last = m.end();
412 }
413 if last < norm.len() {
414 self.bpe_piece(&norm[last..], out);
415 }
416 }
417 None => {
418 for b in norm.bytes() {
420 let tok = format!("<0x{:02X}>", b);
421 if let Some(&id) = self.vocab.get(&tok) {
422 out.push(id);
423 }
424 }
425 }
426 }
427 }
428
429 fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
432 if piece.is_empty() {
433 return;
434 }
435 let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
436 loop {
437 let mut best: Option<(u32, usize)> = None;
438 for i in 0..sym.len().saturating_sub(1) {
439 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
440 if best.map(|(br, _)| r < br).unwrap_or(true) {
441 best = Some((r, i));
442 }
443 }
444 }
445 let Some((_, i)) = best else { break };
446 let merged = format!("{}{}", sym[i], sym[i + 1]);
447 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
448 let mut j = 0;
449 while j + 1 < sym.len() {
450 if sym[j] == left && sym[j + 1] == right {
451 sym[j] = merged.clone();
452 sym.remove(j + 1);
453 }
454 j += 1;
455 }
456 }
457 for t in &sym {
458 if let Some(&id) = self.vocab.get(t) {
459 out.push(id);
460 } else {
461 let mut ok = true;
462 for byte in t.bytes() {
463 let tok = format!("<0x{:02X}>", byte);
464 match self.vocab.get(&tok) {
465 Some(&id) => out.push(id),
466 None => {
467 ok = false;
468 break;
469 }
470 }
471 }
472 if !ok {
473 tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
474 }
475 }
476 }
477 }
478
479 fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
481 if piece.is_empty() {
482 return;
483 }
484 let mapped: Vec<String> = piece
485 .bytes()
486 .map(|b| self.byte_to_char[b as usize].to_string())
487 .collect();
488 let mut sym = mapped;
489
490 loop {
492 let mut best: Option<(u32, usize)> = None;
493 for i in 0..sym.len().saturating_sub(1) {
494 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
495 if best.map(|(br, _)| r < br).unwrap_or(true) {
496 best = Some((r, i));
497 }
498 }
499 }
500 let Some((_, i)) = best else { break };
501 let merged = format!("{}{}", sym[i], sym[i + 1]);
502 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
504 let mut j = 0;
505 while j + 1 < sym.len() {
506 if sym[j] == left && sym[j + 1] == right {
507 sym[j] = merged.clone();
508 sym.remove(j + 1);
509 }
510 j += 1;
511 }
512 }
513
514 for s in &sym {
515 if let Some(&id) = self.vocab.get(s) {
516 out.push(id);
517 } else {
518 let mut ok = true;
520 for ch in s.chars() {
521 let Some(&b) = self.char_to_byte.get(&ch) else {
522 ok = false;
523 break;
524 };
525 let tok = format!("<0x{:02X}>", b);
526 if let Some(&id) = self.vocab.get(&tok) {
527 out.push(id);
528 } else {
529 ok = false;
530 break;
531 }
532 }
533 if !ok {
534 tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
535 }
536 }
537 }
538 }
539
540 pub fn decode(&self, ids: &[u32]) -> String {
543 let mut bytes: Vec<u8> = Vec::new();
544 for &id in ids {
545 if self.special_ids.contains(&id) {
546 continue;
547 }
548 let idx = id as usize;
549 if idx >= self.id_to_token.len() {
550 continue;
551 }
552 let tok = &self.id_to_token[idx];
553 if self.added_ids.contains(&id) {
554 bytes.extend_from_slice(tok.as_bytes());
555 continue;
556 }
557 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
559 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
560 bytes.push(b);
561 continue;
562 }
563 }
564 if self.metaspace {
565 for ch in tok.chars() {
567 if ch == '\u{2581}' {
568 bytes.push(b' ');
569 } else {
570 let mut buf = [0u8; 4];
571 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
572 }
573 }
574 continue;
575 }
576 for ch in tok.chars() {
577 match self.char_to_byte.get(&ch) {
578 Some(&b) => bytes.push(b),
579 None => {
582 let mut buf = [0u8; 4];
583 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
584 }
585 }
586 }
587 }
588 let text = String::from_utf8_lossy(&bytes).into_owned();
589 if self.metaspace && self.sp_prepend {
590 if let Some(stripped) = text.strip_prefix(' ') {
592 return stripped.to_string();
593 }
594 }
595 text
596 }
597
598 pub fn decode_token(&self, id: u32) -> String {
601 if self.special_ids.contains(&id) {
602 return String::new();
603 }
604 let idx = id as usize;
605 if idx >= self.id_to_token.len() {
606 return String::new();
607 }
608 let tok = &self.id_to_token[idx];
609 if self.added_ids.contains(&id) {
610 return tok.clone();
611 }
612 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
613 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
614 return String::from_utf8_lossy(&[b]).into_owned();
615 }
616 }
617 if self.metaspace {
618 return tok.replace('\u{2581}', " ");
619 }
620 let mut bytes = Vec::new();
621 for ch in tok.chars() {
622 match self.char_to_byte.get(&ch) {
623 Some(&b) => bytes.push(b),
624 None => {
625 let mut buf = [0u8; 4];
626 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
627 }
628 }
629 }
630 String::from_utf8_lossy(&bytes).into_owned()
631 }
632
633 pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
637 self.apply_chat_template_opts(messages, None)
638 }
639
640 pub fn apply_chat_template_opts(
645 &self,
646 messages: &[(String, String)],
647 enable_thinking: Option<bool>,
648 ) -> Vec<u32> {
649 if let Some(tpl) = &self.chat_template {
650 match self.render_template(tpl, messages, enable_thinking) {
651 Ok(text) => return self.with_bos(self.encode(&text)),
652 Err(e) => {
653 tracing::error!("chat template render failed ({e}); ChatML fallback");
654 }
655 }
656 }
657 self.with_bos(self.chatml_fallback(messages))
658 }
659
660 pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
662 if self.add_bos {
663 if let Some(b) = self.bos_token_id {
664 if ids.first() != Some(&b) {
665 ids.insert(0, b);
666 }
667 }
668 }
669 ids
670 }
671
672 pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
674 let tpl = self.chat_template.as_ref()?;
675 match self.render_template(tpl, messages, None) {
676 Ok(t) => Some(t),
677 Err(e) => {
678 tracing::error!("chat template render: {e:#}");
679 None
680 }
681 }
682 }
683
684 fn render_template(
685 &self,
686 tpl: &str,
687 messages: &[(String, String)],
688 enable_thinking: Option<bool>,
689 ) -> Result<String, minijinja::Error> {
690 let mut env = minijinja::Environment::new();
691 env.set_trim_blocks(true);
692 env.set_lstrip_blocks(true);
693 env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
695 env.add_template("chat", tpl)?;
696 let msgs: Vec<minijinja::Value> = messages
697 .iter()
698 .map(|(role, content)| {
699 minijinja::context! { role => role, content => content }
700 })
701 .collect();
702 let rendered = match enable_thinking {
705 Some(v) => env.get_template("chat")?.render(minijinja::context! {
706 messages => msgs,
707 add_generation_prompt => true,
708 enable_thinking => v,
709 })?,
710 None => env.get_template("chat")?.render(minijinja::context! {
711 messages => msgs,
712 add_generation_prompt => true,
713 })?,
714 };
715 if enable_thinking == Some(false) && !rendered.contains("</think>") {
719 if let Some(pos) = rendered.rfind("assistant") {
720 let mut insert_at = pos + "assistant".len();
721 if let Some(idx) = rendered[insert_at..].find('\n') {
722 insert_at += idx + 1;
723 }
724 let mut out = String::with_capacity(rendered.len() + 24);
725 out.push_str(&rendered[..insert_at]);
726 if !out.ends_with('\n') {
727 out.push('\n');
728 }
729 out.push_str("<think>\n\n</think>\n\n");
730 out.push_str(&rendered[insert_at..]);
731 return Ok(out);
732 }
733 }
734 Ok(rendered)
735 }
736
737 fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
739 let mut tokens = Vec::new();
740
741 for (role, content) in messages {
742 if let Some(start_id) = self.im_start_id {
744 tokens.push(start_id);
745 }
746 tokens.extend(self.encode(&format!("{}\n{}", role, content)));
747 if let Some(end_id) = self.im_end_id {
748 tokens.push(end_id);
749 }
750 tokens.extend(self.encode("\n"));
751 }
752
753 if let Some(start_id) = self.im_start_id {
755 tokens.push(start_id);
756 }
757 tokens.extend(self.encode("assistant\n"));
758
759 tokens
760 }
761
762 pub fn vocab_size(&self) -> usize {
764 self.id_to_token.len()
765 }
766
767 pub fn is_eos(&self, id: u32) -> bool {
769 self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
770 }
771}
772
773#[derive(Debug, thiserror::Error)]
774pub enum TokenizerError {
775 #[error("IO error: {0}")]
776 Io(String),
777 #[error("Parse error: {0}")]
778 Parse(String),
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn byte_unicode_bijection() {
787 let (b2c, c2b) = bytes_to_unicode();
788 for b in 0..=255u8 {
789 assert_eq!(c2b[&b2c[b as usize]], b);
790 }
791 assert_eq!(b2c[b' ' as usize], 'Ġ');
793 assert_eq!(b2c[b'\n' as usize], 'Ċ');
794 }
795
796 #[test]
797 fn byte_level_roundtrip_utf8() {
798 let tok = Tokenizer::byte_level();
799 let text = "hello 🌍 hi\n";
800 let ids = tok.encode(text);
801 assert_eq!(ids.len(), text.len()); assert_eq!(tok.decode(&ids), text);
803 }
804
805 fn mini_json() -> String {
808 let vocab: Vec<(&str, u32)> = vec![
810 ("h", 0),
811 ("e", 1),
812 ("l", 2),
813 ("o", 3),
814 ("Ġ", 4),
815 ("w", 5),
816 ("r", 6),
817 ("d", 7),
818 ("he", 8),
819 ("Ġw", 9),
820 ];
821 let vocab_json: String = vocab
822 .iter()
823 .map(|(t, i)| format!("\"{t}\": {i}"))
824 .collect::<Vec<_>>()
825 .join(", ");
826 format!(
827 r#"{{
828 "model": {{
829 "type": "BPE",
830 "vocab": {{ {vocab_json} }},
831 "merges": [["h", "e"], ["Ġ", "w"]]
832 }},
833 "added_tokens": [
834 {{"id": 10, "content": "<|eot|>", "special": true}}
835 ]
836 }}"#
837 )
838 }
839
840 #[test]
841 fn full_pipeline_merges_and_added_tokens() {
842 let tok = Tokenizer::from_json(&mini_json()).unwrap();
843 let ids = tok.encode("hello world");
845 assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
846 assert_eq!(tok.decode(&ids), "hello world");
847 let ids2 = tok.encode("he<|eot|>he");
849 assert_eq!(ids2, vec![8, 10, 8]);
850 assert_eq!(tok.decode(&ids2), "hehe");
851 }
852
853 #[test]
854 fn non_ascii_is_never_silently_dropped() {
855 let tok = Tokenizer::from_json(&mini_json()).unwrap();
856 let ids = tok.encode("hello");
859 assert!(!ids.is_empty());
860 }
861}