use anyhow::{anyhow, Result};
use mlx_native::gguf::{GgufFile, MetadataValue};
use std::collections::HashMap;
use tokenizers::models::wordpiece::WordPiece;
use tokenizers::normalizers::bert::BertNormalizer;
use tokenizers::pre_tokenizers::bert::BertPreTokenizer;
use tokenizers::processors::bert::BertProcessing;
use tokenizers::Tokenizer;
#[derive(Debug, Clone)]
pub struct BertWpmTokenizer {
token_to_id: HashMap<String, u32>,
specials: BertSpecialTokens,
max_token_len: usize,
}
impl BertWpmTokenizer {
pub fn new(vocab: &BertVocab) -> Self {
let mut max_len = 0usize;
let mut token_to_id = HashMap::with_capacity(vocab.tokens.len());
for (i, tok) in vocab.tokens.iter().enumerate() {
max_len = max_len.max(tok.len());
token_to_id.insert(tok.clone(), i as u32);
}
Self {
token_to_id,
specials: vocab.specials,
max_token_len: max_len.max(1),
}
}
pub fn encode(&self, text: &str, add_special_tokens: bool) -> Vec<u32> {
let words = preprocess_words(text);
let mut output: Vec<u32> = Vec::with_capacity(words.len() * 2 + 2);
if add_special_tokens {
output.push(self.specials.cls);
}
for word in words {
if word.is_empty() {
continue;
}
let mut word1 = String::with_capacity(word.len() + 3);
word1.push('\u{2581}');
word1.push_str(&word);
let bytes = word1.as_bytes();
let n = bytes.len();
let current_tokens = output.len();
let mut i = 0usize;
let mut matched_word = true;
while i < n {
let mut found_at: Option<usize> = None;
let upper = std::cmp::min(n, i + self.max_token_len + 1);
let mut j = upper;
while j > i {
let slice = &bytes[i..j];
if let Ok(s) = std::str::from_utf8(slice) {
if let Some(&id) = self.token_to_id.get(s) {
found_at = Some(j);
output.push(id);
break;
}
}
j -= 1;
}
match found_at {
Some(end) => {
i = end;
}
None => {
output.truncate(current_tokens);
matched_word = false;
break;
}
}
}
if !matched_word || output.len() == current_tokens {
output.push(self.specials.unk);
}
}
if add_special_tokens {
output.push(self.specials.sep);
}
output
}
pub fn specials(&self) -> &BertSpecialTokens {
&self.specials
}
}
fn preprocess_words(text: &str) -> Vec<String> {
let mut words: Vec<String> = vec![String::new()];
for c in text.chars() {
if c == '\0' || c == '\u{FFFD}' || c.is_control() {
continue;
}
if c.is_whitespace() {
if !words.last().unwrap().is_empty() {
words.push(String::new());
}
continue;
}
let lower: String = c.to_lowercase().collect();
if c.is_ascii_punctuation()
|| (c.is_ascii()
&& (c as u32) < 0x7F
&& c.is_ascii_graphic()
&& !c.is_ascii_alphanumeric()
&& !c.is_ascii_whitespace())
{
if !words.last().unwrap().is_empty() {
words.push(String::new());
}
words.push(lower);
words.push(String::new());
} else {
words.last_mut().unwrap().push_str(&lower);
}
}
if words.last().map(|w| w.is_empty()).unwrap_or(false) {
words.pop();
}
words
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BertSpecialTokens {
pub cls: u32,
pub sep: u32,
pub pad: u32,
pub unk: u32,
pub mask: u32,
}
impl BertSpecialTokens {
pub fn from_gguf(gguf: &GgufFile) -> Result<Self> {
let read = |key: &str| -> Result<u32> {
gguf.metadata_u32(key)
.ok_or_else(|| anyhow!("GGUF missing u32 metadata '{}'", key))
};
let cls =
read("tokenizer.ggml.cls_token_id").or_else(|_| read("tokenizer.ggml.bos_token_id"))?;
let sep = read("tokenizer.ggml.seperator_token_id")
.or_else(|_| read("tokenizer.ggml.separator_token_id"))
.or_else(|_| read("tokenizer.ggml.eos_token_id"))?;
let pad = read("tokenizer.ggml.padding_token_id")?;
let unk = read("tokenizer.ggml.unknown_token_id")?;
let mask = read("tokenizer.ggml.mask_token_id").unwrap_or(unk);
Ok(BertSpecialTokens {
cls,
sep,
pad,
unk,
mask,
})
}
}
#[derive(Debug, Clone)]
pub struct BertVocab {
pub tokens: Vec<String>,
pub specials: BertSpecialTokens,
}
impl BertVocab {
pub fn from_gguf(gguf: &GgufFile) -> Result<Self> {
let tokens_array = gguf
.metadata("tokenizer.ggml.tokens")
.ok_or_else(|| anyhow!("GGUF missing tokenizer.ggml.tokens"))?;
let arr = match tokens_array {
MetadataValue::Array(a) => a,
_ => return Err(anyhow!("tokenizer.ggml.tokens is not an array")),
};
let mut tokens: Vec<String> = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
let s = v
.as_str()
.ok_or_else(|| anyhow!("tokenizer.ggml.tokens[{}] is not a string", i))?;
tokens.push(s.to_string());
}
if tokens.is_empty() {
return Err(anyhow!("tokenizer.ggml.tokens array is empty"));
}
let specials = BertSpecialTokens::from_gguf(gguf)?;
let n = tokens.len() as u32;
for (label, id) in [
("cls", specials.cls),
("sep", specials.sep),
("pad", specials.pad),
("unk", specials.unk),
("mask", specials.mask),
] {
if id >= n {
return Err(anyhow!(
"special token '{}' id {} out of range (vocab size {})",
label,
id,
n
));
}
}
Ok(BertVocab { tokens, specials })
}
pub fn unk_str(&self) -> &str {
&self.tokens[self.specials.unk as usize]
}
pub fn len(&self) -> usize {
self.tokens.len()
}
pub fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
}
pub fn build_token_to_id_map(tokens: &[String]) -> HashMap<String, u32> {
let mut m = HashMap::with_capacity(tokens.len());
for (i, s) in tokens.iter().enumerate() {
m.insert(s.clone(), i as u32);
}
m
}
pub fn build_wordpiece_tokenizer(vocab: &BertVocab) -> Result<Tokenizer> {
let mut token_to_id: ahash::AHashMap<String, u32> = ahash::AHashMap::default();
for (i, s) in vocab.tokens.iter().enumerate() {
token_to_id.insert(s.clone(), i as u32);
}
let wp = WordPiece::builder()
.vocab(token_to_id)
.unk_token(vocab.unk_str().to_string())
.continuing_subword_prefix("##".to_string())
.max_input_chars_per_word(100)
.build()
.map_err(|e| anyhow!("WordPiece builder: {e}"))?;
let mut tokenizer = Tokenizer::new(wp);
tokenizer.with_normalizer(Some(BertNormalizer::default()));
tokenizer.with_pre_tokenizer(Some(BertPreTokenizer));
let cls_id = vocab.specials.cls;
let sep_id = vocab.specials.sep;
let cls_tok = vocab
.tokens
.get(cls_id as usize)
.cloned()
.ok_or_else(|| anyhow!("vocab missing CLS token at id {}", cls_id))?;
let sep_tok = vocab
.tokens
.get(sep_id as usize)
.cloned()
.ok_or_else(|| anyhow!("vocab missing SEP token at id {}", sep_id))?;
tokenizer.with_post_processor(Some(BertProcessing::new(
(sep_tok, sep_id),
(cls_tok, cls_id),
)));
Ok(tokenizer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_token_to_id_map_basic() {
let toks: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
let m = build_token_to_id_map(&toks);
assert_eq!(m.get("a"), Some(&0));
assert_eq!(m.get("b"), Some(&1));
assert_eq!(m.get("c"), Some(&2));
assert_eq!(m.get("d"), None);
}
#[test]
fn build_token_to_id_map_last_duplicate_wins() {
let toks: Vec<String> = vec!["x".into(), "y".into(), "x".into()];
let m = build_token_to_id_map(&toks);
assert_eq!(m.get("x"), Some(&2));
assert_eq!(m.get("y"), Some(&1));
}
#[test]
fn build_wordpiece_tokenizer_with_minimal_vocab() {
let tokens: Vec<String> = vec![
"[UNK]".into(),
"[CLS]".into(),
"[SEP]".into(),
"[PAD]".into(),
"hello".into(),
];
let vocab = BertVocab {
tokens,
specials: BertSpecialTokens {
cls: 1,
sep: 2,
pad: 3,
unk: 0,
mask: 0,
},
};
let tokenizer = build_wordpiece_tokenizer(&vocab).unwrap();
let enc = tokenizer.encode("hello", false).unwrap();
let ids = enc.get_ids();
assert!(ids.contains(&4), "expected 'hello' id 4 in {:?}", ids);
}
#[test]
fn build_wordpiece_tokenizer_falls_back_to_unk() {
let tokens: Vec<String> = vec![
"[UNK]".into(), "hello".into(),
];
let vocab = BertVocab {
tokens,
specials: BertSpecialTokens {
cls: 0,
sep: 0,
pad: 0,
unk: 0,
mask: 0,
},
};
let tokenizer = build_wordpiece_tokenizer(&vocab).unwrap();
let enc = tokenizer.encode("mystery", false).unwrap();
let ids = enc.get_ids();
assert!(ids.contains(&0), "expected [UNK]=0 in {:?}", ids);
}
#[test]
fn bert_vocab_len_matches_input() {
let tokens: Vec<String> = (0..100).map(|i| format!("tok_{}", i)).collect();
let vocab = BertVocab {
tokens,
specials: BertSpecialTokens {
cls: 0,
sep: 1,
pad: 2,
unk: 3,
mask: 4,
},
};
assert_eq!(vocab.len(), 100);
assert!(!vocab.is_empty());
}
#[test]
fn bert_vocab_empty_is_empty() {
let vocab = BertVocab {
tokens: Vec::new(),
specials: BertSpecialTokens {
cls: 0,
sep: 0,
pad: 0,
unk: 0,
mask: 0,
},
};
assert!(vocab.is_empty());
assert_eq!(vocab.len(), 0);
}
#[test]
fn bert_vocab_unk_str_reads_from_tokens() {
let vocab = BertVocab {
tokens: vec!["A".into(), "B".into(), "C".into()],
specials: BertSpecialTokens {
cls: 0,
sep: 1,
pad: 2,
unk: 2,
mask: 2,
},
};
assert_eq!(vocab.unk_str(), "C");
}
#[test]
fn wordpiece_tokenizer_handles_subword_continuation_prefix() {
let tokens: Vec<String> = vec!["[UNK]".into(), "play".into(), "##ing".into()];
let vocab = BertVocab {
tokens,
specials: BertSpecialTokens {
cls: 0,
sep: 0,
pad: 0,
unk: 0,
mask: 0,
},
};
let tokenizer = build_wordpiece_tokenizer(&vocab).unwrap();
let enc = tokenizer.encode("playing", false).unwrap();
let ids = enc.get_ids();
assert!(ids.contains(&1), "expected 'play'=1 in {:?}", ids);
assert!(ids.contains(&2), "expected '##ing'=2 in {:?}", ids);
}
#[test]
fn bge_small_vocab_format_diagnostic() {
let path = std::path::Path::new("/opt/hf2q/models/bert-test/bge-small-en-v1.5-f16.gguf");
if !path.exists() {
eprintln!("skipping: bge GGUF not on disk");
return;
}
let gguf = mlx_native::gguf::GgufFile::open(path).expect("open");
let vocab = BertVocab::from_gguf(&gguf).expect("vocab");
for &idx in &[0u32, 100, 101, 102, 1000, 2088, 3000, 7592, 10000, 11108] {
eprintln!("vocab[{:5}] = {:?}", idx, vocab.tokens.get(idx as usize));
}
let prefix_marker = "\u{2581}";
let mut n_prefix = 0;
let mut n_continuation = 0;
let mut n_bare = 0;
for tok in &vocab.tokens {
if tok.starts_with(prefix_marker) {
n_prefix += 1;
} else if tok.starts_with("##") {
n_continuation += 1;
} else {
n_bare += 1;
}
}
eprintln!(
"vocab counts: total={}, ▁-prefix={}, ##-prefix={}, bare={}",
vocab.tokens.len(),
n_prefix,
n_continuation,
n_bare,
);
}
#[test]
fn bge_small_tokenizer_matches_llama_cpp_on_long_prompt() {
let path = std::path::Path::new("/opt/hf2q/models/bert-test/bge-small-en-v1.5-f16.gguf");
if !path.exists() {
eprintln!("skipping: bge GGUF not on disk");
return;
}
let gguf = mlx_native::gguf::GgufFile::open(path).expect("open");
let vocab = BertVocab::from_gguf(&gguf).expect("vocab");
let tokenizer = BertWpmTokenizer::new(&vocab);
let input = "In the bustling cities of the modern world the economy depends on the fabric of trust between participants who exchange goods services and information across vast networks bridging continents and bringing strangers into productive collaboration daily across borders";
let ids = tokenizer.encode(input, true);
let expected: Vec<u32> = vec![
101, 1999, 1996, 13950, 2989, 3655, 1997, 1996, 2715, 2088, 1996, 4610, 9041, 2006,
1996, 8313, 1997, 3404, 2090, 6818, 2040, 3863, 5350, 2578, 1998, 2592, 2408, 6565,
6125, 7987, 3593, 4726, 17846, 1998, 5026, 12358, 2046, 13318, 5792, 3679, 2408, 6645,
102,
];
assert_eq!(
ids,
expected,
"long-prompt tokenization mismatch ({} hf2q vs {} llama tokens)",
ids.len(),
expected.len(),
);
}
#[test]
fn bge_small_tokenizer_matches_llama_cpp_on_hello_world() {
let path = std::path::Path::new("/opt/hf2q/models/bert-test/bge-small-en-v1.5-f16.gguf");
if !path.exists() {
eprintln!("skipping: bge GGUF not on disk at {}", path.display());
return;
}
let gguf = mlx_native::gguf::GgufFile::open(path).expect("open bge GGUF");
let vocab = BertVocab::from_gguf(&gguf).expect("vocab parse");
let tokenizer = BertWpmTokenizer::new(&vocab);
let ids = tokenizer.encode("hello world", true);
assert_eq!(
ids,
vec![101u32, 7592, 2088, 102],
"tokenization mismatch — vocab[100..103]: {:?}, vocab[7592]: {:?}, vocab[2088]: {:?}",
vocab.tokens.get(100..103),
vocab.tokens.get(7592),
vocab.tokens.get(2088),
);
}
}