use std::time::{Duration, Instant};
use crate::models::cache::LayerCache;
#[derive(Clone)]
pub struct CacheEntry {
pub ids: Vec<u32>,
pub caches: Vec<LayerCache>,
pub fed_images: usize,
pub fed_audios: usize,
last_used: Instant,
pub pinned: bool,
}
pub struct PromptCachePool {
entries: Vec<CacheEntry>,
max_entries: usize,
ttl: Duration,
min_cacheable_tokens: usize,
}
impl PromptCachePool {
pub fn new(max_entries: usize, ttl: Duration, min_cacheable_tokens: usize) -> Self {
PromptCachePool {
entries: Vec::new(),
max_entries,
ttl,
min_cacheable_tokens,
}
}
pub fn with_defaults() -> Self {
Self::new(16, Duration::from_secs(5 * 60), 8)
}
pub fn find_longest_prefix(&mut self, ids: &[u32]) -> Option<(CacheEntry, usize)> {
self.evict_expired();
let mut best: Option<usize> = None; for (i, entry) in self.entries.iter().enumerate() {
if !entry.ids.is_empty()
&& is_prefix(&entry.ids, ids)
&& best
.map(|b| entry.ids.len() > self.entries[b].ids.len())
.unwrap_or(true)
{
best = Some(i);
}
}
let idx = best?;
self.entries[idx].last_used = Instant::now();
let shared = self.entries[idx].ids.len();
Some((self.entries[idx].clone(), shared))
}
pub fn insert_or_update(
&mut self,
ids: Vec<u32>,
caches: Vec<LayerCache>,
fed_images: usize,
fed_audios: usize,
pinned: bool,
) {
let now = Instant::now();
if ids.len() < self.min_cacheable_tokens
&& !self.entries.iter().any(|e| is_prefix(&e.ids, &ids))
{
return;
}
if let Some(existing) = self.entries.iter_mut().find(|e| is_prefix(&e.ids, &ids)) {
existing.ids = ids;
existing.caches = caches;
existing.fed_images = fed_images;
existing.fed_audios = fed_audios;
existing.last_used = now;
existing.pinned = existing.pinned || pinned;
return;
}
self.entries.push(CacheEntry {
ids,
caches,
fed_images,
fed_audios,
last_used: now,
pinned,
});
self.evict_lru_if_over_capacity();
}
fn evict_expired(&mut self) {
let ttl = self.ttl;
let now = Instant::now();
self.entries
.retain(|e| e.pinned || now.duration_since(e.last_used) < ttl);
}
fn evict_lru_if_over_capacity(&mut self) {
while self.entries.len() > self.max_entries {
let victim = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| !e.pinned)
.min_by_key(|(_, e)| e.last_used)
.map(|(i, _)| i);
match victim {
Some(i) => {
self.entries.remove(i);
}
None => break,
}
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn is_prefix(shorter: &[u32], longer: &[u32]) -> bool {
shorter.len() <= longer.len() && shorter == &longer[..shorter.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_caches() -> Vec<LayerCache> {
Vec::new()
}
#[test]
fn find_longest_prefix_picks_the_best_match() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false);
pool.insert_or_update(vec![1, 2, 3, 4, 5], empty_caches(), 0, 0, false);
let (entry, shared) = pool.find_longest_prefix(&[1, 2, 3, 4, 5, 6]).unwrap();
assert_eq!(entry.ids, vec![1, 2, 3, 4, 5]);
assert_eq!(shared, 5);
}
#[test]
fn find_longest_prefix_returns_none_when_no_overlap() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false);
assert!(pool.find_longest_prefix(&[9, 9, 9]).is_none());
}
#[test]
fn insert_or_update_extends_existing_lineage_in_place() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false);
pool.insert_or_update(vec![1, 2, 3, 4, 5], empty_caches(), 1, 0, false);
assert_eq!(
pool.len(),
1,
"extending a lineage should not grow the pool"
);
}
#[test]
fn insert_or_update_evicts_lru_over_capacity() {
let mut pool = PromptCachePool::new(2, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1], empty_caches(), 0, 0, false);
pool.insert_or_update(vec![2], empty_caches(), 0, 0, false);
pool.find_longest_prefix(&[1]);
pool.insert_or_update(vec![3], empty_caches(), 0, 0, false);
assert_eq!(pool.len(), 2);
assert!(
pool.find_longest_prefix(&[2]).is_none(),
"LRU entry should have been evicted"
);
assert!(pool.find_longest_prefix(&[1]).is_some());
assert!(pool.find_longest_prefix(&[3]).is_some());
}
#[test]
fn pinned_entries_survive_lru_pressure() {
let mut pool = PromptCachePool::new(2, Duration::from_secs(300), 0);
pool.insert_or_update(vec![1], empty_caches(), 0, 0, true);
pool.insert_or_update(vec![2], empty_caches(), 0, 0, false);
pool.insert_or_update(vec![3], empty_caches(), 0, 0, false);
assert_eq!(pool.len(), 2);
assert!(
pool.find_longest_prefix(&[1]).is_some(),
"pinned entry must survive eviction"
);
assert!(
pool.find_longest_prefix(&[2]).is_none(),
"unpinned entry should have been evicted"
);
assert!(pool.find_longest_prefix(&[3]).is_some());
}
#[test]
fn entries_shorter_than_the_minimum_are_not_cached() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 8);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false);
assert!(
pool.is_empty(),
"a 3-token entry should be rejected by an 8-token minimum"
);
assert!(pool.find_longest_prefix(&[1, 2, 3]).is_none());
}
#[test]
fn a_lineage_becomes_cacheable_once_it_crosses_the_minimum() {
let mut pool = PromptCachePool::new(16, Duration::from_secs(300), 8);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false);
assert!(pool.is_empty());
let turn_two: Vec<u32> = (1..=10).collect();
pool.insert_or_update(turn_two.clone(), empty_caches(), 0, 0, false);
assert_eq!(pool.len(), 1);
assert!(pool.find_longest_prefix(&turn_two).is_some());
}
#[test]
fn expired_unpinned_entries_are_evicted() {
let mut pool = PromptCachePool::new(16, Duration::from_millis(1), 0);
pool.insert_or_update(vec![1, 2, 3], empty_caches(), 0, 0, false);
std::thread::sleep(Duration::from_millis(5));
assert!(pool.find_longest_prefix(&[1, 2, 3]).is_none());
}
}