1use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use std::sync::OnceLock;
16use unicode_normalization::UnicodeNormalization;
17
18const 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+";
21
22pub const TOOL_MARKUP_TOKENS: &[&str] = &[
34 "<tool_call>",
35 "</tool_call>",
36 "<function",
37 "</function>",
38 "<param",
39 "</param>",
40];
41
42pub struct Tokenizer {
44 vocab: HashMap<String, u32>,
46 id_to_token: Vec<String>,
48 ranks: HashMap<(String, String), u32>,
50 sp_ids: OnceLock<Option<SpMerges>>,
54 added: Vec<(String, u32)>,
56 added_ids: HashSet<u32>,
58 special_ids: HashSet<u32>,
60 split_res: Vec<fancy_regex::Regex>,
64 sp_prepend: bool,
67 sp_prepend_first: bool,
73 metaspace: bool,
76 nfc: bool,
79 byte_to_char: [char; 256],
81 char_to_byte: HashMap<char, u8>,
83 pub bos_token_id: Option<u32>,
85 pub eos_token_id: Option<u32>,
86 pub pad_token_id: Option<u32>,
87 pub im_start_id: Option<u32>,
89 pub im_end_id: Option<u32>,
90 pub chat_template: Option<String>,
93 pub extra_eos: HashSet<u32>,
95 pub add_bos: bool,
97}
98
99impl std::fmt::Debug for Tokenizer {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct("Tokenizer")
102 .field("vocab", &self.vocab.len())
103 .field("merges", &self.ranks.len())
104 .field("added", &self.added.len())
105 .finish()
106 }
107}
108
109fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
112 let mut b2c = ['\0'; 256];
113 let mut c2b = HashMap::with_capacity(256);
114 let mut n = 0u32;
115 for b in 0..=255u16 {
116 let printable =
117 (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
118 let c = if printable {
119 char::from_u32(b as u32).unwrap()
120 } else {
121 let c = char::from_u32(256 + n).unwrap();
122 n += 1;
123 c
124 };
125 b2c[b as usize] = c;
126 c2b.insert(c, b as u8);
127 }
128 (b2c, c2b)
129}
130
131#[derive(Deserialize)]
133struct HfTokenizerJson {
134 model: HfModel,
135 #[serde(default)]
136 added_tokens: Vec<HfAddedToken>,
137 #[serde(default)]
138 pre_tokenizer: Option<serde_json::Value>,
139 #[serde(default)]
140 normalizer: Option<serde_json::Value>,
141 #[serde(default)]
142 post_processor: Option<serde_json::Value>,
143}
144
145#[derive(Deserialize)]
146struct HfModel {
147 vocab: HashMap<String, u32>,
148 #[serde(default)]
149 merges: Vec<HfMerge>,
150 #[serde(default)]
151 byte_fallback: bool,
152}
153
154#[derive(Deserialize)]
157#[serde(untagged)]
158enum HfMerge {
159 Pair([String; 2]),
160 Text(String),
161}
162
163#[derive(Deserialize)]
164struct HfAddedToken {
165 id: u32,
166 content: String,
167 special: bool,
168}
169
170fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
179 if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
180 if let Some(r) = pt
181 .get("pattern")
182 .and_then(|p| p.get("Regex"))
183 .and_then(|r| r.as_str())
184 {
185 out.push(r.to_string());
186 }
187 return;
188 }
189 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
190 for p in list {
191 collect_split_patterns(p, out);
192 }
193 }
194}
195
196fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
199 if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
200 return pt
201 .get("prepend_scheme")
202 .and_then(|p| p.as_str())
203 .map(String::from);
204 }
205 if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
206 return list.iter().find_map(find_prepend_scheme);
207 }
208 None
209}
210
211pub(crate) fn strip_generation_tags(tpl: &str) -> std::borrow::Cow<'_, str> {
227 if !tpl.contains("generation") {
228 return std::borrow::Cow::Borrowed(tpl);
229 }
230 let mut out = String::with_capacity(tpl.len());
231 let mut rest = tpl;
232 let mut touched = false;
233 while let Some(open) = rest.find("{%") {
234 let Some(close_rel) = rest[open..].find("%}") else {
235 break;
236 };
237 let close = open + close_rel + 2;
238 let tag = &rest[open..close];
239 let inner = tag[2..tag.len() - 2].trim();
240 let lead = inner.starts_with('-');
241 let trail = inner.ends_with('-');
242 let name = inner.trim_matches('-').trim();
243 out.push_str(&rest[..open]);
244 if name == "generation" || name == "endgeneration" {
245 out.push_str(if lead { "{%-" } else { "{%" });
246 out.push_str(" set _generation_span = true ");
247 out.push_str(if trail { "-%}" } else { "%}" });
248 touched = true;
249 } else {
250 out.push_str(tag);
251 }
252 rest = &rest[close..];
253 }
254 if !touched {
255 return std::borrow::Cow::Borrowed(tpl);
256 }
257 out.push_str(rest);
258 std::borrow::Cow::Owned(out)
259}
260
261fn close_direct_think(rendered: String, enable_thinking: Option<bool>) -> String {
270 if enable_thinking != Some(false) || rendered.contains("</think>") {
271 return rendered;
272 }
273 if let Some(pos) = rendered.rfind("<|assistant|>") {
274 let marker_end = pos + "<|assistant|>".len();
275 let after = &rendered[marker_end..];
276 let mut out = String::with_capacity(rendered.len() + 24);
277 out.push_str(&rendered[..marker_end]);
278 if let Some(rest) = after.strip_prefix("<think>") {
279 out.push_str("<think></think>\n\n");
280 out.push_str(rest);
281 } else {
282 out.push_str(after);
283 if !out.ends_with('\n') {
284 out.push('\n');
285 }
286 out.push_str("<think>\n\n</think>\n\n");
287 }
288 return out;
289 }
290 if let Some(pos) = rendered.rfind("\nassistant") {
294 let mut insert_at = pos + 1 + "assistant".len();
295 if let Some(idx) = rendered[insert_at..].find('\n') {
296 insert_at += idx + 1;
297 }
298 let mut out = String::with_capacity(rendered.len() + 24);
299 out.push_str(&rendered[..insert_at]);
300 if !out.ends_with('\n') {
301 out.push('\n');
302 }
303 out.push_str("<think>\n\n</think>\n\n");
304 out.push_str(&rendered[insert_at..]);
305 return out;
306 }
307 rendered
308}
309
310impl Tokenizer {
311 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
313 let data = std::fs::read_to_string(path.as_ref())
314 .map_err(|e| TokenizerError::Io(e.to_string()))?;
315 Self::from_json(&data)
316 }
317
318 pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
320 let s = std::str::from_utf8(bytes)
321 .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
322 Self::from_json(s)
323 }
324
325 pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
327 let hf: HfTokenizerJson =
328 serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
329
330 let mut vocab = hf.model.vocab;
331 let mut ranks = HashMap::new();
332 for (rank, m) in hf.model.merges.into_iter().enumerate() {
333 let (a, b) = match m {
334 HfMerge::Pair([a, b]) => (a, b),
335 HfMerge::Text(s) => {
336 let mut it = s.splitn(2, ' ');
337 match (it.next(), it.next()) {
338 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
339 _ => continue,
340 }
341 }
342 };
343 ranks.insert((a, b), rank as u32);
344 }
345
346 let mut saw_gemma_bos = false;
351 let add_bos_detected = hf
352 .post_processor
353 .as_ref()
354 .map(|p| {
355 let pp = p.to_string();
356 pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
357 })
358 .unwrap_or(false);
359 let nfc = hf
360 .normalizer
361 .as_ref()
362 .map(|n| n.to_string().contains("NFC"))
363 .unwrap_or(false);
364 let metaspace = hf.model.byte_fallback
365 || hf
366 .normalizer
367 .as_ref()
368 .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
369 .unwrap_or(false);
370 let sp_prepend = hf
371 .normalizer
372 .as_ref()
373 .map(|n| n.to_string().contains("Prepend"))
374 .unwrap_or(false);
375 let (sp_prepend, sp_prepend_first) = if sp_prepend {
379 (true, false)
380 } else {
381 match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
382 Some(s) if s == "always" => (true, false),
383 Some(s) if s == "first" => (false, true),
384 _ => (false, false),
385 }
386 };
387 let split_res = if metaspace {
388 Vec::new()
389 } else {
390 let mut pats = Vec::new();
391 if let Some(pt) = hf.pre_tokenizer.as_ref() {
392 collect_split_patterns(pt, &mut pats);
393 }
394 if pats.is_empty() {
395 pats.push(DEFAULT_SPLIT.to_string());
396 }
397 pats.iter()
398 .map(|p| {
399 fancy_regex::Regex::new(p)
400 .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
401 })
402 .collect::<Result<Vec<_>, _>>()?
403 };
404
405 let mut bos_token_id = None;
407 let mut eos_token_id = None;
408 let mut pad_token_id = None;
409 let mut im_start_id = None;
410 let mut im_end_id = None;
411 let mut special_ids = HashSet::new();
412 let mut added_ids = HashSet::new();
413 let mut added = Vec::new();
414
415 for at in &hf.added_tokens {
416 vocab.insert(at.content.clone(), at.id);
417 added.push((at.content.clone(), at.id));
418 added_ids.insert(at.id);
419 if at.special && !TOOL_MARKUP_TOKENS.contains(&at.content.as_str()) {
420 special_ids.insert(at.id);
421 }
422 match at.content.as_str() {
423 "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
424 "<|im_start|>" => im_start_id = Some(at.id),
425 "<|im_end|>" => im_end_id = Some(at.id),
426 "<s>" | "[BOS]" => bos_token_id = Some(at.id),
427 "<bos>" => {
431 bos_token_id = Some(at.id);
432 saw_gemma_bos = true;
433 }
434 "<pad>" => pad_token_id = Some(at.id),
435 _ => {}
436 }
437 }
438
439 const DSV41_SPECIALS: &[&str] = &[
444 "<|begin▁of▁sentence|>",
445 "<|end▁of▁sentence|>",
446 "<|User|>",
447 "<|Assistant|>",
448 "<|System|>",
449 "<|latest_reminder|>",
450 "<|deepseek_image|>",
451 "<|action|>",
452 "<|query|>",
453 "<|authority|>",
454 "<|domain|>",
455 "<|title|>",
456 "<|read_url|>",
457 "<think>",
458 "</think>",
459 "|DSML|",
460 ];
461 for token in DSV41_SPECIALS {
462 if let Some(&id) = vocab.get(*token) {
463 if !added.iter().any(|(content, _)| content.as_str() == *token) {
464 added.push(((*token).to_string(), id));
465 }
466 added_ids.insert(id);
467 match *token {
468 "<|begin▁of▁sentence|>" => bos_token_id = Some(id),
469 "<|end▁of▁sentence|>" => eos_token_id = Some(id),
470 _ => {}
471 }
472 }
473 }
474 added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
475
476 let gemma_family = saw_gemma_bos
483 || vocab.contains_key("<start_of_turn>")
484 || added.iter().any(|(c, _)| c == "<start_of_turn>");
485
486 if let Some(pp) = hf.post_processor.as_ref() {
490 let pp = pp.to_string();
491 for name in ["<bos>", "<s>"] {
492 if pp.contains(&format!("\"{name}\"")) {
493 if let Some(&id) = vocab.get(name) {
494 bos_token_id = Some(id);
495 }
496 break;
497 }
498 }
499 }
500
501 let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
503 let mut id_to_token = vec![String::new(); max_id + 1];
504 for (token, &id) in &vocab {
505 if (id as usize) < id_to_token.len() {
506 id_to_token[id as usize] = token.clone();
507 }
508 }
509
510 let (byte_to_char, char_to_byte) = bytes_to_unicode();
511
512 tracing::info!(
513 "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
514 vocab.len(),
515 ranks.len(),
516 added.len(),
517 eos_token_id
518 );
519
520 Ok(Self {
521 vocab,
522 id_to_token,
523 ranks,
524 sp_ids: OnceLock::new(),
525 added,
526 added_ids,
527 special_ids,
528 split_res,
529 metaspace,
530 sp_prepend,
531 sp_prepend_first,
532 nfc,
533 byte_to_char,
534 char_to_byte,
535 bos_token_id,
536 eos_token_id,
537 pad_token_id,
538 im_start_id,
539 im_end_id,
540 chat_template: None,
541 extra_eos: HashSet::new(),
542 add_bos: add_bos_detected || gemma_family,
543 })
544 }
545
546 pub fn byte_level() -> Self {
548 let mut vocab = HashMap::new();
549 let mut id_to_token = Vec::with_capacity(256);
550 for i in 0..256u32 {
551 let tok = format!("<0x{:02X}>", i);
552 vocab.insert(tok.clone(), i);
553 id_to_token.push(tok);
554 }
555 let (byte_to_char, char_to_byte) = bytes_to_unicode();
556 Self {
557 vocab,
558 id_to_token,
559 ranks: HashMap::new(),
560 sp_ids: OnceLock::new(),
561 added: Vec::new(),
562 added_ids: HashSet::new(),
563 special_ids: HashSet::new(),
564 split_res: Vec::new(),
565 metaspace: false,
566 sp_prepend: false,
567 sp_prepend_first: false,
568 nfc: false,
569 byte_to_char,
570 char_to_byte,
571 bos_token_id: None,
572 eos_token_id: None,
573 pad_token_id: None,
574 im_start_id: None,
575 im_end_id: None,
576 chat_template: None,
577 extra_eos: HashSet::new(),
578 add_bos: false,
579 }
580 }
581
582 pub fn encode(&self, text: &str) -> Vec<u32> {
584 let mut ids = Vec::new();
585 let mut rest = text;
587 let mut head = true;
592 'outer: while !rest.is_empty() {
593 let mut best: Option<(usize, usize, u32)> = None; for (content, id) in &self.added {
595 if let Some(pos) = rest.find(content.as_str()) {
596 let better = match best {
597 None => true,
598 Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
599 };
600 if better {
601 best = Some((pos, content.len(), *id));
602 }
603 if pos == 0 {
604 break; }
606 }
607 }
608 match best {
609 Some((pos, len, id)) => {
610 self.encode_segment_at(&rest[..pos], head, &mut ids);
611 ids.push(id);
612 rest = &rest[pos + len..];
613 head = false;
614 }
615 None => {
616 self.encode_segment_at(rest, head, &mut ids);
617 break 'outer;
618 }
619 }
620 }
621 ids
622 }
623
624 pub fn encode_plain(&self, text: &str) -> Vec<u32> {
630 let mut ids = Vec::new();
631 self.encode_segment_at(text, true, &mut ids);
632 ids
633 }
634
635 fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
639 if segment.is_empty() {
640 return;
641 }
642 let norm: String = if self.nfc {
643 segment.nfc().collect()
644 } else {
645 segment.to_string()
646 };
647 if self.metaspace {
648 let sp = if self.sp_prepend {
652 format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
655 } else {
656 let replaced = norm.replace(' ', "\u{2581}");
660 if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
661 format!("\u{2581}{replaced}")
662 } else {
663 replaced
664 }
665 };
666 self.bpe_piece_sp(&sp, out);
667 return;
668 }
669 if !self.split_res.is_empty() {
670 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
673 for re in &self.split_res {
674 let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
675 for (ps, pe) in pieces {
676 let seg = &norm[ps..pe];
677 let mut last = 0usize;
678 for m in re.find_iter(seg) {
679 let m = match m {
680 Ok(m) => m,
681 Err(e) => {
682 tracing::error!("pre-tokenizer regex failed: {e}");
683 break;
684 }
685 };
686 if m.start() > last {
687 next.push((ps + last, ps + m.start()));
688 }
689 if m.end() > m.start() {
690 next.push((ps + m.start(), ps + m.end()));
691 }
692 last = m.end();
693 }
694 if last < seg.len() {
695 next.push((ps + last, pe));
696 }
697 }
698 pieces = next;
699 }
700 for (ps, pe) in pieces {
701 self.bpe_piece(&norm[ps..pe], out);
702 }
703 } else {
704 {
705 for b in norm.bytes() {
707 let tok = format!("<0x{:02X}>", b);
708 if let Some(&id) = self.vocab.get(&tok) {
709 out.push(id);
710 }
711 }
712 }
713 }
714 }
715
716 fn sp_merges(&self) -> Option<&SpMerges> {
718 self.sp_ids
719 .get_or_init(|| SpMerges::build(&self.vocab, &self.ranks))
720 .as_ref()
721 }
722
723 fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
732 if piece.is_empty() {
733 return;
734 }
735 if !SP_STRINGS.with(|f| f.get()) {
736 if let Some(m) = self.sp_merges() {
737 m.encode(&self.vocab, piece, out);
738 return;
739 }
740 }
741 self.bpe_piece_sp_strings(piece, out);
742 }
743
744 #[doc(hidden)]
747 pub fn encode_reference(&self, text: &str) -> Vec<u32> {
748 SP_STRINGS.with(|f| f.set(true));
749 let ids = self.encode(text);
750 SP_STRINGS.with(|f| f.set(false));
751 ids
752 }
753
754 fn bpe_piece_sp_strings(&self, piece: &str, out: &mut Vec<u32>) {
755 let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
756 loop {
757 let mut best: Option<(u32, usize)> = None;
758 for i in 0..sym.len().saturating_sub(1) {
759 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
760 if best.map(|(br, _)| r < br).unwrap_or(true) {
761 best = Some((r, i));
762 }
763 }
764 }
765 let Some((_, i)) = best else { break };
766 let merged = format!("{}{}", sym[i], sym[i + 1]);
767 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
768 let mut j = 0;
769 while j + 1 < sym.len() {
770 if sym[j] == left && sym[j + 1] == right {
771 sym[j] = merged.clone();
772 sym.remove(j + 1);
773 }
774 j += 1;
775 }
776 }
777 for t in &sym {
778 if let Some(&id) = self.vocab.get(t) {
779 out.push(id);
780 } else {
781 let mut ok = true;
782 for byte in t.bytes() {
783 let tok = format!("<0x{:02X}>", byte);
784 match self.vocab.get(&tok) {
785 Some(&id) => out.push(id),
786 None => {
787 ok = false;
788 break;
789 }
790 }
791 }
792 if !ok {
793 tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
794 }
795 }
796 }
797 }
798
799 fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
801 if piece.is_empty() {
802 return;
803 }
804 let mapped: Vec<String> = piece
805 .bytes()
806 .map(|b| self.byte_to_char[b as usize].to_string())
807 .collect();
808 let mut sym = mapped;
809
810 loop {
812 let mut best: Option<(u32, usize)> = None;
813 for i in 0..sym.len().saturating_sub(1) {
814 if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
815 if best.map(|(br, _)| r < br).unwrap_or(true) {
816 best = Some((r, i));
817 }
818 }
819 }
820 let Some((_, i)) = best else { break };
821 let merged = format!("{}{}", sym[i], sym[i + 1]);
822 let (left, right) = (sym[i].clone(), sym[i + 1].clone());
824 let mut j = 0;
825 while j + 1 < sym.len() {
826 if sym[j] == left && sym[j + 1] == right {
827 sym[j] = merged.clone();
828 sym.remove(j + 1);
829 }
830 j += 1;
831 }
832 }
833
834 for s in &sym {
835 if let Some(&id) = self.vocab.get(s) {
836 out.push(id);
837 } else {
838 let mut ok = true;
840 for ch in s.chars() {
841 let Some(&b) = self.char_to_byte.get(&ch) else {
842 ok = false;
843 break;
844 };
845 let tok = format!("<0x{:02X}>", b);
846 if let Some(&id) = self.vocab.get(&tok) {
847 out.push(id);
848 } else {
849 ok = false;
850 break;
851 }
852 }
853 if !ok {
854 tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
855 }
856 }
857 }
858 }
859
860 pub fn decode(&self, ids: &[u32]) -> String {
863 let mut bytes: Vec<u8> = Vec::new();
864 for &id in ids {
865 if self.special_ids.contains(&id) {
866 continue;
867 }
868 let idx = id as usize;
869 if idx >= self.id_to_token.len() {
870 continue;
871 }
872 let tok = &self.id_to_token[idx];
873 if self.added_ids.contains(&id) {
874 if self.metaspace && tok.contains('\u{2581}') {
877 bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
878 } else {
879 bytes.extend_from_slice(tok.as_bytes());
880 }
881 continue;
882 }
883 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
885 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
886 bytes.push(b);
887 continue;
888 }
889 }
890 if self.metaspace {
891 for ch in tok.chars() {
893 if ch == '\u{2581}' {
894 bytes.push(b' ');
895 } else {
896 let mut buf = [0u8; 4];
897 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
898 }
899 }
900 continue;
901 }
902 for ch in tok.chars() {
903 match self.char_to_byte.get(&ch) {
904 Some(&b) => bytes.push(b),
905 None => {
908 let mut buf = [0u8; 4];
909 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
910 }
911 }
912 }
913 }
914 let text = String::from_utf8_lossy(&bytes).into_owned();
915 if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
916 if let Some(stripped) = text.strip_prefix(' ') {
918 return stripped.to_string();
919 }
920 }
921 text
922 }
923
924 pub fn decode_token(&self, id: u32) -> String {
927 if self.special_ids.contains(&id) {
928 return String::new();
929 }
930 let idx = id as usize;
931 if idx >= self.id_to_token.len() {
932 return String::new();
933 }
934 let tok = &self.id_to_token[idx];
935 if self.added_ids.contains(&id) {
936 if self.metaspace && tok.contains('\u{2581}') {
937 return tok.replace('\u{2581}', " ");
938 }
939 return tok.clone();
940 }
941 if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
942 if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
943 return String::from_utf8_lossy(&[b]).into_owned();
944 }
945 }
946 if self.metaspace {
947 return tok.replace('\u{2581}', " ");
948 }
949 let mut bytes = Vec::new();
950 for ch in tok.chars() {
951 match self.char_to_byte.get(&ch) {
952 Some(&b) => bytes.push(b),
953 None => {
954 let mut buf = [0u8; 4];
955 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
956 }
957 }
958 }
959 String::from_utf8_lossy(&bytes).into_owned()
960 }
961
962 pub fn decode_token_for_hash(&self, id: u32) -> String {
965 let idx = id as usize;
966 if idx >= self.id_to_token.len() {
967 return String::new();
968 }
969 if self.special_ids.contains(&id) {
970 return self.id_to_token[idx].clone();
971 }
972 self.decode_token(id)
973 }
974
975 pub fn decode_for_protocol(&self, ids: &[u32]) -> String {
978 let mut out = String::new();
979 for &id in ids {
980 let idx = id as usize;
981 if self.special_ids.contains(&id) {
982 if let Some(token) = self.id_to_token.get(idx) {
983 out.push_str(token);
984 }
985 } else {
986 out.push_str(&self.decode_token(id));
987 }
988 }
989 out
990 }
991
992 pub fn raw_token_for_hash(&self, id: u32) -> String {
994 self.id_to_token
995 .get(id as usize)
996 .cloned()
997 .unwrap_or_default()
998 }
999
1000 pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
1004 self.apply_chat_template_opts(messages, None)
1005 }
1006
1007 pub fn apply_chat_template_json(
1024 &self,
1025 messages: &[serde_json::Value],
1026 tools: Option<&[serde_json::Value]>,
1027 enable_thinking: Option<bool>,
1028 ) -> Vec<u32> {
1029 match self.try_apply_chat_template_json(messages, tools, enable_thinking) {
1030 Ok(ids) => ids,
1031 Err(e) => {
1032 tracing::error!("chat template render failed ({e}); ChatML fallback");
1033 self.chatml_json_fallback(messages, enable_thinking)
1034 }
1035 }
1036 }
1037
1038 pub fn try_apply_chat_template_json(
1049 &self,
1050 messages: &[serde_json::Value],
1051 tools: Option<&[serde_json::Value]>,
1052 enable_thinking: Option<bool>,
1053 ) -> Result<Vec<u32>, String> {
1054 if let Some(tpl) = &self.chat_template {
1055 return self
1056 .render_template_json(tpl, messages, tools, enable_thinking)
1057 .map(|text| self.with_bos(self.encode(&text)))
1058 .map_err(|e| format!("{e:#}"));
1059 }
1060 Ok(self.chatml_json_fallback(messages, enable_thinking))
1061 }
1062
1063 fn chatml_json_fallback(
1064 &self,
1065 messages: &[serde_json::Value],
1066 enable_thinking: Option<bool>,
1067 ) -> Vec<u32> {
1068 let pairs: Vec<(String, String)> = messages
1069 .iter()
1070 .map(|m| {
1071 (
1072 m.get("role")
1073 .and_then(|v| v.as_str())
1074 .unwrap_or("user")
1075 .to_string(),
1076 m.get("content")
1077 .and_then(|v| v.as_str())
1078 .unwrap_or("")
1079 .to_string(),
1080 )
1081 })
1082 .collect();
1083 self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
1084 }
1085
1086 pub fn render_chat_json(
1088 &self,
1089 messages: &[serde_json::Value],
1090 tools: Option<&[serde_json::Value]>,
1091 enable_thinking: Option<bool>,
1092 ) -> Option<String> {
1093 let tpl = self.chat_template.as_ref()?;
1094 match self.render_template_json(tpl, messages, tools, enable_thinking) {
1095 Ok(t) => Some(t),
1096 Err(e) => {
1097 tracing::error!("chat template render (json): {e:#}");
1098 eprintln!("chat template render (json): {e:#}");
1099 None
1100 }
1101 }
1102 }
1103
1104 fn render_template_json(
1105 &self,
1106 tpl: &str,
1107 messages: &[serde_json::Value],
1108 tools: Option<&[serde_json::Value]>,
1109 enable_thinking: Option<bool>,
1110 ) -> Result<String, minijinja::Error> {
1111 let mut env = crate::chat_template::environment();
1112 let tpl_src = strip_generation_tags(tpl);
1113 env.add_template("chat", &tpl_src)?;
1114 let msgs: Vec<minijinja::Value> = messages
1115 .iter()
1116 .map(minijinja::Value::from_serialize)
1117 .collect();
1118 let tools_v: Option<Vec<minijinja::Value>> =
1119 tools.map(|ts| ts.iter().map(minijinja::Value::from_serialize).collect());
1120 let tpl = env.get_template("chat")?;
1121 let rendered = match (tools_v, enable_thinking) {
1128 (Some(ts), Some(v)) => tpl.render(minijinja::context! {
1129 messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
1130 reasoning_effort => if !v { Some("low") } else { None::<&str> },
1135 tool_call_format => "json",
1136 })?,
1137 (Some(ts), None) => tpl.render(minijinja::context! {
1138 messages => msgs, tools => ts, add_generation_prompt => true,
1139 tool_call_format => "json",
1140 })?,
1141 (None, Some(v)) => tpl.render(minijinja::context! {
1142 messages => msgs, add_generation_prompt => true, enable_thinking => v,
1143 reasoning_effort => if !v { Some("low") } else { None::<&str> },
1145 tool_call_format => "json",
1146 })?,
1147 (None, None) => tpl.render(minijinja::context! {
1148 messages => msgs, add_generation_prompt => true,
1149 tool_call_format => "json",
1150 })?,
1151 };
1152 Ok(close_direct_think(rendered, enable_thinking))
1153 }
1154
1155 pub fn apply_chat_template_opts(
1156 &self,
1157 messages: &[(String, String)],
1158 enable_thinking: Option<bool>,
1159 ) -> Vec<u32> {
1160 if let Some(tpl) = &self.chat_template {
1161 match self.render_template(tpl, messages, enable_thinking) {
1162 Ok(text) => return self.with_bos(self.encode(&text)),
1163 Err(e) => {
1164 tracing::error!("chat template render failed ({e}); ChatML fallback");
1165 }
1166 }
1167 }
1168 self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
1169 }
1170
1171 pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
1173 if self.add_bos {
1174 if let Some(b) = self.bos_token_id {
1175 if ids.first() != Some(&b) {
1176 ids.insert(0, b);
1177 }
1178 }
1179 }
1180 ids
1181 }
1182
1183 pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
1185 self.render_chat_opts(messages, None)
1186 }
1187
1188 pub fn render_chat_opts(
1190 &self,
1191 messages: &[(String, String)],
1192 enable_thinking: Option<bool>,
1193 ) -> Option<String> {
1194 let tpl = self.chat_template.as_ref()?;
1195 match self.render_template(tpl, messages, enable_thinking) {
1196 Ok(t) => Some(t),
1197 Err(e) => {
1198 tracing::error!("chat template render: {e:#}");
1199 None
1200 }
1201 }
1202 }
1203
1204 fn render_template(
1205 &self,
1206 tpl: &str,
1207 messages: &[(String, String)],
1208 enable_thinking: Option<bool>,
1209 ) -> Result<String, minijinja::Error> {
1210 let mut env = crate::chat_template::environment();
1211 let tpl_src = strip_generation_tags(tpl);
1212 env.add_template("chat", &tpl_src)?;
1213 let msgs: Vec<minijinja::Value> = messages
1214 .iter()
1215 .map(|(role, content)| {
1216 minijinja::context! { role => role, content => content }
1217 })
1218 .collect();
1219 let rendered = match enable_thinking {
1222 Some(v) => env.get_template("chat")?.render(minijinja::context! {
1223 messages => msgs,
1224 add_generation_prompt => true,
1225 enable_thinking => v,
1226 reasoning_effort => if !v { Some("low") } else { None::<&str> },
1229 })?,
1230 None => env.get_template("chat")?.render(minijinja::context! {
1231 messages => msgs,
1232 add_generation_prompt => true,
1233 })?,
1234 };
1235 Ok(close_direct_think(rendered, enable_thinking))
1236 }
1237
1238 fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
1240 self.chatml_fallback_opts(messages, None)
1241 }
1242
1243 fn chatml_fallback_opts(
1245 &self,
1246 messages: &[(String, String)],
1247 enable_thinking: Option<bool>,
1248 ) -> Vec<u32> {
1249 let mut tokens = Vec::new();
1250
1251 for (role, content) in messages {
1252 if let Some(start_id) = self.im_start_id {
1254 tokens.push(start_id);
1255 }
1256 tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1257 if let Some(end_id) = self.im_end_id {
1258 tokens.push(end_id);
1259 }
1260 tokens.extend(self.encode("\n"));
1261 }
1262
1263 if let Some(start_id) = self.im_start_id {
1265 tokens.push(start_id);
1266 }
1267 tokens.extend(self.encode("assistant\n"));
1268 if enable_thinking == Some(false) {
1269 tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1270 }
1271
1272 tokens
1273 }
1274
1275 pub fn vocab_size(&self) -> usize {
1277 self.id_to_token.len()
1278 }
1279
1280 pub fn token_to_id(&self, token: &str) -> Option<u32> {
1284 self.vocab.get(token).copied()
1285 }
1286
1287 pub fn convert_tokens_to_ids(&self, token: &str) -> Option<u32> {
1290 self.token_to_id(token)
1291 }
1292
1293 pub fn is_eos(&self, id: u32) -> bool {
1295 self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1296 }
1297}
1298
1299#[derive(Debug, thiserror::Error)]
1300pub enum TokenizerError {
1301 #[error("IO error: {0}")]
1302 Io(String),
1303 #[error("Parse error: {0}")]
1304 Parse(String),
1305}
1306
1307struct SpMerges {
1309 pairs: HashMap<u64, (u32, u32)>,
1310 bytes: [u32; 256],
1312}
1313
1314const SP_NONE: u32 = u32::MAX;
1315
1316thread_local! {
1317 static SP_STRINGS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1319}
1320
1321impl SpMerges {
1322 fn build(vocab: &HashMap<String, u32>, ranks: &HashMap<(String, String), u32>) -> Option<Self> {
1323 if ranks.is_empty() {
1324 return None;
1325 }
1326 let mut pairs = HashMap::with_capacity(ranks.len());
1327 let mut cat = String::new();
1328 for ((a, b), &r) in ranks {
1329 let (&ia, &ib) = (vocab.get(a)?, vocab.get(b)?);
1330 cat.clear();
1331 cat.push_str(a);
1332 cat.push_str(b);
1333 let &m = vocab.get(&cat)?;
1334 pairs.insert(((ia as u64) << 32) | ib as u64, (r, m));
1335 }
1336 let mut bytes = [SP_NONE; 256];
1337 for (b, slot) in bytes.iter_mut().enumerate() {
1338 if let Some(&id) = vocab.get(&format!("<0x{b:02X}>")) {
1339 *slot = id;
1340 }
1341 }
1342 Some(SpMerges { pairs, bytes })
1343 }
1344
1345 #[inline]
1346 fn rank(&self, a: u32, b: u32) -> Option<(u32, u32)> {
1347 if a == SP_NONE || b == SP_NONE {
1348 return None;
1349 }
1350 self.pairs.get(&(((a as u64) << 32) | b as u64)).copied()
1351 }
1352
1353 fn encode(&self, vocab: &HashMap<String, u32>, piece: &str, out: &mut Vec<u32>) {
1358 use std::cmp::Reverse;
1359 use std::collections::BinaryHeap;
1360 let spans: Vec<(usize, usize)> = piece
1361 .char_indices()
1362 .map(|(i, c)| (i, i + c.len_utf8()))
1363 .collect();
1364 let n = spans.len();
1365 let mut buf = [0u8; 4];
1366 let mut id: Vec<u32> = piece
1367 .chars()
1368 .map(|c| *vocab.get(c.encode_utf8(&mut buf) as &str).unwrap_or(&SP_NONE))
1369 .collect();
1370 let mut next: Vec<usize> = (1..=n).collect();
1371 let mut prev: Vec<usize> = (0..n).map(|i| i.wrapping_sub(1)).collect();
1372 let mut alive = vec![true; n];
1373 let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::with_capacity(n);
1374 for i in 0..n.saturating_sub(1) {
1375 if let Some((r, _)) = self.rank(id[i], id[i + 1]) {
1376 heap.push(Reverse((r, i)));
1377 }
1378 }
1379 let mut batch: Vec<usize> = Vec::new();
1380 let mut merged: Vec<usize> = Vec::new();
1381 while let Some(&Reverse((r, _))) = heap.peek() {
1382 batch.clear();
1383 while let Some(&Reverse((r2, pos))) = heap.peek() {
1384 if r2 != r {
1385 break;
1386 }
1387 heap.pop();
1388 batch.push(pos);
1389 }
1390 batch.sort_unstable();
1391 batch.dedup();
1392 merged.clear();
1393 for &pos in &batch {
1394 if !alive[pos] || next[pos] >= n {
1395 continue;
1396 }
1397 let j = next[pos];
1398 match self.rank(id[pos], id[j]) {
1399 Some((rr, m)) if rr == r => {
1400 id[pos] = m;
1401 alive[j] = false;
1402 next[pos] = next[j];
1403 if next[j] < n {
1404 prev[next[j]] = pos;
1405 }
1406 merged.push(pos);
1407 }
1408 _ => {}
1409 }
1410 }
1411 for &pos in &merged {
1412 if !alive[pos] {
1413 continue;
1414 }
1415 let pv = prev[pos];
1416 if pv < n && alive[pv] {
1417 if let Some((r2, _)) = self.rank(id[pv], id[pos]) {
1418 heap.push(Reverse((r2, pv)));
1419 }
1420 }
1421 let nx = next[pos];
1422 if nx < n {
1423 if let Some((r2, _)) = self.rank(id[pos], id[nx]) {
1424 heap.push(Reverse((r2, pos)));
1425 }
1426 }
1427 }
1428 }
1429 let mut i = 0usize;
1430 while i < n {
1431 if id[i] != SP_NONE {
1432 out.push(id[i]);
1433 } else {
1434 let (a, b) = spans[i];
1436 let mut ok = true;
1437 for &byte in piece[a..b].as_bytes() {
1438 match self.bytes[byte as usize] {
1439 SP_NONE => {
1440 ok = false;
1441 break;
1442 }
1443 t => out.push(t),
1444 }
1445 }
1446 if !ok {
1447 tracing::error!("tokenizer: no id for SP symbol {:?} — dropped", &piece[a..b]);
1448 }
1449 }
1450 i = next[i];
1451 }
1452 }
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 #[test]
1460 fn byte_unicode_bijection() {
1461 let (b2c, c2b) = bytes_to_unicode();
1462 for b in 0..=255u8 {
1463 assert_eq!(c2b[&b2c[b as usize]], b);
1464 }
1465 assert_eq!(b2c[b' ' as usize], 'Ġ');
1467 assert_eq!(b2c[b'\n' as usize], 'Ċ');
1468 }
1469
1470 #[test]
1471 fn byte_level_roundtrip_utf8() {
1472 let tok = Tokenizer::byte_level();
1473 let text = "hello 🌍 hi\n";
1474 let ids = tok.encode(text);
1475 assert_eq!(ids.len(), text.len()); assert_eq!(tok.decode(&ids), text);
1477 }
1478
1479 fn mini_json() -> String {
1482 let vocab: Vec<(&str, u32)> = vec![
1484 ("h", 0),
1485 ("e", 1),
1486 ("l", 2),
1487 ("o", 3),
1488 ("Ġ", 4),
1489 ("w", 5),
1490 ("r", 6),
1491 ("d", 7),
1492 ("he", 8),
1493 ("Ġw", 9),
1494 ];
1495 let vocab_json: String = vocab
1496 .iter()
1497 .map(|(t, i)| format!("\"{t}\": {i}"))
1498 .collect::<Vec<_>>()
1499 .join(", ");
1500 format!(
1501 r#"{{
1502 "model": {{
1503 "type": "BPE",
1504 "vocab": {{ {vocab_json} }},
1505 "merges": [["h", "e"], ["Ġ", "w"]]
1506 }},
1507 "added_tokens": [
1508 {{"id": 10, "content": "<|eot|>", "special": true}}
1509 ]
1510 }}"#
1511 )
1512 }
1513
1514 #[test]
1520 fn sp_heap_bpe_matches_the_string_form() {
1521 let toks = [
1522 "a", "b", "c", "▁", "bc", "abc", "ab", "aa", "aab", "ca", "▁a", "aabc",
1523 "aaa", "<0x64>", "<0xC3>", "▁▁",
1524 ];
1525 let vocab: Vec<String> = toks
1526 .iter()
1527 .enumerate()
1528 .map(|(i, t)| format!("\"{t}\": {i}"))
1529 .collect();
1530 let merges = [
1531 ("aab", "c"),
1532 ("b", "c"),
1533 ("a", "bc"),
1534 ("a", "b"),
1535 ("ab", "c"),
1536 ("a", "a"),
1537 ("aa", "b"),
1538 ("c", "a"),
1539 ("▁", "a"),
1540 ("aa", "a"),
1541 ("▁", "▁"),
1542 ];
1543 let merges: Vec<String> = merges
1544 .iter()
1545 .map(|(a, b)| format!("[\"{a}\", \"{b}\"]"))
1546 .collect();
1547 let json = format!(
1548 r#"{{"model": {{"type": "BPE", "byte_fallback": true, "vocab": {{ {} }}, "merges": [{}]}},
1549 "normalizer": {{"type": "Replace", "pattern": {{"String": " "}}, "content": "\u2581"}},
1550 "added_tokens": []}}"#,
1551 vocab.join(", "),
1552 merges.join(", ")
1553 );
1554 let t = Tokenizer::from_json(&json).unwrap();
1555 assert!(t.metaspace, "a byte_fallback vocabulary takes the SentencePiece path");
1556 assert!(t.sp_merges().is_some());
1557 let alphabet = ['a', 'b', 'c', '\u{2581}', 'd', 'é', 'a', 'a', 'b'];
1558 let mut state = 0x2545_f491_4f6c_dd1du64;
1559 for len in 0..40 {
1560 for _ in 0..60 {
1561 let piece: String = (0..len)
1562 .map(|_| {
1563 state ^= state << 13;
1564 state ^= state >> 7;
1565 state ^= state << 17;
1566 alphabet[(state % alphabet.len() as u64) as usize]
1567 })
1568 .collect();
1569 let (mut fast, mut slow) = (Vec::new(), Vec::new());
1570 t.sp_merges().unwrap().encode(&t.vocab, &piece, &mut fast);
1571 t.bpe_piece_sp_strings(&piece, &mut slow);
1572 assert_eq!(fast, slow, "{piece:?}");
1573 }
1574 }
1575 }
1576
1577 #[test]
1581 fn tool_markup_decodes_even_when_special() {
1582 let json = r#"{
1583 "model": {"type": "BPE", "vocab": {"h": 0, "e": 1, "l": 2, "o": 3}, "merges": []},
1584 "added_tokens": [
1585 {"id": 10, "content": "<|im_end|>", "special": true},
1586 {"id": 11, "content": "<function", "special": true},
1587 {"id": 12, "content": "</function>", "special": true},
1588 {"id": 13, "content": "<param", "special": true},
1589 {"id": 14, "content": "</param>", "special": true},
1590 {"id": 15, "content": "<tool_call>", "special": true}
1591 ]
1592 }"#;
1593 let t = Tokenizer::from_json(json).unwrap();
1594 let ids = [11, 0, 1, 13, 2, 14, 12, 15, 10];
1595 assert_eq!(
1596 t.decode(&ids),
1597 "<functionhe<paraml</param></function><tool_call>"
1598 );
1599 let streamed: String = ids.iter().map(|&i| t.decode_token(i)).collect();
1600 assert_eq!(streamed, t.decode(&ids), "streaming must agree with decode");
1601 assert!(
1602 !t.decode(&[10]).contains("im_end"),
1603 "control tokens stay hidden"
1604 );
1605 }
1606
1607 #[test]
1610 fn real_tokenizer_parity_when_available() {
1611 let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1612 return;
1613 };
1614 let t = Tokenizer::from_file(&path).expect("load");
1615 for (text, want) in [
1616 (
1617 "The capital of France is",
1618 vec![671u32, 6102, 294, 8760, 344],
1619 ),
1620 ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1621 ] {
1622 let got = t.encode(text);
1623 assert_eq!(got, want, "«{text}»");
1624 }
1625 }
1626
1627 #[test]
1632 fn granite_42_chat_template_when_available() {
1633 let Ok(path) = std::env::var("CMF_GRANITE_CHAT_TEMPLATE") else {
1634 return;
1635 };
1636 let mut tok = Tokenizer::byte_level();
1637 tok.chat_template = Some(std::fs::read_to_string(path).expect("read Granite template"));
1638 let messages = vec![("user".to_string(), "Hello".to_string())];
1639
1640 let thinking = tok
1641 .render_chat_opts(&messages, Some(true))
1642 .expect("render Granite thinking prompt");
1643 assert_eq!(
1644 thinking,
1645 "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1646 );
1647
1648 let direct = tok
1649 .render_chat_opts(&messages, Some(false))
1650 .expect("render Granite direct prompt");
1651 assert_eq!(
1652 direct,
1653 "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think></think>"
1654 );
1655 }
1656
1657 #[test]
1661 fn glm_direct_template_keeps_assistant_special_token_intact() {
1662 let mut tok = Tokenizer::byte_level();
1663 tok.chat_template = Some(
1664 "[gMASK]<sop>{%- set effort = reasoning_effort if reasoning_effort is defined and reasoning_effort in ['low', 'high'] else 'max' -%}<|system|>Reasoning Effort: {{ effort | capitalize }}{%- for m in messages -%}<|user|>{{ m.content }}{%- endfor -%}<|assistant|><think>"
1665 .to_string(),
1666 );
1667 let messages = vec![("user".to_string(), "2+2?".to_string())];
1668 let rendered = tok
1669 .render_chat_opts(&messages, Some(false))
1670 .expect("render GLM direct prompt");
1671 assert!(rendered.contains("Reasoning Effort: Low"));
1672 assert!(rendered.contains("<|assistant|><think></think>"));
1673 assert!(!rendered.contains("<|assistant\n"));
1674
1675 let thinking = tok
1678 .render_chat_opts(&messages, Some(true))
1679 .expect("render GLM thinking prompt");
1680 assert!(thinking.contains("Reasoning Effort: Max"));
1681
1682 let json_messages = vec![serde_json::json!({
1683 "role": "user",
1684 "content": "2+2?"
1685 })];
1686 let rendered_json = tok
1687 .render_chat_json(&json_messages, None, Some(false))
1688 .expect("render GLM JSON direct prompt");
1689 assert!(rendered_json.contains("Reasoning Effort: Low"));
1690 assert!(rendered_json.contains("<|assistant|><think></think>"));
1691 assert!(!rendered_json.contains("<|assistant\n"));
1692 }
1693
1694 #[test]
1700 fn every_split_in_a_sequence_is_applied() {
1701 let pt = serde_json::json!({
1702 "type": "Sequence",
1703 "pretokenizers": [
1704 {"type": "Split", "behavior": "Isolated",
1705 "pattern": {"Regex": r"\p{N}{1,3}"}},
1706 {"type": "Split", "behavior": "Isolated",
1707 "pattern": {"Regex": r" ?[\p{L}]+"}},
1708 {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1709 ]
1710 });
1711 let mut pats = Vec::new();
1712 collect_split_patterns(&pt, &mut pats);
1713 assert_eq!(
1714 pats.len(),
1715 2,
1716 "both Split stages must be collected: {pats:?}"
1717 );
1718 assert!(pats[0].contains("p{N}"), "digit rule first");
1719 assert!(pats[1].contains("p{L}"), "word rule second");
1720
1721 let re: Vec<fancy_regex::Regex> = pats
1725 .iter()
1726 .map(|p| fancy_regex::Regex::new(p).unwrap())
1727 .collect();
1728 let norm = "ab cd12";
1729 let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1730 for r in &re {
1731 let mut next = Vec::new();
1732 for (ps, pe) in pieces {
1733 let seg = &norm[ps..pe];
1734 let mut last = 0;
1735 for m in r.find_iter(seg).flatten() {
1736 if m.start() > last {
1737 next.push((ps + last, ps + m.start()));
1738 }
1739 if m.end() > m.start() {
1740 next.push((ps + m.start(), ps + m.end()));
1741 }
1742 last = m.end();
1743 }
1744 if last < seg.len() {
1745 next.push((ps + last, pe));
1746 }
1747 }
1748 pieces = next;
1749 }
1750 let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1751 assert_eq!(
1752 got,
1753 vec!["ab", " cd", "12"],
1754 "staged split produced {got:?}"
1755 );
1756 }
1757
1758 #[test]
1759 fn full_pipeline_merges_and_added_tokens() {
1760 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1761 let ids = tok.encode("hello world");
1763 assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1764 assert_eq!(tok.decode(&ids), "hello world");
1765 let ids2 = tok.encode("he<|eot|>he");
1767 assert_eq!(ids2, vec![8, 10, 8]);
1768 assert_eq!(tok.decode(&ids2), "hehe");
1769 }
1770
1771 #[test]
1772 fn non_ascii_is_never_silently_dropped() {
1773 let tok = Tokenizer::from_json(&mini_json()).unwrap();
1774 let ids = tok.encode("hello");
1777 assert!(!ids.is_empty());
1778 }
1779}
1780
1781#[cfg(test)]
1782mod generation_tag_tests {
1783 use super::strip_generation_tags;
1784
1785 #[test]
1790 fn a_generation_block_becomes_a_no_op_keeping_its_whitespace_control() {
1791 let tpl = "a{%- generation -%}b{%- endgeneration -%}c";
1792 let out = strip_generation_tags(tpl);
1793 assert!(!out.contains("{%- generation"));
1794 assert!(!out.contains("endgeneration"));
1795 assert_eq!(out.matches("{%-").count(), 2);
1797 assert_eq!(out.matches("-%}").count(), 2);
1798 assert!(out.starts_with('a') && out.ends_with('c'));
1799 }
1800
1801 #[test]
1804 fn each_side_keeps_its_own_dash() {
1805 let out = strip_generation_tags("{% generation %}x{%- endgeneration %}");
1806 assert!(out.starts_with("{% set"), "no dash added on the left");
1807 assert!(out.contains("{%- set"), "the right tag keeps its dash");
1808 assert!(!out.contains("-%}"), "no trailing dash invented");
1809 }
1810
1811 #[test]
1814 fn everything_else_is_left_alone() {
1815 let plain = "{%- if x -%}{{ y }}{%- endif -%}";
1816 assert_eq!(strip_generation_tags(plain), plain);
1817 let prose = "{{ 'the generation of tokens' }}";
1819 assert_eq!(strip_generation_tags(prose), prose);
1820 }
1821}