use base64::{engine::general_purpose::STANDARD, Engine};
use rustc_hash::FxHashMap;
use thiserror::Error;
use super::token_bytes::{Decoder, Encoder, TokenBytes};
pub type EncoderDecoderPair = (FxHashMap<Vec<u8>, u32>, FxHashMap<u32, Vec<u8>>);
#[derive(Error, Debug)]
pub enum VocabError {
#[error("Invalid base64 encoding: {0}")]
Base64Error(#[from] base64::DecodeError),
#[error("Invalid line format: {0}")]
ParseError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Vocabulary is empty")]
EmptyVocab,
#[error("Special token {name:?} claims id {id}, which the vocabulary spells {found:?}")]
SpecialTokenConflict {
id: u32,
name: String,
found: String,
},
#[error("SentencePiece vocabulary line for id {id} has no space separating piece from score")]
SpmMissingScore { id: u32 },
#[error("SentencePiece piece for id {id} is not valid base64: {source}")]
SpmBase64 {
id: u32,
source: base64::DecodeError,
},
#[error("SentencePiece score for id {id} is not a number: {value:?}")]
SpmScore { id: u32, value: String },
#[error("SentencePiece piece for id {id} is not valid UTF-8")]
SpmNonUtf8 { id: u32 },
}
pub fn load_spm_vocab(data: &[u8]) -> Result<(Vec<String>, Vec<f32>), VocabError> {
let mut pieces = Vec::new();
let mut scores = Vec::new();
for line in data.split(|&b| b == b'\n') {
let line = match line.strip_suffix(b"\r") {
Some(stripped) => stripped,
None => line,
};
if line.is_empty() {
continue;
}
let id = pieces.len() as u32;
let space = line
.iter()
.rposition(|&b| b == b' ')
.ok_or(VocabError::SpmMissingScore { id })?;
let (Some(piece_b64), Some(score_bytes)) = (line.get(..space), line.get(space + 1..))
else {
return Err(VocabError::SpmMissingScore { id });
};
let bytes = STANDARD
.decode(piece_b64)
.map_err(|source| VocabError::SpmBase64 { id, source })?;
let piece = String::from_utf8(bytes).map_err(|_| VocabError::SpmNonUtf8 { id })?;
let score_str = std::str::from_utf8(score_bytes)
.map_err(|_| VocabError::SpmScore {
id,
value: String::from_utf8_lossy(score_bytes).into_owned(),
})?
.trim();
let score: f32 = score_str.parse().map_err(|_| VocabError::SpmScore {
id,
value: score_str.to_string(),
})?;
pieces.push(piece);
scores.push(score);
}
if pieces.is_empty() {
return Err(VocabError::EmptyVocab);
}
Ok((pieces, scores))
}
pub fn load_tiktoken_bpe(data: &[u8]) -> Result<FxHashMap<Vec<u8>, u32>, VocabError> {
let mut encoder = FxHashMap::default();
for line in data.split(|&b| b == b'\n') {
if line.is_empty() {
continue;
}
let space_pos = line
.iter()
.rposition(|&b| b == b' ')
.ok_or_else(|| VocabError::ParseError("Missing space separator".to_string()))?;
let token_b64 = &line[..space_pos];
let rank_str = &line[space_pos + 1..];
let token = STANDARD.decode(token_b64)?;
let rank_str = std::str::from_utf8(rank_str)
.map_err(|_| VocabError::ParseError("Invalid UTF-8 in rank".to_string()))?;
let rank: u32 = rank_str
.trim()
.parse()
.map_err(|_| VocabError::ParseError(format!("Invalid rank: {}", rank_str)))?;
encoder.insert(token, rank);
}
Ok(encoder)
}
const PACKED_MAGIC: &[u8; 8] = b"SPLNTRV1";
pub fn load_packed_bpe(data: &[u8]) -> Result<Encoder, VocabError> {
let count = packed_header(data)?;
let mut encoder = Encoder::with_capacity_and_hasher(count, rustc_hash::FxBuildHasher);
walk_packed(data, count, |token, rank| {
encoder.insert(TokenBytes::from(token.to_vec()), rank);
})?;
Ok(encoder)
}
pub fn load_packed_bpe_borrowed(data: &'static [u8]) -> Result<Encoder, VocabError> {
let count = packed_header(data)?;
let mut encoder = Encoder::with_capacity_and_hasher(count, rustc_hash::FxBuildHasher);
walk_packed(data, count, |token, rank| {
encoder.insert(TokenBytes::Static(token), rank);
})?;
Ok(encoder)
}
fn packed_header(data: &[u8]) -> Result<usize, VocabError> {
if data.len() < 12 || &data[..8] != PACKED_MAGIC {
return Err(VocabError::ParseError(
"not a packed vocabulary: bad magic".to_string(),
));
}
let count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
if count == 0 {
return Err(VocabError::EmptyVocab);
}
Ok(count)
}
fn walk_packed<'a>(
data: &'a [u8],
count: usize,
mut visit: impl FnMut(&'a [u8], u32),
) -> Result<(), VocabError> {
let mut pos = 12;
for _ in 0..count {
let rank = read_varint(data, &mut pos)?;
let len = read_varint(data, &mut pos)? as usize;
let end = pos.checked_add(len).ok_or_else(|| {
VocabError::ParseError("packed vocabulary: token length overflows".to_string())
})?;
if end > data.len() {
return Err(VocabError::ParseError(
"packed vocabulary: token runs past end of data".to_string(),
));
}
visit(&data[pos..end], rank);
pos = end;
}
Ok(())
}
fn read_varint(data: &[u8], pos: &mut usize) -> Result<u32, VocabError> {
let mut value: u32 = 0;
for group in 0..5 {
let byte = *data.get(*pos).ok_or_else(|| {
VocabError::ParseError("packed vocabulary: truncated varint".to_string())
})?;
*pos += 1;
value |= u32::from(byte & 0x7F)
.checked_shl(group * 7)
.ok_or_else(|| {
VocabError::ParseError("packed vocabulary: varint too wide".to_string())
})?;
if byte & 0x80 == 0 {
return Ok(value);
}
}
Err(VocabError::ParseError(
"packed vocabulary: varint too wide".to_string(),
))
}
pub fn load_tiktoken_bpe_file(path: &str) -> Result<FxHashMap<Vec<u8>, u32>, VocabError> {
let data = std::fs::read(path)?;
load_tiktoken_bpe(&data)
}
pub fn load_tiktoken_bpe_with_decoder(data: &[u8]) -> Result<EncoderDecoderPair, VocabError> {
let mut encoder = FxHashMap::default();
let mut decoder = FxHashMap::default();
for line in data.split(|&b| b == b'\n') {
if line.is_empty() {
continue;
}
let space_pos = line
.iter()
.rposition(|&b| b == b' ')
.ok_or_else(|| VocabError::ParseError("Missing space separator".to_string()))?;
let token_b64 = &line[..space_pos];
let rank_str = &line[space_pos + 1..];
let token = STANDARD.decode(token_b64)?;
let rank_str = std::str::from_utf8(rank_str)
.map_err(|_| VocabError::ParseError("Invalid UTF-8 in rank".to_string()))?;
let rank: u32 = rank_str
.trim()
.parse()
.map_err(|_| VocabError::ParseError(format!("Invalid rank: {}", rank_str)))?;
decoder.insert(rank, token.clone());
encoder.entry(token).or_insert(rank);
}
Ok((encoder, decoder))
}
pub fn place_special_pieces(
pieces: &mut Vec<String>,
special: &FxHashMap<String, u32>,
) -> Result<(), VocabError> {
let Some(&max_id) = special.values().max() else {
return Ok(());
};
if pieces.len() <= max_id as usize {
pieces.resize(max_id as usize + 1, String::new());
}
for (name, &id) in special {
let Some(slot) = pieces.get_mut(id as usize) else {
continue;
};
if slot.is_empty() {
*slot = name.clone();
} else if slot.as_str() != name.as_str() {
return Err(VocabError::SpecialTokenConflict {
id,
name: name.clone(),
found: slot.clone(),
});
}
}
Ok(())
}
pub fn build_decoder(encoder: &Encoder) -> Decoder {
encoder.iter().map(|(k, v)| (*v, k.clone())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_tiktoken_bpe() {
let data = b"SGVsbG8= 0\nV29ybGQ= 1\n";
let encoder = load_tiktoken_bpe(data).unwrap();
assert_eq!(encoder.get(b"Hello".as_slice()), Some(&0));
assert_eq!(encoder.get(b"World".as_slice()), Some(&1));
assert_eq!(encoder.len(), 2);
}
#[test]
fn place_special_pieces_grows_the_list_to_cover_added_ids() {
let mut pieces = vec!["<unk>".to_string(), "a".to_string()];
let mut special = FxHashMap::default();
special.insert("<unk>".to_string(), 0);
special.insert("<|pad|>".to_string(), 4);
place_special_pieces(&mut pieces, &special).unwrap();
assert_eq!(pieces.len(), 5);
assert_eq!(pieces[4], "<|pad|>");
assert_eq!(pieces[2], "");
assert_eq!(pieces[3], "");
}
#[test]
fn place_special_pieces_reports_a_claimed_id_that_holds_another_token() {
let mut pieces = vec!["<unk>".to_string(), "▁the".to_string()];
let mut special = FxHashMap::default();
special.insert("<|im_start|>".to_string(), 1);
assert!(matches!(
place_special_pieces(&mut pieces, &special),
Err(VocabError::SpecialTokenConflict { id: 1, .. })
));
}
fn spm_blob(entries: &[(&str, &str)]) -> Vec<u8> {
let mut out = Vec::new();
for (piece, score) in entries {
out.extend_from_slice(STANDARD.encode(piece.as_bytes()).as_bytes());
out.extend_from_slice(format!(" {score}\n").as_bytes());
}
out
}
#[test]
fn spm_vocab_keeps_piece_spelling_and_scores() {
let data = spm_blob(&[
("<unk>", "0.0"),
("<0x41>", "0.0"),
("▁the", "-31.0"),
("▁▁", "-1000000000.0"),
]);
let (pieces, scores) = load_spm_vocab(&data).unwrap();
assert_eq!(pieces, vec!["<unk>", "<0x41>", "▁the", "▁▁"]);
assert_eq!(scores, vec![0.0, 0.0, -31.0, -1e9]);
}
#[test]
fn spm_vocab_parses_the_never_merge_sentinel_exactly() {
let data = spm_blob(&[("▁", "-1000000000.0")]);
let (_, scores) = load_spm_vocab(&data).unwrap();
assert_eq!(scores.first().copied(), Some(-1e9f32));
assert_eq!(
scores.first().map(|s| s.to_bits()),
Some((-1e9f32).to_bits())
);
}
#[test]
fn spm_vocab_ignores_blank_and_carriage_return_line_endings() {
let mut data = spm_blob(&[("a", "-1.0"), ("b", "-2.0")]);
data.extend_from_slice(b"\n");
let (pieces, _) = load_spm_vocab(&data).unwrap();
assert_eq!(pieces, vec!["a", "b"]);
let crlf = b"YQ== -1.0\r\nYg== -2.0\r\n";
let (pieces, scores) = load_spm_vocab(crlf).unwrap();
assert_eq!(pieces, vec!["a", "b"]);
assert_eq!(scores, vec![-1.0, -2.0]);
}
#[test]
fn spm_vocab_rejects_a_line_without_a_score() {
assert!(matches!(
load_spm_vocab(b"YQ== -1.0\nYg==\n"),
Err(VocabError::SpmMissingScore { id: 1 })
));
}
#[test]
fn spm_vocab_reports_malformed_fields_with_their_id() {
assert!(matches!(
load_spm_vocab(b"YQ== -1.0\n!!!! -2.0\n"),
Err(VocabError::SpmBase64 { id: 1, .. })
));
assert!(matches!(
load_spm_vocab(b"YQ== -1.0\nloE= -2.0\n"),
Err(VocabError::SpmNonUtf8 { id: 1 })
));
assert!(matches!(
load_spm_vocab(b"YQ== -1.0\nYg== rank\n"),
Err(VocabError::SpmScore { id: 1, .. })
));
}
#[test]
fn spm_vocab_rejects_a_tiktoken_file() {
let mut data = Vec::new();
data.extend_from_slice(STANDARD.encode([0x80u8]).as_bytes());
data.extend_from_slice(b" 0\n");
assert!(matches!(
load_spm_vocab(&data),
Err(VocabError::SpmNonUtf8 { id: 0 })
));
}
#[test]
fn spm_vocab_rejects_empty_data() {
assert!(matches!(load_spm_vocab(b""), Err(VocabError::EmptyVocab)));
}
#[test]
fn test_build_decoder() {
let mut encoder = Encoder::default();
encoder.insert(TokenBytes::from(b"Hello".to_vec()), 0);
encoder.insert(TokenBytes::from(b"World".to_vec()), 1);
let decoder = build_decoder(&encoder);
assert_eq!(
decoder.get(&0).map(TokenBytes::as_slice),
Some(&b"Hello"[..])
);
assert_eq!(
decoder.get(&1).map(TokenBytes::as_slice),
Some(&b"World"[..])
);
}
}