use std::borrow::Cow;
use std::collections::HashMap;
use serde::Deserialize;
use tokenizers::{Normalizer, PreTokenizer};
use tokenizers::{NormalizerWrapper, OffsetReferential, OffsetType, PreTokenizedString, PreTokenizerWrapper};
const UNK_PENALTY: f64 = 10.0;
pub struct Unigram {
normalizer: Option<NormalizerWrapper>,
pre_tokenizer: Option<PreTokenizerWrapper>,
arena: String,
spans: Vec<(usize, usize)>,
scores: Vec<f64>,
by_hash: HashMap<u64, u32>,
collided: HashMap<String, u32>,
longest: HashMap<(char, char), usize>,
unk: u32,
min_score: f64,
}
#[derive(Deserialize)]
struct File<'a> {
normalizer: Option<NormalizerWrapper>,
pre_tokenizer: Option<PreTokenizerWrapper>,
#[serde(borrow)]
model: Model<'a>,
}
#[derive(Deserialize)]
struct Model<'a> {
#[serde(rename = "type", borrow)]
kind: Cow<'a, str>,
unk_id: Option<usize>,
#[serde(borrow)]
vocab: Vec<(Cow<'a, str>, f64)>,
#[serde(default)]
byte_fallback: bool,
}
const FNV: u64 = 0xcbf29ce484222325;
fn fnv(h: u64, bytes: &[u8]) -> u64 {
bytes.iter().fold(h, |h, &b| (h ^ b as u64).wrapping_mul(0x100000001b3))
}
impl Unigram {
pub fn parse(json: &str) -> Option<Unigram> {
let f: File = serde_json::from_str(json).ok()?;
let m = f.model;
if m.kind != "Unigram" || m.byte_fallback || m.vocab.is_empty() {
return None;
}
let unk = u32::try_from(m.unk_id?).ok().filter(|&u| (u as usize) < m.vocab.len())?;
let mut u = Unigram {
normalizer: f.normalizer,
pre_tokenizer: f.pre_tokenizer,
arena: String::with_capacity(m.vocab.iter().map(|(p, _)| p.len()).sum()),
spans: Vec::with_capacity(m.vocab.len()),
scores: Vec::with_capacity(m.vocab.len()),
by_hash: HashMap::with_capacity(m.vocab.len()),
collided: HashMap::new(),
longest: HashMap::new(),
unk,
min_score: f64::INFINITY,
};
for (id, (piece, score)) in m.vocab.iter().enumerate() {
let start = u.arena.len();
u.arena.push_str(piece);
u.spans.push((start, u.arena.len()));
u.scores.push(*score);
u.min_score = u.min_score.min(*score);
let h = fnv(FNV, piece.as_bytes());
match u.by_hash.get(&h).copied() {
Some(other) if u.piece(other) != piece.as_ref() => {
u.collided.insert(piece.to_string(), id as u32);
}
_ => {
u.by_hash.insert(h, id as u32);
}
}
let mut cs = piece.chars();
if let Some(a) = cs.next() {
let e = u.longest.entry((a, cs.next().unwrap_or('\0'))).or_default();
*e = (*e).max(piece.len());
}
}
Some(u)
}
fn piece(&self, id: u32) -> &str {
let (a, b) = self.spans[id as usize];
&self.arena[a..b]
}
fn id(&self, h: u64, s: &str) -> Option<u32> {
match self.by_hash.get(&h) {
Some(&id) if self.piece(id) == s => Some(id),
_ => self.collided.get(s).copied(),
}
}
pub fn ids(&self, text: &str) -> Option<Vec<u32>> {
let mut pre = PreTokenizedString::from(text);
if let Some(n) = &self.normalizer {
pre.normalize(|s| n.normalize(s)).ok()?;
}
if let Some(p) = &self.pre_tokenizer {
p.pre_tokenize(&mut pre).ok()?;
}
let mut out = Vec::new();
for (s, _, _) in pre.get_splits(OffsetReferential::Original, OffsetType::Byte) {
self.viterbi(s, &mut out);
}
Some(out)
}
fn viterbi(&self, s: &str, out: &mut Vec<u32>) {
if s.is_empty() {
return;
}
let mut best: Vec<(f64, Option<usize>, u32)> = vec![(0.0, None, 0); s.len() + 1];
let unk_score = self.min_score - UNK_PENALTY;
let mut start = 0;
while start < s.len() {
let base = best[start].0;
let first = s[start..].chars().next().map_or(1, char::len_utf8);
let second = s[start + first..].chars().next().unwrap_or('\0');
let c0 = s[start..].chars().next().unwrap_or('\0');
let longest = self.longest.get(&(c0, '\0')).copied().unwrap_or(0).max(self.longest.get(&(c0, second)).copied().unwrap_or(0));
let mut single = false;
let mut h = FNV;
let mut end = start;
for (i, c) in s[start..].char_indices() {
let next = start + i + c.len_utf8();
if next - start > longest {
break;
}
h = fnv(h, &s.as_bytes()[end..next]);
end = next;
if let Some(id) = self.id(h, &s[start..end]) {
let score = base + self.scores[id as usize];
let t = &mut best[end];
if t.1.is_none() || score > t.0 {
*t = (score, Some(start), id);
}
single |= end - start == first;
}
}
if !single {
let score = base + unk_score;
let t = &mut best[start + first];
if t.1.is_none() || score > t.0 {
*t = (score, Some(start), self.unk);
}
}
start += first;
}
let mut ids = Vec::new();
let mut end = s.len();
while end > 0 {
let (_, Some(from), id) = best[end] else { break };
if !(id == self.unk && ids.last() == Some(&self.unk)) {
ids.push(id);
}
end = from;
}
out.extend(ids.into_iter().rev());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny() -> String {
serde_json::json!({
"normalizer": null,
"pre_tokenizer": {"type": "Metaspace", "replacement": "\u{2581}", "prepend_scheme": "always", "split": false},
"model": {"type": "Unigram", "unk_id": 0, "byte_fallback": false, "vocab": [
["<unk>", 0.0], ["\u{2581}", -2.0], ["a", -3.0], ["b", -3.0], ["ab", -2.5], ["\u{2581}ab", -1.0], ["c", -3.0], ["c", -3.5]
]}
})
.to_string()
}
#[test]
fn best_path_and_fused_unknowns_match_tokenizers() {
let json = tiny();
let ours = Unigram::parse(&json).unwrap();
let theirs: tokenizers::Tokenizer = json.parse().unwrap();
for text in ["ab", "abab", "ab xyz ab", "c", "zzab", "a b ab"] {
let want = theirs.encode_fast(text, false).unwrap().get_ids().to_vec();
assert_eq!(ours.ids(text).unwrap(), want, "{text}");
}
}
#[test]
fn other_models_are_left_to_tokenizers() {
let json = tiny().replace("\"Unigram\"", "\"BPE\"");
assert!(Unigram::parse(&json).is_none());
}
}