use std::collections::HashMap;
use std::path::Path;
use crate::te::error::{TeError, TeResult};
const IM_START: u32 = 151644;
const IM_END: u32 = 151645;
pub const PAD_ID: u32 = 151643;
const USER: u32 = 872;
const ASSISTANT: u32 = 77091;
const NL: u32 = 198;
const NL2: u32 = 271;
const THINK_OPEN: u32 = 151667;
const THINK_CLOSE: u32 = 151668;
const PREFIX: [u32; 3] = [IM_START, USER, NL];
const SUFFIX: [u32; 9] = [
IM_END,
NL,
IM_START,
ASSISTANT,
NL,
THINK_OPEN,
NL2,
THINK_CLOSE,
NL2,
];
#[derive(Debug, Clone)]
pub struct TokenizerOutput {
pub input_ids: Vec<u32>,
pub attention_mask: Vec<i32>,
}
pub struct Qwen3Tokenizer {
vocab: HashMap<String, u32>,
merge_ranks: HashMap<(String, String), u32>,
byte_to_unicode: [char; 256],
}
impl Qwen3Tokenizer {
pub fn open(dir: &Path) -> TeResult<Self> {
let path = dir.join("tokenizer.json");
let text = std::fs::read_to_string(&path).map_err(|e| TeError::Io {
path: path.display().to_string(),
source: e,
})?;
Self::from_json_str(&text)
}
pub fn from_json_str(text: &str) -> TeResult<Self> {
let root: serde_json::Value = serde_json::from_str(text)
.map_err(|e| TeError::Tokenizer(format!("json parse: {e}")))?;
let model = root
.get("model")
.ok_or_else(|| TeError::Tokenizer("no model field".into()))?;
let vocab_obj = model
.get("vocab")
.and_then(|v| v.as_object())
.ok_or_else(|| TeError::Tokenizer("no model.vocab object".into()))?;
let mut vocab = HashMap::with_capacity(vocab_obj.len());
for (k, v) in vocab_obj {
let id = v
.as_u64()
.ok_or_else(|| TeError::Tokenizer(format!("vocab id for {k:?} not an int")))?;
vocab.insert(k.clone(), id as u32);
}
let merges = model
.get("merges")
.and_then(|v| v.as_array())
.ok_or_else(|| TeError::Tokenizer("no model.merges array".into()))?;
let mut merge_ranks = HashMap::with_capacity(merges.len());
for (rank, m) in merges.iter().enumerate() {
let (a, b) = parse_merge(m)
.ok_or_else(|| TeError::Tokenizer(format!("bad merge entry at {rank}")))?;
merge_ranks.insert((a, b), rank as u32);
}
Ok(Self {
vocab,
merge_ranks,
byte_to_unicode: build_byte_to_unicode(),
})
}
pub fn tokenize(&self, prompt: &str, max_len: usize) -> TeResult<TokenizerOutput> {
let mut ids: Vec<u32> = Vec::with_capacity(max_len);
ids.extend_from_slice(&PREFIX);
ids.extend(self.encode_prompt(prompt)?);
ids.extend_from_slice(&SUFFIX);
if ids.len() > max_len {
ids.truncate(max_len);
}
let real = ids.len();
let mut attention_mask = vec![1i32; real];
if real < max_len {
ids.resize(max_len, PAD_ID);
attention_mask.resize(max_len, 0);
}
Ok(TokenizerOutput {
input_ids: ids,
attention_mask,
})
}
pub fn encode_prompt(&self, prompt: &str) -> TeResult<Vec<u32>> {
let normalized = nfc_ascii(prompt);
let mut ids = Vec::new();
for piece in pre_tokenize(&normalized) {
let mut byte_level = String::with_capacity(piece.len());
for &b in piece.as_bytes() {
byte_level.push(self.byte_to_unicode[b as usize]);
}
for tok in self.bpe(&byte_level) {
let id = self
.vocab
.get(&tok)
.ok_or_else(|| TeError::Tokenizer(format!("token {tok:?} not in vocab")))?;
ids.push(*id);
}
}
Ok(ids)
}
fn bpe(&self, token: &str) -> Vec<String> {
let mut word: Vec<String> = token.chars().map(|c| c.to_string()).collect();
if word.len() < 2 {
return word;
}
loop {
let mut best_rank = u32::MAX;
let mut best_idx: Option<usize> = None;
for i in 0..word.len() - 1 {
if let Some(&r) = self
.merge_ranks
.get(&(word[i].clone(), word[i + 1].clone()))
{
if r < best_rank {
best_rank = r;
best_idx = Some(i);
}
}
}
let Some(idx) = best_idx else { break };
let (a, b) = (word[idx].clone(), word[idx + 1].clone());
let mut merged: Vec<String> = Vec::with_capacity(word.len());
let mut i = 0;
while i < word.len() {
if i + 1 < word.len() && word[i] == a && word[i + 1] == b {
merged.push(format!("{a}{b}"));
i += 2;
} else {
merged.push(word[i].clone());
i += 1;
}
}
word = merged;
if word.len() < 2 {
break;
}
}
word
}
}
fn parse_merge(m: &serde_json::Value) -> Option<(String, String)> {
if let Some(arr) = m.as_array() {
if arr.len() == 2 {
return Some((arr[0].as_str()?.to_string(), arr[1].as_str()?.to_string()));
}
return None;
}
let s = m.as_str()?;
let sp = s.find(' ')?;
Some((s[..sp].to_string(), s[sp + 1..].to_string()))
}
fn build_byte_to_unicode() -> [char; 256] {
let mut keep: Vec<u32> = Vec::new();
keep.extend(b'!' as u32..=b'~' as u32);
keep.extend(0xA1u32..=0xACu32);
keep.extend(0xAEu32..=0xFFu32);
let mut table = ['\0'; 256];
let mut n = 0u32;
for b in 0u32..256 {
if keep.contains(&b) {
table[b as usize] = char::from_u32(b).unwrap_or('\0');
} else {
let c = char::from_u32(256 + n).unwrap_or('\0');
table[b as usize] = c;
n += 1;
}
}
table
}
fn nfc_ascii(s: &str) -> String {
s.to_string()
}
fn pre_tokenize(text: &str) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out: Vec<String> = Vec::new();
let mut i = 0usize;
let is_letter = |c: char| c.is_alphabetic();
let is_number = |c: char| c.is_numeric();
let is_ws = |c: char| c.is_whitespace();
let is_nl = |c: char| c == '\r' || c == '\n';
while i < n {
let c = chars[i];
if c == '\'' && i + 1 < n {
if let Some(len) = match_contraction(&chars[i..]) {
out.push(chars[i..i + len].iter().collect());
i += len;
continue;
}
}
{
let mut j = i;
if !is_nl(chars[j]) && !is_letter(chars[j]) && !is_number(chars[j]) {
if j + 1 < n && is_letter(chars[j + 1]) {
j += 1;
}
}
if j < n && is_letter(chars[j]) {
while j < n && is_letter(chars[j]) {
j += 1;
}
out.push(chars[i..j].iter().collect());
i = j;
continue;
}
}
if is_number(c) {
out.push(c.to_string());
i += 1;
continue;
}
{
let mut j = i;
let mut consumed_space = false;
if chars[j] == ' ' {
if j + 1 < n
&& !is_ws(chars[j + 1])
&& !is_letter(chars[j + 1])
&& !is_number(chars[j + 1])
{
j += 1;
consumed_space = true;
}
}
if j < n && !is_ws(chars[j]) && !is_letter(chars[j]) && !is_number(chars[j]) {
while j < n && !is_ws(chars[j]) && !is_letter(chars[j]) && !is_number(chars[j]) {
j += 1;
}
while j < n && is_nl(chars[j]) {
j += 1;
}
out.push(chars[i..j].iter().collect());
i = j;
continue;
}
let _ = consumed_space;
}
if is_ws(c) {
let mut j = i;
while j < n && is_ws(chars[j]) && !is_nl(chars[j]) {
j += 1;
}
if j < n && is_nl(chars[j]) {
while j < n && is_nl(chars[j]) {
j += 1;
}
out.push(chars[i..j].iter().collect());
i = j;
continue;
}
}
if is_ws(c) {
let mut j = i;
while j < n && is_ws(chars[j]) {
j += 1;
}
if j == n {
out.push(chars[i..j].iter().collect());
i = j;
} else if j - i >= 2 {
out.push(chars[i..j - 1].iter().collect());
i = j - 1;
} else {
out.push(chars[i..j].iter().collect());
i = j;
}
continue;
}
out.push(c.to_string());
i += 1;
}
out
}
fn match_contraction(rest: &[char]) -> Option<usize> {
let lower = |c: char| c.to_ascii_lowercase();
if rest.len() >= 2 {
let c1 = lower(rest[1]);
if rest.len() >= 3 {
let c2 = lower(rest[2]);
let three = matches!((c1, c2), ('r', 'e') | ('v', 'e') | ('l', 'l'));
if three {
return Some(3);
}
}
if matches!(c1, 's' | 't' | 'm' | 'd') {
return Some(2);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_to_unicode_known_points() {
let t = build_byte_to_unicode();
assert_eq!(t[b' ' as usize], 'Ġ');
assert_eq!(t[b'\n' as usize], 'Ċ');
assert_eq!(t[b'a' as usize], 'a');
}
#[test]
fn pretokenize_canonical_prompt() {
let p = pre_tokenize("a tiny bonsai tree in a ceramic pot");
assert_eq!(
p,
vec!["a", " tiny", " bonsai", " tree", " in", " a", " ceramic", " pot"]
);
}
#[test]
fn bpe_merges_by_rank() {
let json = r#"{
"model": {
"vocab": {"a":0,"b":1,"c":2,"ab":3,"abc":4},
"merges": [["a","b"],["ab","c"]]
}
}"#;
let tok = Qwen3Tokenizer::from_json_str(json).expect("parse");
let out = tok.bpe("abc");
assert_eq!(out, vec!["abc".to_string()]);
assert_eq!(tok.bpe("ab"), vec!["ab".to_string()]);
assert_eq!(tok.bpe("ba"), vec!["b".to_string(), "a".to_string()]);
}
#[test]
fn chat_template_wraps_and_pads() {
let json = r#"{ "model": { "vocab": {"a":64}, "merges": [] } }"#;
let tok = Qwen3Tokenizer::from_json_str(json).expect("parse");
let out = tok.tokenize("a", 8).expect("tok");
assert_eq!(out.input_ids.len(), 8);
assert_eq!(&out.input_ids[..4], &[IM_START, USER, NL, 64]);
assert!(out.attention_mask.iter().all(|&m| m == 1));
let out2 = tok.tokenize("a", 20).expect("tok");
assert_eq!(out2.input_ids.len(), 20);
assert_eq!(
out2.input_ids[13..]
.iter()
.filter(|&&x| x == PAD_ID)
.count(),
7
);
assert_eq!(out2.attention_mask.iter().filter(|&&m| m == 0).count(), 7);
assert_eq!(out2.attention_mask.iter().filter(|&&m| m == 1).count(), 13);
}
#[test]
fn golden_ids_match_when_available() {
let dir_buf = match std::env::var("OXI_TE_TOKENIZER_DIR") {
Ok(d) => std::path::PathBuf::from(d),
Err(_) => {
eprintln!("skip: set OXI_TE_TOKENIZER_DIR to the text_encoder dir");
return;
}
};
let dir = dir_buf.as_path();
if !dir.join("tokenizer.json").exists() {
eprintln!("skip: tokenizer.json not present");
return;
}
let tok = Qwen3Tokenizer::open(dir).expect("open tokenizer");
let out = tok
.tokenize("a tiny bonsai tree in a ceramic pot", 512)
.expect("tokenize");
let expect_head: [u32; 21] = [
151644, 872, 198, 64, 13673, 81034, 2143, 4916, 304, 264, 42024, 3338, 151645, 198,
151644, 77091, 198, 151667, 271, 151668, 271,
];
assert_eq!(&out.input_ids[..21], &expect_head);
assert!(out.input_ids[21..].iter().all(|&x| x == PAD_ID));
assert_eq!(out.attention_mask.iter().filter(|&&m| m == 1).count(), 21);
}
}