mod l1;
use std::sync::Arc;
pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
use crate::{
Encoding, Result, TokenIdType,
traits::{DecodeResult, Decoder, Encoder, Tokenizer},
};
pub struct CachedTokenizer {
inner: Arc<dyn Tokenizer>,
l1: L1Cache,
l1_enabled: bool,
extend_on_hit: bool,
}
impl CachedTokenizer {
pub fn new(
inner: Arc<dyn Tokenizer>,
special_tokens: Vec<String>,
max_memory_bytes: usize,
) -> Self {
let l1_enabled = !special_tokens.is_empty();
Self {
inner,
l1: L1Cache::new(max_memory_bytes, special_tokens),
l1_enabled,
extend_on_hit: false,
}
}
pub fn with_extend(mut self, enabled: bool) -> Self {
self.extend_on_hit = enabled;
self
}
pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
self.l1.set_observer(on_hit, on_miss);
self
}
pub fn cache_stats(&self) -> L1CacheStats {
self.l1.stats()
}
pub fn clear_cache(&self) {
self.l1.clear();
}
pub fn inner(&self) -> &Arc<dyn Tokenizer> {
&self.inner
}
}
impl Encoder for CachedTokenizer {
fn encode(&self, input: &str) -> Result<Encoding> {
if !self.l1_enabled {
return self.inner.encode(input);
}
if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
self.l1.longest_prefix_match(input)
{
let suffix = &input[prefix_len..];
if suffix.is_empty() {
return Ok(Encoding::Sp(prefix_tokens.to_vec()));
}
if self.extend_on_hit {
return Ok(Encoding::Sp(self.l1.extend_after_match(
input,
prefix_tokens,
prefix_len,
deepest_boundary,
self.inner.as_ref(),
)?));
}
let suffix_enc = self.inner.encode(suffix)?;
let mut merged: Vec<TokenIdType> =
Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
merged.extend_from_slice(&prefix_tokens);
merged.extend_from_slice(suffix_enc.token_ids());
return Ok(Encoding::Sp(merged));
}
Ok(Encoding::Sp(
self.l1.populate_and_encode(input, self.inner.as_ref())?,
))
}
fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
if !self.l1_enabled {
return self.inner.encode_batch(inputs);
}
inputs.iter().map(|&i| self.encode(i)).collect()
}
}
impl Decoder for CachedTokenizer {
fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
self.inner.decode(token_ids, skip_special_tokens)
}
}
impl Tokenizer for CachedTokenizer {}
#[cfg(test)]
mod tests {
use super::*;
use crate::HuggingFaceTokenizer;
const TINYLLAMA_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../llm/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
);
fn inner() -> Arc<dyn Tokenizer> {
Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
}
fn specials() -> Vec<String> {
vec!["<s>".into(), "</s>".into()]
}
#[test]
fn empty_specials_passes_through_correctly() {
let tok = inner();
let cached = CachedTokenizer::new(tok.clone(), Vec::new(), 4096);
let s = "<s>hello world</s>";
let a = cached.encode(s).unwrap();
let b = tok.encode(s).unwrap();
assert_eq!(a.token_ids(), b.token_ids());
let stats = cached.cache_stats();
assert_eq!(stats.entries, 0);
assert_eq!(stats.misses, 0, "empty specials must not increment misses");
assert_eq!(stats.hits, 0);
}
#[test]
fn two_turn_chat_correctness_and_hit() {
let tok = inner();
let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024);
let template = "<s>system\nYou are helpful.</s><s>user\n";
let first = format!("{template}First question?</s>");
let second = format!("{template}Second different prompt entirely.</s>");
let _ = cached.encode(&first).unwrap();
let cached_second = cached.encode(&second).unwrap();
let plain_second = tok.encode(&second).unwrap();
assert_eq!(
cached_second.token_ids(),
plain_second.token_ids(),
"cached encode must equal plain encode for second turn"
);
let stats = cached.cache_stats();
assert!(stats.hits >= 1, "expected L1 hit on second request");
}
#[test]
fn decode_passes_through() {
let tok = inner();
let cached = CachedTokenizer::new(tok.clone(), specials(), 4096);
let enc = cached.encode("<s>hello</s>").unwrap();
let direct = tok.decode(enc.token_ids(), false).unwrap();
let through = cached.decode(enc.token_ids(), false).unwrap();
assert_eq!(direct, through);
}
#[test]
fn encode_batch_uses_cache() {
let tok = inner();
let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024);
let shared = "<s>system\nShared persona.</s><s>user\n";
let inputs = [
format!("{shared}q1</s>"),
format!("{shared}q2</s>"),
format!("{shared}q3</s>"),
];
let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
let outs = cached.encode_batch(&refs).unwrap();
assert_eq!(outs.len(), 3);
assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
}
}