use super::unicode;
use super::{load_special_tokens, split_on_special_tokens, TextOrSpecial, TokenizerLoadError};
use std::collections::HashMap;
const PHANTOM_SPACE: &str = "\u{2581}";
const DEFAULT_UNK_ID: u32 = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NormalizerOptions {
pub lowercase: bool,
pub strip_accents: bool,
}
impl NormalizerOptions {
fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Self {
let lowercase = file
.metadata_bool("tokenizer.ggml.normalizer.lowercase")
.unwrap_or(true);
let strip_accents = file
.metadata_bool("tokenizer.ggml.normalizer.strip_accents")
.unwrap_or(lowercase);
NormalizerOptions {
lowercase,
strip_accents,
}
}
}
pub struct GgufWordPieceTokenizer {
token_to_id: HashMap<Vec<u8>, u32>,
id_to_token: Vec<String>,
max_token_len: usize,
unk_id: u32,
normalizer: NormalizerOptions,
special_tokens: Vec<(String, u32)>,
}
impl GgufWordPieceTokenizer {
pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Result<Self, TokenizerLoadError> {
let tokens_value = file
.metadata("tokenizer.ggml.tokens")
.ok_or(TokenizerLoadError::MissingTokens)?;
let id_to_token: Vec<String> = match tokens_value {
ferrox_gguf::GgufValue::Array(items) => items
.iter()
.map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Option<Vec<_>>>()
.ok_or(TokenizerLoadError::TokensNotStringArray)?,
_ => return Err(TokenizerLoadError::TokensNotStringArray),
};
let mut token_to_id: HashMap<Vec<u8>, u32> = HashMap::with_capacity(id_to_token.len());
for (i, text) in id_to_token.iter().enumerate() {
token_to_id
.entry(text.as_bytes().to_vec())
.or_insert(i as u32);
}
let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap_or(0);
let unk_id = file
.metadata("tokenizer.ggml.unknown_token_id")
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(DEFAULT_UNK_ID);
let special_tokens = load_special_tokens(file, &id_to_token);
Ok(GgufWordPieceTokenizer {
token_to_id,
id_to_token,
max_token_len,
unk_id,
normalizer: NormalizerOptions::from_gguf(file),
special_tokens,
})
}
pub fn vocab_size(&self) -> usize {
self.id_to_token.len()
}
pub fn normalizer(&self) -> NormalizerOptions {
self.normalizer
}
pub fn encode(&self, text: &str) -> Vec<u32> {
let mut out = Vec::new();
for seg in split_on_special_tokens(text, &self.special_tokens) {
match seg {
TextOrSpecial::Special(id) => out.push(id),
TextOrSpecial::Text(t) => self.encode_normal_run(t, &mut out),
}
}
out
}
fn encode_normal_run(&self, text: &str, out: &mut Vec<u32>) {
for word in preprocess(text, self.normalizer) {
if word.is_empty() {
continue;
}
let word1 = format!("{PHANTOM_SPACE}{word}");
let bytes = word1.as_bytes();
let n = bytes.len();
let start = out.len();
let mut i = 0usize;
while i < n {
let mut j = n.min(i + self.max_token_len + 1);
let mut matched = false;
while j > i {
if let Some(&id) = self.token_to_id.get(&bytes[i..j]) {
out.push(id);
i = j;
matched = true;
break;
}
j -= 1;
}
if !matched {
out.truncate(start);
break;
}
}
if out.len() == start {
out.push(self.unk_id);
}
}
}
pub fn decode(&self, ids: &[u32]) -> String {
let mut out = String::new();
for &id in ids {
if let Some(token) = self.id_to_token.get(id as usize) {
out.push_str(&token.replace(PHANTOM_SPACE, " "));
}
}
out
}
}
fn is_chinese_char(c: char) -> bool {
let cpt = c as u32;
(0x04E00..=0x09FFF).contains(&cpt)
|| (0x03400..=0x04DBF).contains(&cpt)
|| (0x20000..=0x2A6DF).contains(&cpt)
|| (0x2A700..=0x2B73F).contains(&cpt)
|| (0x2B740..=0x2B81F).contains(&cpt)
|| (0x2B920..=0x2CEAF).contains(&cpt)
|| (0x0F900..=0x0FAFF).contains(&cpt)
|| (0x2F800..=0x2FA1F).contains(&cpt)
}
fn preprocess(text: &str, opts: NormalizerOptions) -> Vec<String> {
let mut words: Vec<String> = vec![String::new()];
for raw in text.chars() {
let c = if opts.strip_accents {
unicode::nfd_base(raw)
} else {
raw
};
if unicode::is_whitespace(c) {
if !words.last().is_some_and(String::is_empty) {
words.push(String::new());
}
continue;
}
let flags = unicode::flags(c);
debug_assert!(
!flags.is_separator(),
"every \\p{{Z}} codepoint is also White_Space, so the check above \
should have consumed it: {c:?}"
);
if c == '\0' || c == '\u{fffd}' || flags.is_control() {
continue;
}
if opts.strip_accents && flags.is_accent_mark() {
continue;
}
let c = if opts.lowercase {
unicode::to_lower(c)
} else {
c
};
if flags.is_punctuation() || ((c as u32) < 0x7F && flags.is_symbol()) || is_chinese_char(c)
{
if !words.last().is_some_and(String::is_empty) {
words.push(String::new());
}
words.last_mut().expect("just pushed or non-empty").push(c);
words.push(String::new());
} else {
words.last_mut().expect("seeded with one word").push(c);
}
}
if words.last().is_some_and(String::is_empty) {
words.pop();
}
words
}
#[cfg(test)]
mod tests {
use super::*;
const BOTH: NormalizerOptions = NormalizerOptions {
lowercase: true,
strip_accents: true,
};
const NEITHER: NormalizerOptions = NormalizerOptions {
lowercase: false,
strip_accents: false,
};
fn words(text: &str, opts: NormalizerOptions) -> Vec<String> {
preprocess(text, opts)
}
#[test]
fn whitespace_splits_words_and_is_dropped() {
assert_eq!(words("hello world", BOTH), ["hello", "world"]);
assert_eq!(words(" hello world ", BOTH), ["hello", "world"]);
assert_eq!(words("", BOTH), Vec::<String>::new());
assert_eq!(words(" ", BOTH), Vec::<String>::new());
}
#[test]
fn unicode_spaces_break_words_but_zero_width_ones_vanish() {
assert_eq!(
words("a\u{a0}b\u{3000}c\u{2009}d\u{200b}e", BOTH),
["a", "b", "c", "de"]
);
}
#[test]
fn punctuation_and_ascii_symbols_become_single_character_words() {
assert_eq!(words("don't.", BOTH), ["don", "'", "t", "."]);
assert_eq!(words("a+b", BOTH), ["a", "+", "b"]);
assert_eq!(words("1+2=3", BOTH), ["1", "+", "2", "=", "3"]);
}
#[test]
fn non_ascii_symbols_do_not_split() {
assert_eq!(words("a€b", BOTH), ["a€b"]);
assert_eq!(words("a$b", BOTH), ["a", "$", "b"]);
}
#[test]
fn cjk_characters_each_get_their_own_word() {
assert_eq!(words("日本語", BOTH), ["日", "本", "語"]);
assert_eq!(words("a日b", BOTH), ["a", "日", "b"]);
}
#[test]
fn hangul_is_folded_to_its_leading_jamo_by_the_accent_pass() {
assert_eq!(words("서울", BOTH), ["\u{1109}\u{110b}"]);
assert_eq!(words("서울", NEITHER), ["서울"], "only the fold does this");
}
#[test]
fn controls_are_dropped_without_breaking_the_word() {
assert_eq!(words("a\u{1b}b", BOTH), ["ab"], "ESC is Cc");
assert_eq!(words("a\u{ad}b", BOTH), ["ab"], "soft hyphen is Cf");
assert_eq!(words("a\u{fffd}b", BOTH), ["ab"], "the replacement char");
assert_eq!(words("a\0b", BOTH), ["ab"], "NUL");
}
#[test]
fn lowercase_and_accent_stripping_follow_their_flags() {
assert_eq!(words("Café", BOTH), ["cafe"]);
assert_eq!(words("Café", NEITHER), ["Café"]);
assert_eq!(
words(
"Café",
NormalizerOptions {
lowercase: true,
strip_accents: false
}
),
["café"]
);
assert_eq!(
words(
"Café",
NormalizerOptions {
lowercase: false,
strip_accents: true
}
),
["Cafe"]
);
}
#[test]
fn precomposed_and_decomposed_accents_normalize_alike() {
assert_eq!(words("café", BOTH), words("cafe\u{301}", BOTH));
assert_eq!(words("cafe\u{301}", BOTH), ["cafe"]);
}
fn toy() -> GgufWordPieceTokenizer {
let pieces = [
"[PAD]", "[UNK]", "[CLS]", "[SEP]", "▁un", "▁hello", "▁.", "▁world", "aff", "able", "ing", "▁unaff", ];
let id_to_token: Vec<String> = pieces.iter().map(|s| s.to_string()).collect();
let mut token_to_id = HashMap::new();
for (i, t) in id_to_token.iter().enumerate() {
token_to_id.entry(t.as_bytes().to_vec()).or_insert(i as u32);
}
let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap();
GgufWordPieceTokenizer {
token_to_id,
id_to_token,
max_token_len,
unk_id: 1,
normalizer: BOTH,
special_tokens: vec![("[CLS]".to_string(), 2), ("[SEP]".to_string(), 3)],
}
}
#[test]
fn a_word_is_covered_word_initial_piece_first_then_continuations() {
let t = toy();
assert_eq!(t.encode("unable"), [4, 9]);
assert_eq!(t.encode("hello"), [5]);
}
#[test]
fn the_longest_match_wins_where_a_shorter_one_would_also_cover() {
let t = toy();
assert_eq!(t.encode("unaffable"), [11, 9], "▁unaff + able");
assert_ne!(
t.encode("unaffable"),
[4, 8, 9],
"▁un + aff + able is the shortest-first answer"
);
}
#[test]
fn a_continuation_piece_cannot_start_a_word() {
let t = toy();
assert_eq!(t.encode("aff"), [1], "no ▁aff, so [UNK]");
}
#[test]
fn a_partly_covered_word_discards_its_pieces_and_becomes_one_unknown() {
let t = toy();
assert_eq!(
t.encode("worlds"),
[1],
"a partial cover must be discarded, not kept"
);
assert_eq!(t.encode("world"), [7]);
}
#[test]
fn each_word_falls_back_independently() {
let t = toy();
assert_eq!(t.encode("hello zzz world"), [5, 1, 7]);
}
#[test]
fn punctuation_is_its_own_word_and_matches_its_own_piece() {
let t = toy();
assert_eq!(t.encode("hello."), [5, 6], "▁hello then ▁.");
}
#[test]
fn special_tokens_are_carved_out_before_normalization() {
let t = toy();
assert_eq!(t.encode("[CLS]hello[SEP]"), [2, 5, 3]);
}
#[test]
fn decode_reverses_the_phantom_space() {
let t = toy();
assert_eq!(t.decode(&[4, 8, 9]), " unaffable");
assert_eq!(t.decode(&[5, 7]), " hello world");
assert_eq!(t.decode(&[2]), "[CLS]");
}
#[test]
fn the_probe_cap_is_the_longest_piece_in_bytes() {
let t = toy();
assert_eq!(t.max_token_len, "▁hello".len(), "6 bytes, not 6 chars");
}
}