use base64::{engine::general_purpose::STANDARD, Engine};
use rustc_hash::{FxHashMap, FxHashSet};
use thiserror::Error;
use super::decode_table::Decoder;
use super::encoder::Encoder;
const USER_DEFINED_PIECE_TYPE: u32 = 4;
#[derive(Debug, Clone)]
pub struct SpmVocab {
pub pieces: Vec<String>,
pub scores: Vec<f32>,
pub user_defined: Vec<bool>,
}
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 type for id {id} is not a number: {value:?}")]
SpmPieceType { 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<SpmVocab, VocabError> {
let mut pieces = Vec::new();
let mut scores = Vec::new();
let mut user_defined = 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()
.position(|&b| b == b' ')
.ok_or(VocabError::SpmMissingScore { id })?;
let (Some(piece_b64), Some(rest)) = (line.get(..space), line.get(space + 1..)) else {
return Err(VocabError::SpmMissingScore { id });
};
let (score_bytes, type_bytes) = match rest.iter().position(|&b| b == b' ') {
Some(at) => (&rest[..at], Some(&rest[at + 1..])),
None => (rest, None),
};
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(),
})?;
let is_user_defined = match type_bytes {
Some(bytes) => {
let text = std::str::from_utf8(bytes)
.map_err(|_| VocabError::SpmPieceType {
id,
value: String::from_utf8_lossy(bytes).into_owned(),
})?
.trim();
let value: u32 = text.parse().map_err(|_| VocabError::SpmPieceType {
id,
value: text.to_string(),
})?;
value == USER_DEFINED_PIECE_TYPE
}
None => false,
};
pieces.push(piece);
scores.push(score);
user_defined.push(is_user_defined);
}
if pieces.is_empty() {
return Err(VocabError::EmptyVocab);
}
Ok(SpmVocab {
pieces,
scores,
user_defined,
})
}
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)?;
packed_into_arena(data, count, &FxHashSet::default())
}
pub fn load_packed_bpe_borrowed(data: &'static [u8]) -> Result<Encoder, VocabError> {
let count = packed_header(data)?;
packed_into_arena(data, count, &FxHashSet::default())
}
pub fn load_packed_bpe_without(
data: &'static [u8],
skip: &FxHashSet<u32>,
) -> Result<Encoder, VocabError> {
let count = packed_header(data)?;
packed_into_arena(data, count, skip)
}
pub fn decoder_from_packed(data: &[u8]) -> Result<Decoder, VocabError> {
let count = packed_header(data)?;
let mut decoder = Decoder::with_capacity(count);
walk_packed(data, count, |token, id| decoder.insert(id, token))?;
Ok(decoder)
}
fn packed_into_arena(
data: &[u8],
count: usize,
skip: &FxHashSet<u32>,
) -> Result<Encoder, VocabError> {
let mut encoder = Encoder::with_arena(data.to_vec(), count - skip.len().min(count));
let base = data.as_ptr() as usize;
walk_packed(data, count, |token, rank| {
if skip.contains(&rank) {
return;
}
let offset = (token.as_ptr() as usize - base) as u32;
encoder.insert_span(offset, token.len() as u32, 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(())
}
const PACKED_MERGES_MAGIC: &[u8; 8] = b"SPLNTRM1";
#[derive(Clone, Copy, Debug)]
pub struct MergeRule<'a> {
pub result: &'a [u8],
pub split: usize,
}
impl<'a> MergeRule<'a> {
pub fn operands(&self) -> (&'a [u8], &'a [u8]) {
self.result.split_at(self.split)
}
}
pub fn load_packed_merge_rules<'a>(
data: &'a [u8],
pieces: &'a Decoder,
) -> Result<Vec<MergeRule<'a>>, VocabError> {
if data.len() < 12 || &data[..8] != PACKED_MERGES_MAGIC {
return Err(VocabError::ParseError(
"not a packed merge list: 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::ParseError("no merges".to_string()));
}
let mut rules: Vec<MergeRule<'_>> = Vec::with_capacity(count);
let mut pos = 12;
for _ in 0..count {
let id = read_varint(data, &mut pos)?;
let split = read_varint(data, &mut pos)? as usize;
let result = pieces.get(id).ok_or_else(|| {
VocabError::ParseError(format!(
"packed merge list: id {id} is not in the vocabulary — the two \
were not packed from the same file"
))
})?;
if split == 0 || split >= result.len() {
return Err(VocabError::ParseError(format!(
"packed merge list: id {id} splits at {split}, which leaves an \
empty half of a {}-byte token",
result.len()
)));
}
rules.push(MergeRule { result, split });
}
Ok(rules)
}
pub fn orphan_ids(
pieces: &Decoder,
rules: &[MergeRule<'_>],
ranks: &crate::core::encoder::Encoder,
) -> FxHashSet<u32> {
let mut reachable: rustc_hash::FxHashMap<&[u8], usize> = rustc_hash::FxHashMap::default();
reachable.reserve(rules.len());
for rule in rules {
reachable.insert(rule.result, rule.split);
}
let unreachable = |bytes: &[u8]| -> bool {
if bytes.iter().filter(|b| !is_utf8_tail(**b)).count() <= 1 {
return false;
}
if is_byte_fallback_piece(bytes) {
return true;
}
let Some(&split) = reachable.get(bytes) else {
return true;
};
let symbols = |b: &[u8]| b.iter().filter(|b| !is_utf8_tail(**b)).count();
let (left, right) = MergeRule {
result: bytes,
split,
}
.operands();
if symbols(left) == 1 && symbols(right) == 1 {
return false;
}
!crate::core::bpe::merges_to_whole(bytes, crate::core::bpe::RankLookup::new(ranks), true)
};
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let entries: Vec<(u32, &[u8])> = pieces.iter().collect();
entries
.par_iter()
.filter(|(_, bytes)| unreachable(bytes))
.map(|(id, _)| *id)
.collect::<Vec<u32>>()
.into_iter()
.collect()
}
#[cfg(not(feature = "rayon"))]
{
pieces
.iter()
.filter(|(_, bytes)| unreachable(bytes))
.map(|(id, _)| id)
.collect()
}
}
pub(crate) fn is_byte_fallback_piece(token: &[u8]) -> bool {
token.len() == 6
&& token.starts_with(b"<0x")
&& token[5] == b'>'
&& token[3..5].iter().all(u8::is_ascii_hexdigit)
}
fn is_utf8_tail(byte: u8) -> bool {
byte & 0xC0 == 0x80
}
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 {
Decoder::from_encoder(encoder)
}
#[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
}
fn spm_typed_blob(entries: &[(&str, &str, u32)]) -> Vec<u8> {
let mut out = Vec::new();
for (piece, score, kind) in entries {
out.extend_from_slice(STANDARD.encode(piece.as_bytes()).as_bytes());
out.extend_from_slice(format!(" {score} {kind}\n").as_bytes());
}
out
}
#[test]
fn spm_vocab_separates_user_defined_from_control() {
let data = spm_typed_blob(&[
("<pad>", "0.0", 3), ("<unk>", "0.0", 2), ("<0x41>", "0.0", 6), ("<blockquote>", "0.0", 4), ("▁the", "-31.0", 1), ]);
let v = load_spm_vocab(&data).unwrap();
assert_eq!(v.pieces.len(), 5);
assert_eq!(
v.user_defined,
vec![false, false, false, true, false],
"only the USER_DEFINED piece may be flagged — CONTROL and BYTE \
score 0.0 as well and are spelled the same way"
);
assert_eq!(v.scores, vec![0.0, 0.0, 0.0, 0.0, -31.0]);
}
#[test]
fn spm_vocab_rejects_an_unparseable_piece_type() {
let data = b"PHBhZD4= 0.0 control\n".to_vec();
assert!(matches!(
load_spm_vocab(&data),
Err(VocabError::SpmPieceType { id: 0, .. })
));
}
#[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 v = load_spm_vocab(&data).unwrap();
assert_eq!(v.pieces, vec!["<unk>", "<0x41>", "▁the", "▁▁"]);
assert_eq!(v.scores, vec![0.0, 0.0, -31.0, -1e9]);
assert_eq!(v.user_defined, vec![false; 4]);
}
#[test]
fn spm_vocab_parses_the_never_merge_sentinel_exactly() {
let data = spm_blob(&[("▁", "-1000000000.0")]);
let scores = load_spm_vocab(&data).unwrap().scores;
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");
assert_eq!(load_spm_vocab(&data).unwrap().pieces, vec!["a", "b"]);
let crlf = b"YQ== -1.0\r\nYg== -2.0\r\n";
let v = load_spm_vocab(crlf).unwrap();
assert_eq!(v.pieces, vec!["a", "b"]);
assert_eq!(v.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(b"Hello", 0);
encoder.insert(b"World", 1);
let decoder = build_decoder(&encoder);
assert_eq!(decoder.get(0), Some(&b"Hello"[..]));
assert_eq!(decoder.get(1), Some(&b"World"[..]));
}
}