use std::collections::HashMap;
use super::TokenizerLoadError;
pub(crate) struct ScoredVocab {
id_to_token: Vec<String>,
token_to_id: HashMap<String, u32>,
scores: Vec<f32>,
}
impl ScoredVocab {
pub(crate) 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),
};
if id_to_token.is_empty() {
return Err(TokenizerLoadError::EmptyVocabulary);
}
let scores: Vec<f32> = match file.metadata("tokenizer.ggml.scores") {
Some(ferrox_gguf::GgufValue::Array(items)) => {
items.iter().map(|v| v.as_f32().unwrap_or(0.0)).collect()
}
_ => vec![0.0; id_to_token.len()],
};
if scores.len() != id_to_token.len() {
return Err(TokenizerLoadError::ScoresVocabLengthMismatch {
tokens: id_to_token.len(),
scores: scores.len(),
});
}
let token_to_id: HashMap<String, u32> = id_to_token
.iter()
.enumerate()
.map(|(i, t)| (t.clone(), i as u32))
.collect();
Ok(ScoredVocab {
id_to_token,
token_to_id,
scores,
})
}
pub(crate) fn len(&self) -> usize {
self.id_to_token.len()
}
pub(crate) fn tokens(&self) -> &[String] {
&self.id_to_token
}
pub(crate) fn id_of(&self, piece: &str) -> Option<u32> {
self.token_to_id.get(piece).copied()
}
pub(crate) fn token(&self, id: u32) -> Option<&str> {
self.id_to_token.get(id as usize).map(String::as_str)
}
pub(crate) fn lookup(&self, piece: &str) -> Option<(u32, f32)> {
let id = self.id_of(piece)?;
Some((id, self.scores[id as usize]))
}
pub(crate) fn min_score(&self) -> f32 {
self.scores.iter().copied().fold(f32::INFINITY, f32::min)
}
}
#[cfg(test)]
pub(crate) struct MetadataOnlyGguf {
meta: HashMap<String, ferrox_gguf::GgufValue>,
}
#[cfg(test)]
impl MetadataOnlyGguf {
pub(crate) fn new() -> Self {
MetadataOnlyGguf {
meta: HashMap::new(),
}
}
pub(crate) fn with(mut self, key: &str, value: ferrox_gguf::GgufValue) -> Self {
self.meta.insert(key.to_string(), value);
self
}
pub(crate) fn with_tokens(self, tokens: &[&str]) -> Self {
let items = tokens
.iter()
.map(|t| ferrox_gguf::GgufValue::String((*t).to_string()))
.collect();
self.with(
"tokenizer.ggml.tokens",
ferrox_gguf::GgufValue::Array(items),
)
}
pub(crate) fn with_scores(self, scores: &[f32]) -> Self {
let items = scores
.iter()
.map(|s| ferrox_gguf::GgufValue::F32(*s))
.collect();
self.with(
"tokenizer.ggml.scores",
ferrox_gguf::GgufValue::Array(items),
)
}
}
#[cfg(test)]
impl ferrox_gguf::TensorSource for MetadataOnlyGguf {
fn metadata(&self, key: &str) -> Option<&ferrox_gguf::GgufValue> {
self.meta.get(key)
}
fn find_tensor(&self, _name: &str) -> Option<&ferrox_gguf::TensorInfo> {
None
}
fn tensor_bytes(&self, name: &str) -> Result<&[u8], ferrox_gguf::GgufError> {
Err(ferrox_gguf::GgufError::TensorNotFound(name.to_string()))
}
fn tensor_mapped_range(
&self,
name: &str,
) -> Result<
(
std::sync::Arc<ferrox_gguf::MmapHandle>,
std::ops::Range<usize>,
),
ferrox_gguf::GgufError,
> {
Err(ferrox_gguf::GgufError::TensorNotFound(name.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn short_scores_file() -> MetadataOnlyGguf {
let tokens: Vec<String> = (0..100).map(|i| format!("piece{i}")).collect();
let refs: Vec<&str> = tokens.iter().map(String::as_str).collect();
MetadataOnlyGguf::new()
.with_tokens(&refs)
.with_scores(&[-1.0, -2.0, -3.0])
}
#[test]
fn a_scores_array_shorter_than_the_vocabulary_is_refused_with_both_lengths_named() {
let err = ScoredVocab::from_gguf(&short_scores_file())
.err()
.expect("a 100-token vocabulary with 3 scores must not load");
assert!(
matches!(
err,
TokenizerLoadError::ScoresVocabLengthMismatch {
tokens: 100,
scores: 3
}
),
"err={err:?}"
);
let text = err.to_string();
assert!(text.contains("100") && text.contains('3'), "text={text}");
}
#[test]
fn a_scores_array_longer_than_the_vocabulary_is_refused_too() {
let file = MetadataOnlyGguf::new()
.with_tokens(&["a", "b"])
.with_scores(&[-1.0, -2.0, -3.0]);
assert!(matches!(
ScoredVocab::from_gguf(&file),
Err(TokenizerLoadError::ScoresVocabLengthMismatch {
tokens: 2,
scores: 3
})
));
}
#[test]
fn an_absent_scores_key_scores_every_token_zero_rather_than_refusing() {
let file = MetadataOnlyGguf::new().with_tokens(&["a", "b", "c"]);
let vocab = ScoredVocab::from_gguf(&file).expect("a scoreless vocabulary is legal");
assert_eq!(vocab.len(), 3);
assert_eq!(vocab.lookup("c"), Some((2, 0.0)));
assert_eq!(vocab.min_score(), 0.0);
}
#[test]
fn an_empty_vocabulary_is_refused_so_min_score_is_always_a_real_score() {
let file = MetadataOnlyGguf::new().with_tokens(&[]).with_scores(&[]);
assert!(matches!(
ScoredVocab::from_gguf(&file),
Err(TokenizerLoadError::EmptyVocabulary)
));
}
#[test]
fn every_id_a_lookup_hands_back_indexes_its_own_score() {
let file = MetadataOnlyGguf::new()
.with_tokens(&["a", "bb", "ccc"])
.with_scores(&[-1.5, -2.5, -3.5]);
let vocab = ScoredVocab::from_gguf(&file).expect("lengths agree");
assert_eq!(vocab.lookup("a"), Some((0, -1.5)));
assert_eq!(vocab.lookup("bb"), Some((1, -2.5)));
assert_eq!(vocab.lookup("ccc"), Some((2, -3.5)));
assert_eq!(vocab.lookup("dddd"), None);
assert_eq!(vocab.min_score(), -3.5);
assert_eq!(vocab.token(2), Some("ccc"));
assert_eq!(vocab.token(3), None);
assert_eq!(vocab.id_of("bb"), Some(1));
}
}