use crate::generate::{FinishReason, Usage};
use std::collections::{HashMap, VecDeque};
use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub model: String,
pub prompt: String,
pub max_tokens: usize,
pub temperature_bits: u32,
pub top_p_bits: u32,
pub top_k: usize,
pub repetition_penalty_bits: u32,
pub presence_penalty_bits: u32,
pub frequency_penalty_bits: u32,
pub seed: Option<u64>,
pub stop: Vec<String>,
}
impl CacheKey {
pub fn digest(&self) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CachedCompletion {
pub content: String,
pub finish: FinishReason,
pub usage: Usage,
}
struct Entry {
completion: CachedCompletion,
inserted_at: Instant,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub entries: usize,
}
pub struct ResponseCache {
entries: HashMap<CacheKey, Entry>,
order: VecDeque<CacheKey>,
max_entries: usize,
ttl: Duration,
hits: u64,
misses: u64,
}
impl ResponseCache {
pub fn new(max_entries: usize, ttl: Duration) -> Self {
ResponseCache {
entries: HashMap::new(),
order: VecDeque::new(),
max_entries,
ttl,
hits: 0,
misses: 0,
}
}
pub fn get(&mut self, key: &CacheKey) -> Option<CachedCompletion> {
let is_expired = self
.entries
.get(key)
.map(|e| e.inserted_at.elapsed() > self.ttl)
.unwrap_or(false);
if is_expired {
self.entries.remove(key);
self.order.retain(|k| k != key);
}
match self.entries.get(key) {
Some(entry) => {
self.hits += 1;
self.order.retain(|k| k != key);
self.order.push_back(key.clone());
Some(entry.completion.clone())
}
None => {
self.misses += 1;
None
}
}
}
pub fn put(&mut self, key: CacheKey, completion: CachedCompletion) {
if !self.entries.contains_key(&key) && self.entries.len() >= self.max_entries {
if let Some(oldest) = self.order.pop_front() {
self.entries.remove(&oldest);
}
}
self.order.retain(|k| k != &key);
self.order.push_back(key.clone());
self.entries.insert(
key,
Entry {
completion,
inserted_at: Instant::now(),
},
);
}
pub fn stats(&self) -> CacheStats {
CacheStats {
hits: self.hits,
misses: self.misses,
entries: self.entries.len(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(prompt: &str) -> CacheKey {
CacheKey {
model: "test-model".to_string(),
prompt: prompt.to_string(),
max_tokens: 16,
temperature_bits: 0.0f32.to_bits(),
top_p_bits: 1.0f32.to_bits(),
top_k: 0,
repetition_penalty_bits: 1.0f32.to_bits(),
presence_penalty_bits: 0.0f32.to_bits(),
frequency_penalty_bits: 0.0f32.to_bits(),
seed: None,
stop: Vec::new(),
}
}
fn cc(text: &str) -> CachedCompletion {
CachedCompletion {
content: text.to_string(),
finish: FinishReason::Stop,
usage: Usage::new(3, 5),
}
}
#[test]
fn miss_then_hit_for_the_same_key() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
assert_eq!(cache.get(&key("hello")), None);
cache.put(key("hello"), cc("world"));
assert_eq!(cache.get(&key("hello")), Some(cc("world")));
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.entries, 1);
}
#[test]
fn different_keys_do_not_collide() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
cache.put(key("prompt a"), cc("response a"));
cache.put(key("prompt b"), cc("response b"));
assert_eq!(cache.get(&key("prompt a")), Some(cc("response a")));
assert_eq!(cache.get(&key("prompt b")), Some(cc("response b")));
}
#[test]
fn different_max_tokens_is_a_different_key_even_for_the_same_prompt() {
let mut cache = ResponseCache::new(10, Duration::from_secs(60));
let mut k1 = key("same prompt");
k1.max_tokens = 16;
let mut k2 = key("same prompt");
k2.max_tokens = 32;
cache.put(k1.clone(), cc("short response"));
assert_eq!(
cache.get(&k2),
None,
"different max_tokens must be a cache miss even with identical prompt text"
);
assert_eq!(cache.get(&k1), Some(cc("short response")));
}
#[test]
fn expired_entry_is_a_miss_and_is_evicted() {
let mut cache = ResponseCache::new(10, Duration::from_millis(10));
cache.put(key("hello"), cc("world"));
std::thread::sleep(Duration::from_millis(30));
assert_eq!(
cache.get(&key("hello")),
None,
"entry older than the TTL must be treated as a miss"
);
assert_eq!(
cache.stats().entries,
0,
"expired entry must actually be evicted, not just skipped"
);
}
#[test]
fn evicts_least_recently_used_entry_when_full() {
let mut cache = ResponseCache::new(2, Duration::from_secs(60));
cache.put(key("a"), cc("1"));
cache.put(key("b"), cc("2"));
assert_eq!(cache.get(&key("a")), Some(cc("1")));
cache.put(key("c"), cc("3"));
assert_eq!(
cache.get(&key("b")),
None,
"least-recently-used entry ('b') must have been evicted"
);
assert_eq!(
cache.get(&key("a")),
Some(cc("1")),
"recently-touched entry ('a') must survive eviction"
);
assert_eq!(
cache.get(&key("c")),
Some(cc("3")),
"newly inserted entry ('c') must be present"
);
}
#[test]
fn putting_an_existing_key_again_does_not_grow_past_capacity() {
let mut cache = ResponseCache::new(2, Duration::from_secs(60));
cache.put(key("a"), cc("1"));
cache.put(key("b"), cc("2"));
cache.put(key("a"), cc("1-updated")); assert_eq!(cache.stats().entries, 2);
assert_eq!(cache.get(&key("a")), Some(cc("1-updated")));
assert_eq!(
cache.get(&key("b")),
Some(cc("2")),
"unrelated entry must survive a re-insert of another key"
);
}
#[test]
fn digest_is_stable_for_identical_keys_and_differs_for_different_keys() {
let a1 = key("hello");
let a2 = key("hello");
let b = key("goodbye");
assert_eq!(a1.digest(), a2.digest());
assert_ne!(a1.digest(), b.digest());
}
}