use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use crate::error::{FocrError, FocrResult};
pub mod music;
mod ops;
pub mod pretok;
pub mod tiktoken;
mod unicode_tables;
pub use ops::TokenizerOps;
pub mod special_smollm2 {
pub const UNK: u32 = 0;
pub const BOS: u32 = 1;
pub const PAD: u32 = 2;
pub const GLOBAL_IMG: u32 = 49152;
pub const FAKE_AROUND_IMAGE: u32 = 49189;
pub const IMAGE: u32 = 49190;
pub const END_OF_UTTERANCE: u32 = 49279;
}
pub mod special_opt {
pub const PAD: u32 = 1;
pub const BOS_EOS: u32 = 2;
pub const IMG_PAD: u32 = 50_265;
pub const IMG_START: u32 = 50_266;
pub const IMG_END: u32 = 50_267;
pub const NUMBER: u32 = 50_268;
}
pub mod special {
pub const BOS: u32 = 0;
pub const EOS: u32 = 1;
pub const PAD: u32 = 2;
pub const IMAGE: u32 = 128815;
pub const REF: u32 = 128816;
pub const REF_END: u32 = 128817;
pub const DET: u32 = 128818;
pub const DET_END: u32 = 128819;
pub const GROUNDING: u32 = 128820;
pub const TD: u32 = 128821;
pub const TD_END: u32 = 128822;
pub const TR: u32 = 128823;
pub const TR_END: u32 = 128824;
pub const USER: u32 = 128825;
pub const ASSISTANT: u32 = 128826;
}
#[derive(Debug, Deserialize)]
struct RawTokenizer {
#[serde(default)]
added_tokens: Vec<RawAddedToken>,
model: RawModel,
#[serde(default)]
pre_tokenizer: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct RawAddedToken {
id: u32,
content: String,
#[serde(default)]
special: bool,
}
#[derive(Debug, Deserialize)]
struct RawModel {
vocab: HashMap<String, u32>,
#[serde(default)]
merges: Vec<RawMerge>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawMerge {
Pair([String; 2]),
Joined(String),
}
impl RawMerge {
fn into_pair(self) -> Option<(String, String)> {
match self {
RawMerge::Pair([l, r]) => Some((l, r)),
RawMerge::Joined(s) => {
let mut it = s.splitn(2, ' ');
let l = it.next()?.to_string();
let r = it.next()?.to_string();
Some((l, r))
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PretokScheme {
DeepSeekV2,
SmolLm2,
Gpt2,
}
fn classify_pretok(v: Option<&serde_json::Value>) -> FocrResult<PretokScheme> {
let Some(v) = v else {
return Ok(PretokScheme::DeepSeekV2);
};
let stages = v
.get("pretokenizers")
.and_then(|p| p.as_array())
.ok_or_else(|| {
FocrError::FormatMismatch(format!(
"unsupported tokenizer.json pre_tokenizer (expected a Sequence): {v}"
))
})?;
fn ty(s: &serde_json::Value) -> &str {
s.get("type").and_then(|t| t.as_str()).unwrap_or("")
}
if stages.len() == 2
&& ty(&stages[0]) == "Digits"
&& stages[0].get("individual_digits").and_then(|b| b.as_bool()) == Some(true)
&& ty(&stages[1]) == "ByteLevel"
&& stages[1].get("use_regex").and_then(|b| b.as_bool()) == Some(true)
{
return Ok(PretokScheme::SmolLm2);
}
if stages.len() == 4
&& ty(&stages[0]) == "Split"
&& stages[0].pointer("/pattern/Regex").and_then(|r| r.as_str()) == Some("\\p{N}{1,3}")
&& ty(&stages[3]) == "ByteLevel"
&& stages[3].get("use_regex").and_then(|b| b.as_bool()) == Some(false)
{
return Ok(PretokScheme::DeepSeekV2);
}
Err(FocrError::FormatMismatch(format!(
"unsupported tokenizer.json pre_tokenizer sequence (neither the \
DeepSeek-V2 4-stage Split nor the SmolLM2 Digits+ByteLevel shape): {v}"
)))
}
#[derive(Debug)]
pub struct Tokenizer {
vocab: HashMap<String, u32>,
id_to_token: HashMap<u32, String>,
merge_ranks: HashMap<(String, String), u32>,
added: Vec<AddedToken>,
added_by_content: HashMap<String, u32>,
special_ids: std::collections::HashSet<u32>,
scheme: PretokScheme,
}
#[derive(Debug, Clone)]
struct AddedToken {
content: String,
id: u32,
#[allow(dead_code)]
special: bool,
}
impl Tokenizer {
pub fn from_file(path: &Path) -> FocrResult<Self> {
let bytes = std::fs::read(path).map_err(|e| {
FocrError::ModelNotFound(format!("tokenizer.json at {}: {e}", path.display()))
})?;
Self::from_json_bytes(&bytes)
}
pub fn load(path: &Path) -> FocrResult<Self> {
Self::from_file(path)
}
pub fn from_json_bytes(bytes: &[u8]) -> FocrResult<Self> {
let raw: RawTokenizer = serde_json::from_slice(bytes)
.map_err(|e| FocrError::FormatMismatch(format!("tokenizer.json parse: {e}")))?;
let scheme = classify_pretok(raw.pre_tokenizer.as_ref())?;
let vocab = raw.model.vocab;
let mut merge_ranks = HashMap::with_capacity(raw.model.merges.len());
for (rank, m) in raw.model.merges.into_iter().enumerate() {
if let Some(pair) = m.into_pair() {
merge_ranks.entry(pair).or_insert(rank as u32);
}
}
let mut id_to_token: HashMap<u32, String> = HashMap::with_capacity(vocab.len());
for (tok, &id) in &vocab {
id_to_token.insert(id, tok.clone());
}
let mut added = Vec::with_capacity(raw.added_tokens.len());
let mut added_by_content = HashMap::with_capacity(raw.added_tokens.len());
let mut special_ids = std::collections::HashSet::new();
for at in raw.added_tokens {
id_to_token.insert(at.id, at.content.clone());
added_by_content.insert(at.content.clone(), at.id);
if at.special {
special_ids.insert(at.id);
}
added.push(AddedToken {
content: at.content,
id: at.id,
special: at.special,
});
}
added.sort_by(|a, b| b.content.len().cmp(&a.content.len()).then(a.id.cmp(&b.id)));
let tk = Tokenizer {
vocab,
id_to_token,
merge_ranks,
added,
added_by_content,
special_ids,
scheme,
};
tk.validate_pinned_ids()?;
Ok(tk)
}
pub fn from_opt_dir(dir: &Path) -> FocrResult<Self> {
let read = |name: &str| -> FocrResult<Vec<u8>> {
std::fs::read(dir.join(name)).map_err(|e| {
FocrError::ModelNotFound(format!("{} at {}: {e}", name, dir.display()))
})
};
Self::from_opt_files(
&read("vocab.json")?,
&read("merges.txt")?,
&read("added_tokens.json")?,
)
}
pub fn from_opt_files(
vocab_json: &[u8],
merges_txt: &[u8],
added_tokens_json: &[u8],
) -> FocrResult<Self> {
let vocab: HashMap<String, u32> = serde_json::from_slice(vocab_json)
.map_err(|e| FocrError::FormatMismatch(format!("vocab.json parse: {e}")))?;
let merges_text = std::str::from_utf8(merges_txt)
.map_err(|e| FocrError::FormatMismatch(format!("merges.txt not UTF-8: {e}")))?;
let mut merge_ranks = HashMap::new();
let mut rank = 0u32;
for line in merges_text.lines() {
let line = line.trim_end();
if line.is_empty() || line.starts_with("#version") {
continue;
}
let Some((l, r)) = line.split_once(' ') else {
return Err(FocrError::FormatMismatch(format!(
"merges.txt line without a space: {line:?}"
)));
};
merge_ranks
.entry((l.to_string(), r.to_string()))
.or_insert(rank);
rank += 1;
}
let added_map: HashMap<String, u32> = serde_json::from_slice(added_tokens_json)
.map_err(|e| FocrError::FormatMismatch(format!("added_tokens.json parse: {e}")))?;
let mut id_to_token: HashMap<u32, String> = HashMap::with_capacity(vocab.len());
for (tok, &id) in &vocab {
id_to_token.insert(id, tok.clone());
}
let mut added = Vec::with_capacity(added_map.len());
let mut added_by_content = HashMap::with_capacity(added_map.len());
let mut special_ids = std::collections::HashSet::new();
for (content, id) in added_map {
id_to_token.insert(id, content.clone());
added_by_content.insert(content.clone(), id);
special_ids.insert(id);
added.push(AddedToken {
content,
id,
special: true,
});
}
for surface in ["<s>", "<pad>", "</s>", "<unk>"] {
if let Some(&id) = vocab.get(surface)
&& !added_by_content.contains_key(surface)
{
added_by_content.insert(surface.to_string(), id);
special_ids.insert(id);
added.push(AddedToken {
content: surface.to_string(),
id,
special: true,
});
}
}
added.sort_by(|a, b| b.content.len().cmp(&a.content.len()).then(a.id.cmp(&b.id)));
let tk = Tokenizer {
vocab,
id_to_token,
merge_ranks,
added,
added_by_content,
special_ids,
scheme: PretokScheme::Gpt2,
};
tk.validate_pinned_ids()?;
Ok(tk)
}
fn validate_pinned_ids(&self) -> FocrResult<()> {
let checks: &[(&str, u32)] = match self.scheme {
PretokScheme::DeepSeekV2 => &[
("<image>", special::IMAGE),
("<|ref|>", special::REF),
("<|/ref|>", special::REF_END),
("<|det|>", special::DET),
("<|/det|>", special::DET_END),
("<|grounding|>", special::GROUNDING),
("<|User|>", special::USER),
("<|Assistant|>", special::ASSISTANT),
],
PretokScheme::SmolLm2 => &[
("<|endoftext|>", special_smollm2::UNK),
("<|im_start|>", special_smollm2::BOS),
("<|im_end|>", special_smollm2::PAD),
("<global-img>", special_smollm2::GLOBAL_IMG),
(
"<fake_token_around_image>",
special_smollm2::FAKE_AROUND_IMAGE,
),
("<image>", special_smollm2::IMAGE),
("<end_of_utterance>", special_smollm2::END_OF_UTTERANCE),
],
PretokScheme::Gpt2 => &[
("<imgpad>", special_opt::IMG_PAD),
("<img>", special_opt::IMG_START),
("</img>", special_opt::IMG_END),
("<Number>", special_opt::NUMBER),
],
};
for &(content, want) in checks {
if let Some(&got) = self.added_by_content.get(content)
&& got != want
{
return Err(FocrError::FormatMismatch(format!(
"tokenizer.json id mismatch for {content}: file says {got}, expected {want}"
)));
}
}
Ok(())
}
pub fn encode(&self, text: &str) -> FocrResult<Vec<u32>> {
let mut ids = Vec::new();
for segment in self.split_on_added(text) {
match segment {
Segment::Added(id) => ids.push(id),
Segment::Text(s) => self.encode_text_segment(s, &mut ids)?,
}
}
Ok(ids)
}
fn encode_text_segment(&self, text: &str, out: &mut Vec<u32>) -> FocrResult<()> {
let pieces = match self.scheme {
PretokScheme::DeepSeekV2 => pretok::pretokenize(text),
PretokScheme::SmolLm2 => pretok::pretokenize_smollm2(text),
PretokScheme::Gpt2 => pretok::pretokenize_gpt2(text),
};
for piece in pieces {
let symbols = self.bpe(&piece);
for sym in symbols {
let id = self.vocab.get(&sym).copied().ok_or_else(|| {
FocrError::FormatMismatch(format!(
"byte-level symbol {sym:?} missing from vocab (corrupt tokenizer.json)"
))
})?;
out.push(id);
}
}
Ok(())
}
fn bpe(&self, piece: &str) -> Vec<String> {
let mut symbols: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
if symbols.len() < 2 {
return symbols;
}
loop {
let mut best: Option<(usize, u32)> = None;
for i in 0..symbols.len() - 1 {
let pair = (symbols[i].clone(), symbols[i + 1].clone());
if let Some(&rank) = self.merge_ranks.get(&pair) {
match best {
Some((_, br)) if rank >= br => {}
_ => best = Some((i, rank)),
}
}
}
let Some((i, _)) = best else { break };
let merged = format!("{}{}", symbols[i], symbols[i + 1]);
symbols.splice(i..=i + 1, std::iter::once(merged));
if symbols.len() < 2 {
break;
}
}
symbols
}
fn split_on_added<'a>(&self, text: &'a str) -> Vec<Segment<'a>> {
if self.added.is_empty() {
return vec![Segment::Text(text)];
}
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut run_start = 0usize; let mut i = 0usize;
while i < bytes.len() {
let mut matched = None;
for at in &self.added {
let c = at.content.as_bytes();
if !c.is_empty() && bytes[i..].starts_with(c) {
matched = Some((c.len(), at.id));
break; }
}
if let Some((len, id)) = matched {
if run_start < i {
out.push(Segment::Text(&text[run_start..i]));
}
out.push(Segment::Added(id));
i += len;
run_start = i;
} else {
let ch_len = utf8_char_len(bytes[i]);
i += ch_len;
}
}
if run_start < text.len() {
out.push(Segment::Text(&text[run_start..]));
}
out
}
pub fn decode(&self, ids: &[u32]) -> FocrResult<String> {
self.decode_inner(ids, false)
}
pub fn decode_skip_special(&self, ids: &[u32]) -> FocrResult<String> {
self.decode_inner(ids, true)
}
fn decode_inner(&self, ids: &[u32], skip_special: bool) -> FocrResult<String> {
let mut bytes: Vec<u8> = Vec::new();
for &id in ids {
if skip_special && self.special_ids.contains(&id) {
continue;
}
let tok = self.id_to_token.get(&id).ok_or_else(|| {
FocrError::FormatMismatch(format!("decode: unknown token id {id}"))
})?;
if self.added_by_content.contains_key(tok) {
bytes.extend_from_slice(tok.as_bytes());
} else {
for c in tok.chars() {
let b = pretok::char_to_byte(c).ok_or_else(|| {
FocrError::FormatMismatch(format!(
"decode: token id {id} has non-byte-level symbol {c:?}"
))
})?;
bytes.push(b);
}
}
}
String::from_utf8(bytes)
.map_err(|e| FocrError::FormatMismatch(format!("decode: invalid UTF-8: {e}")))
}
pub fn token_to_id(&self, content: &str) -> Option<u32> {
self.added_by_content
.get(content)
.copied()
.or_else(|| self.vocab.get(content).copied())
}
pub fn id_to_token(&self, id: u32) -> Option<&str> {
self.id_to_token.get(&id).map(String::as_str)
}
pub fn vocab_size(&self) -> usize {
self.vocab.len()
}
pub fn scheme(&self) -> PretokScheme {
self.scheme
}
pub fn bos_id(&self) -> u32 {
match self.scheme {
PretokScheme::DeepSeekV2 => special::BOS,
PretokScheme::SmolLm2 => special_smollm2::BOS,
PretokScheme::Gpt2 => special_opt::BOS_EOS,
}
}
pub fn eos_id(&self) -> u32 {
match self.scheme {
PretokScheme::DeepSeekV2 => special::EOS,
PretokScheme::SmolLm2 => special_smollm2::END_OF_UTTERANCE,
PretokScheme::Gpt2 => special_opt::BOS_EOS,
}
}
pub fn pad_id(&self) -> u32 {
match self.scheme {
PretokScheme::DeepSeekV2 => special::PAD,
PretokScheme::SmolLm2 => special_smollm2::PAD,
PretokScheme::Gpt2 => special_opt::PAD,
}
}
pub fn image_id(&self) -> u32 {
match self.scheme {
PretokScheme::DeepSeekV2 => special::IMAGE,
PretokScheme::SmolLm2 => special_smollm2::IMAGE,
PretokScheme::Gpt2 => special_opt::IMG_PAD,
}
}
}
enum Segment<'a> {
Text(&'a str),
Added(u32),
}
pub(super) fn utf8_char_len(lead: u8) -> usize {
if lead < 0x80 {
1
} else if lead >> 5 == 0b110 {
2
} else if lead >> 4 == 0b1110 {
3
} else if lead >> 3 == 0b11110 {
4
} else {
1 }
}
#[allow(dead_code)]
const UNICODE_TABLE_REGEN: () = ();
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn tiny_json() -> String {
r#"{
"version": "1.0",
"added_tokens": [
{"id": 128815, "content": "<image>", "special": true},
{"id": 100, "content": "<|x|>", "special": false}
],
"normalizer": {"type":"Sequence","normalizers":[]},
"model": {
"type": "BPE",
"vocab": {
"a": 0, "b": 1, "c": 2, "d": 3, "e": 4, "f": 5,
"Ġ": 6, "ab": 7, "abc": 8, "Ġa": 9, "Ġd": 10
},
"merges": [
["a", "b"],
["ab", "c"],
["Ġ", "a"],
["Ġ", "d"]
]
}
}"#
.to_string()
}
fn tk() -> Tokenizer {
Tokenizer::from_json_bytes(tiny_json().as_bytes()).expect("tiny tokenizer loads")
}
#[test]
fn loads_and_validates_pinned_ids() {
let t = tk();
assert_eq!(t.token_to_id("<image>"), Some(special::IMAGE));
assert_eq!(t.image_id(), 128815);
assert_eq!(t.bos_id(), 0);
assert_eq!(t.eos_id(), 1);
assert_eq!(t.vocab_size(), 11);
}
#[test]
fn pinned_id_mismatch_is_format_error() {
let bad = tiny_json().replace("\"id\": 128815", "\"id\": 999");
let err = Tokenizer::from_json_bytes(bad.as_bytes()).unwrap_err();
assert!(matches!(err, FocrError::FormatMismatch(_)), "got {err:?}");
}
#[test]
fn bpe_merge_ordering() {
let t = tk();
assert_eq!(t.encode("abc").unwrap(), vec![8]);
assert_eq!(t.encode("ab").unwrap(), vec![7]);
assert_eq!(t.encode("ba").unwrap(), vec![1, 0]);
}
#[test]
fn merge_priority_is_rank_not_position() {
let t = tk();
assert_eq!(t.encode(" a").unwrap(), vec![9]);
}
#[test]
fn special_token_splitting() {
let t = tk();
assert_eq!(t.encode("ab<image>c").unwrap(), vec![7, 128815, 2]);
assert_eq!(t.encode("a<|x|>b").unwrap(), vec![0, 100, 1]);
let ids = t.encode("<image>").unwrap();
assert_eq!(ids, vec![128815]);
}
#[test]
fn round_trip_encode_decode() {
let t = tk();
let ids = t.encode("abc").unwrap();
assert_eq!(t.decode(&ids).unwrap(), "abc");
let ids2 = t.encode(" a").unwrap();
assert_eq!(t.decode(&ids2).unwrap(), " a");
let ids3 = t.encode("ab<image>c").unwrap();
assert_eq!(t.decode(&ids3).unwrap(), "ab<image>c");
}
#[test]
fn decode_skip_special_drops_specials() {
let t = tk();
let ids = t.encode("ab<image>c").unwrap();
assert_eq!(t.decode_skip_special(&ids).unwrap(), "abc");
let ids2 = t.encode("a<|x|>b").unwrap();
assert_eq!(t.decode_skip_special(&ids2).unwrap(), "a<|x|>b");
}
#[test]
fn byte_level_non_ascii_round_trips_through_bytes() {
let json = r#"{
"added_tokens": [],
"model": {"type":"BPE",
"vocab": {"Ã": 0, "©": 1},
"merges": []
}
}"#;
let t = Tokenizer::from_json_bytes(json.as_bytes()).unwrap();
let ids = t.encode("é").unwrap(); assert_eq!(ids, vec![0, 1]);
assert_eq!(t.decode(&ids).unwrap(), "é");
}
#[test]
fn legacy_joined_merge_format_is_accepted() {
let json = r#"{
"added_tokens": [],
"model": {"type":"BPE",
"vocab": {"a":0,"b":1,"ab":2},
"merges": ["a b"]
}
}"#;
let t = Tokenizer::from_json_bytes(json.as_bytes()).unwrap();
assert_eq!(t.encode("ab").unwrap(), vec![2]);
}
#[test]
fn decode_unknown_id_errors() {
let t = tk();
let err = t.decode(&[424242]).unwrap_err();
assert!(matches!(err, FocrError::FormatMismatch(_)), "got {err:?}");
}
#[test]
fn from_file_missing_is_model_not_found() {
let err = Tokenizer::from_file(Path::new("/nonexistent/tokenizer.json")).unwrap_err();
assert!(matches!(err, FocrError::ModelNotFound(_)), "got {err:?}");
}
#[test]
fn empty_input_encodes_empty() {
let t = tk();
assert_eq!(t.encode("").unwrap(), Vec::<u32>::new());
assert_eq!(t.decode(&[]).unwrap(), "");
}
pub(super) fn load_real() -> Option<Tokenizer> {
let path = std::env::var("FOCR_TOKENIZER_JSON").unwrap_or_else(|_| {
concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/truth-pack/snapshots/tokenizer.json"
)
.to_string()
});
let path = Path::new(&path);
if !path.is_file() {
eprintln!(
"SKIP baidu tokenizer conformance: {} absent \
(scripts/fetch_sources.sh or FOCR_TOKENIZER_JSON)",
path.display()
);
return None;
}
Some(Tokenizer::from_file(path).expect("pinned tokenizer.json must load"))
}
#[test]
fn real_vocab_anchors() {
let Some(t) = load_real() else {
return;
};
assert_eq!(t.vocab_size(), 128000);
assert_eq!(
t.encode("<|begin▁of▁sentence|>").unwrap(),
vec![special::BOS]
);
assert_eq!(
t.encode("<|end▁of▁sentence|>").unwrap(),
vec![special::EOS]
);
assert_eq!(t.encode("<|▁pad▁|>").unwrap(), vec![special::PAD]);
assert_eq!(t.encode("<image>").unwrap(), vec![special::IMAGE]);
assert_eq!(t.encode("<|grounding|>").unwrap(), vec![special::GROUNDING]);
assert_eq!(t.encode("<|User|>").unwrap(), vec![special::USER]);
assert_eq!(t.encode("<|Assistant|>").unwrap(), vec![special::ASSISTANT]);
assert_ne!(t.encode("<|User|>").unwrap(), vec![special::USER]);
}
#[test]
fn baidu_token_id_conformance_gate() {
let Some(t) = load_real() else {
return;
};
const EXPECTED: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/tokenizer_baidu/expected.json"
));
let v: serde_json::Value = serde_json::from_str(EXPECTED).unwrap();
assert_eq!(
v["_meta"]["tokenizer_json_sha256"].as_str().unwrap(),
"a02f8fd5228c90256bb4f6554c34a579d48f909e5beb232dc4afad870b55a8b4",
"fixture was generated against a different tokenizer.json pin"
);
let cases = v["fixtures"].as_array().expect("fixtures array");
let num_cases = v["_meta"]["num_cases"].as_u64().unwrap() as usize;
assert_eq!(cases.len(), num_cases, "fixture _meta.num_cases drift");
assert!(
cases.len() >= 100,
"conformance corpus must stay >= 100 cases"
);
let mut mismatches = 0usize;
for rec in cases {
let text = rec["text"].as_str().unwrap();
let want: Vec<u32> = rec["ids"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_u64().unwrap() as u32)
.collect();
let got = t.encode(text).unwrap();
if got != want {
let pos = got
.iter()
.zip(&want)
.position(|(a, b)| a != b)
.unwrap_or_else(|| got.len().min(want.len()));
eprintln!(
"ENC MISMATCH {{\"case\": {text:?}, \"len\": {}, \"mismatch_pos\": {pos}}}\n \
got {got:?}\n want {want:?}",
want.len()
);
mismatches += 1;
}
let want_decoded = rec["decoded"].as_str().unwrap();
let got_decoded = t.decode(&want).unwrap();
if got_decoded != want_decoded {
eprintln!(
"DEC MISMATCH {{\"case\": {text:?}, \"mismatch_pos\": \"none\"}}\n \
got {got_decoded:?}\n want {want_decoded:?}"
);
mismatches += 1;
}
}
assert_eq!(
mismatches, 0,
"tok_id_mismatch_count must be 0 (got {mismatches})"
);
}
fn tiny_smollm2_json() -> String {
r#"{
"version": "1.0",
"added_tokens": [
{"id": 49190, "content": "<image>", "special": true},
{"id": 49279, "content": "<end_of_utterance>", "special": true}
],
"normalizer": null,
"pre_tokenizer": {
"type": "Sequence",
"pretokenizers": [
{"type": "Digits", "individual_digits": true},
{"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": true}
]
},
"post_processor": null,
"model": {
"type": "BPE",
"vocab": {
"a": 0, "b": 1, "c": 2, "1": 3, "2": 4,
"Ġ": 5, "ab": 6, "12": 7, "'": 8, "s": 9, "'s": 10
},
"merges": [
["a", "b"],
["1", "2"],
["'", "s"]
]
}
}"#
.to_string()
}
fn tk_smollm2() -> Tokenizer {
Tokenizer::from_json_bytes(tiny_smollm2_json().as_bytes()).expect("tiny smollm2 loads")
}
#[test]
fn smollm2_scheme_is_detected_from_pre_tokenizer() {
let t = tk_smollm2();
assert_eq!(t.scheme(), PretokScheme::SmolLm2);
assert_eq!(tk().scheme(), PretokScheme::DeepSeekV2);
}
#[test]
fn smollm2_scheme_ids() {
let t = tk_smollm2();
assert_eq!(t.bos_id(), special_smollm2::BOS); assert_eq!(t.eos_id(), special_smollm2::END_OF_UTTERANCE); assert_eq!(t.pad_id(), special_smollm2::PAD); assert_eq!(t.image_id(), special_smollm2::IMAGE); let b = tk();
assert_eq!(b.bos_id(), special::BOS);
assert_eq!(b.eos_id(), special::EOS);
assert_eq!(b.image_id(), special::IMAGE);
}
#[test]
fn smollm2_digits_encode_individually() {
let t = tk_smollm2();
assert_eq!(t.encode("12").unwrap(), vec![3, 4]);
let baidu_style = tiny_smollm2_json()
.replace(
r#""pre_tokenizer": {
"type": "Sequence",
"pretokenizers": [
{"type": "Digits", "individual_digits": true},
{"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": true}
]
},"#,
"",
)
.replace("<image>", "<img2>");
assert!(
!baidu_style.contains("pre_tokenizer"),
"test fixture drifted: the pre_tokenizer block was not stripped"
);
let b = Tokenizer::from_json_bytes(baidu_style.as_bytes()).expect("loads");
assert_eq!(b.scheme(), PretokScheme::DeepSeekV2);
assert_eq!(b.encode("12").unwrap(), vec![7]);
}
#[test]
fn smollm2_contraction_merges() {
let t = tk_smollm2();
assert_eq!(t.encode("ab's").unwrap(), vec![6, 10]);
}
#[test]
fn smollm2_added_tokens_split() {
let t = tk_smollm2();
assert_eq!(
t.encode("ab<image>ab").unwrap(),
vec![6, special_smollm2::IMAGE, 6]
);
assert_eq!(
t.decode_skip_special(&t.encode("ab<image>ab").unwrap())
.unwrap(),
"abab"
);
}
#[test]
fn smollm2_wrong_image_pin_is_rejected() {
let bad = tiny_smollm2_json().replace("\"id\": 49190", "\"id\": 999");
let err = Tokenizer::from_json_bytes(bad.as_bytes()).unwrap_err();
assert!(matches!(err, FocrError::FormatMismatch(_)), "got {err:?}");
}
#[test]
fn unknown_pre_tokenizer_is_rejected() {
let bad = tiny_smollm2_json().replace(
r#"{"type": "Digits", "individual_digits": true}"#,
r#"{"type": "Whitespace"}"#,
);
let err = Tokenizer::from_json_bytes(bad.as_bytes()).unwrap_err();
assert!(matches!(err, FocrError::FormatMismatch(_)), "got {err:?}");
let bad2 = tiny_smollm2_json().replace(
r#"{
"type": "Sequence",
"pretokenizers": [
{"type": "Digits", "individual_digits": true},
{"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": true, "use_regex": true}
]
}"#,
r#"{"type": "ByteLevel", "add_prefix_space": true, "use_regex": true}"#,
);
let err2 = Tokenizer::from_json_bytes(bad2.as_bytes()).unwrap_err();
assert!(matches!(err2, FocrError::FormatMismatch(_)), "got {err2:?}");
}
pub(super) fn load_real_smolvlm2() -> Option<Tokenizer> {
let path = std::env::var("FOCR_SMOLVLM2_TOKENIZER_JSON")
.ok()
.or_else(|| {
std::env::var("FOCR_SMOLVLM2_DIR")
.ok()
.map(|d| format!("{d}/tokenizer.json"))
});
let Some(path) = path else {
eprintln!(
"SKIP smolvlm2 tokenizer conformance: set FOCR_SMOLVLM2_TOKENIZER_JSON \
(or FOCR_SMOLVLM2_DIR) to the pinned tokenizer.json"
);
return None;
};
let path = Path::new(&path);
if !path.is_file() {
eprintln!(
"SKIP smolvlm2 tokenizer conformance: {} absent",
path.display()
);
return None;
}
Some(Tokenizer::from_file(path).expect("pinned smolvlm2 tokenizer.json must load"))
}
#[test]
fn smolvlm2_real_vocab_anchors() {
let Some(t) = load_real_smolvlm2() else {
return;
};
assert_eq!(t.scheme(), PretokScheme::SmolLm2);
assert_eq!(t.vocab_size(), 49152);
assert_eq!(t.encode("<|endoftext|>").unwrap(), vec![0]);
assert_eq!(t.encode("<|im_start|>").unwrap(), vec![1]);
assert_eq!(t.encode("<|im_end|>").unwrap(), vec![2]);
assert_eq!(
t.encode("<global-img>").unwrap(),
vec![special_smollm2::GLOBAL_IMG]
);
assert_eq!(
t.encode("<fake_token_around_image>").unwrap(),
vec![special_smollm2::FAKE_AROUND_IMAGE]
);
assert_eq!(t.encode("<image>").unwrap(), vec![special_smollm2::IMAGE]);
assert_eq!(
t.encode("<end_of_utterance>").unwrap(),
vec![special_smollm2::END_OF_UTTERANCE]
);
assert_eq!(t.encode("<row_1_col_1>").unwrap(), vec![49153]);
assert_eq!(t.encode("<row_6_col_6>").unwrap(), vec![49188]);
}
#[test]
fn smolvlm2_token_id_conformance_gate() {
let Some(t) = load_real_smolvlm2() else {
return;
};
const EXPECTED: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/tokenizer_smolvlm2/expected.json"
));
let v: serde_json::Value = serde_json::from_str(EXPECTED).unwrap();
assert_eq!(
v["_meta"]["tokenizer_json_sha256"].as_str().unwrap(),
"5ece781dc8d2b2f3e2f289ca0ae50b17cfc27dd27bfe7971bb8241e0b964331a",
"fixture was generated against a different tokenizer.json pin"
);
let cases = v["fixtures"].as_array().expect("fixtures array");
let num_cases = v["_meta"]["num_cases"].as_u64().unwrap() as usize;
assert_eq!(cases.len(), num_cases, "fixture _meta.num_cases drift");
assert!(
cases.len() >= 100,
"conformance corpus must stay >= 100 cases"
);
let mut mismatches = 0usize;
for rec in cases {
let text = rec["text"].as_str().unwrap();
let want: Vec<u32> = rec["ids"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_u64().unwrap() as u32)
.collect();
let got = t.encode(text).unwrap();
if got != want {
let pos = got
.iter()
.zip(&want)
.position(|(a, b)| a != b)
.unwrap_or_else(|| got.len().min(want.len()));
eprintln!(
"ENC MISMATCH {{\"case\": {text:?}, \"len\": {}, \"mismatch_pos\": {pos}}}\n \
got {got:?}\n want {want:?}",
want.len()
);
mismatches += 1;
}
let want_decoded = rec["decoded"].as_str().unwrap();
let got_decoded = t.decode(&want).unwrap();
if got_decoded != want_decoded {
eprintln!(
"DEC MISMATCH {{\"case\": {text:?}}}\n \
got {got_decoded:?}\n want {want_decoded:?}"
);
mismatches += 1;
}
}
assert_eq!(
mismatches, 0,
"tok_id_mismatch_count must be 0 (got {mismatches})"
);
}
#[test]
fn opt_files_load_and_scheme_dispatch() {
let vocab = r#"{"a":0,"b":1,"c":2,"1":3,"2":4,"Ġ":5,"ab":6,"12":7}"#.as_bytes();
let merges = b"#version: 0.2\na b\n1 2\n";
let added = br#"{"<imgpad>":50265,"<img>":50266,"</img>":50267,"<Number>":50268}"#;
let t = Tokenizer::from_opt_files(vocab, merges, added).expect("opt files load");
assert_eq!(t.scheme(), PretokScheme::Gpt2);
assert_eq!(t.encode("12").unwrap(), vec![7]);
assert_eq!(t.encode("ab").unwrap(), vec![6]);
assert_eq!(
t.encode("ab<imgpad>ab").unwrap(),
vec![6, special_opt::IMG_PAD, 6]
);
assert_eq!(
t.decode_skip_special(&t.encode("ab<Number>").unwrap())
.unwrap(),
"ab"
);
assert_eq!(t.bos_id(), special_opt::BOS_EOS);
assert_eq!(t.eos_id(), special_opt::BOS_EOS);
assert_eq!(t.pad_id(), special_opt::PAD);
assert_eq!(t.image_id(), special_opt::IMG_PAD);
let bad = br#"{"<imgpad>":999}"#;
let err = Tokenizer::from_opt_files(vocab, merges, bad).unwrap_err();
assert!(matches!(err, FocrError::FormatMismatch(_)), "got {err:?}");
let err = Tokenizer::from_opt_dir(Path::new("/nonexistent")).unwrap_err();
assert!(matches!(err, FocrError::ModelNotFound(_)), "got {err:?}");
}
#[test]
fn onechart_token_id_conformance_gate() {
let Ok(dir) = std::env::var("FOCR_ONECHART_DIR") else {
eprintln!("SKIP onechart tokenizer conformance: set FOCR_ONECHART_DIR");
return;
};
let t = Tokenizer::from_opt_dir(Path::new(&dir)).expect("onechart tokenizer loads");
assert_eq!(t.vocab_size(), 50_265);
const EXPECTED: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/tokenizer_onechart/expected.json"
));
let v: serde_json::Value = serde_json::from_str(EXPECTED).unwrap();
let cases = v["fixtures"].as_object().expect("fixtures object");
assert!(cases.len() >= 25, "conformance corpus shrank");
let mut mismatches = 0usize;
for (text, want) in cases {
let want: Vec<u32> = want
.as_array()
.unwrap()
.iter()
.map(|x| x.as_u64().unwrap() as u32)
.collect();
let got = t.encode(text).unwrap();
if got != want {
eprintln!("ENC MISMATCH {text:?}\n got {got:?}\n want {want:?}");
mismatches += 1;
}
}
eprintln!("[D9] {} cases, {mismatches} mismatches", cases.len());
assert_eq!(mismatches, 0, "tok_id_mismatch_count must be 0");
}
}