use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::core::task::TaskId;
const MAX_CACHED_PLANS: usize = 256;
const DEFAULT_TTL: Duration = Duration::from_secs(3600);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CachedPlan {
pub assignments: HashMap<TaskId, String>,
pub execution_order: Vec<TaskId>,
pub model_selections: HashMap<TaskId, String>,
}
struct CacheEntry {
plan: CachedPlan,
created_at: Instant,
last_accessed: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PlanKey {
hash: u64,
}
impl PlanKey {
#[must_use]
pub fn from_crew(
agent_keys: &[String],
task_descriptions: &[String],
process_mode: &str,
) -> Self {
let mut hasher = std::hash::DefaultHasher::new();
let mut sorted_agents: Vec<&str> = agent_keys.iter().map(|s| s.as_str()).collect();
sorted_agents.sort();
for key in &sorted_agents {
key.hash(&mut hasher);
}
let mut sorted_tasks: Vec<&str> = task_descriptions.iter().map(|s| s.as_str()).collect();
sorted_tasks.sort();
for desc in &sorted_tasks {
desc.hash(&mut hasher);
}
process_mode.hash(&mut hasher);
Self {
hash: hasher.finish(),
}
}
}
pub struct PlanCache {
entries: HashMap<PlanKey, CacheEntry>,
ttl: Duration,
}
impl PlanCache {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
ttl: DEFAULT_TTL,
}
}
pub fn with_ttl(ttl: Duration) -> Self {
Self {
entries: HashMap::new(),
ttl,
}
}
#[must_use]
pub fn get(&mut self, key: &PlanKey) -> Option<&CachedPlan> {
let now = Instant::now();
if let Some(entry) = self.entries.get(key)
&& now.duration_since(entry.created_at) > self.ttl
{
self.entries.remove(key);
debug!("plan cache entry expired");
return None;
}
if let Some(entry) = self.entries.get_mut(key) {
entry.last_accessed = now;
debug!("plan cache hit");
Some(&entry.plan)
} else {
None
}
}
pub fn insert(&mut self, key: PlanKey, plan: CachedPlan) {
if self.entries.len() >= MAX_CACHED_PLANS {
self.evict_lru();
}
let now = Instant::now();
self.entries.insert(
key,
CacheEntry {
plan,
created_at: now,
last_accessed: now,
},
);
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn evict_lru(&mut self) {
if let Some(oldest_key) = self
.entries
.iter()
.min_by_key(|(_, e)| e.last_accessed)
.map(|(k, _)| k.clone())
{
self.entries.remove(&oldest_key);
debug!("plan cache: evicted LRU entry");
}
}
}
impl Default for PlanCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_key() -> PlanKey {
PlanKey::from_crew(
&["agent-a".into(), "agent-b".into()],
&["do X".into(), "do Y".into()],
"sequential",
)
}
fn sample_plan() -> CachedPlan {
CachedPlan {
assignments: HashMap::new(),
execution_order: Vec::new(),
model_selections: HashMap::new(),
}
}
#[test]
fn insert_and_get() {
let mut cache = PlanCache::new();
let key = sample_key();
cache.insert(key.clone(), sample_plan());
assert_eq!(cache.len(), 1);
assert!(cache.get(&key).is_some());
}
#[test]
fn miss_returns_none() {
let mut cache = PlanCache::new();
let key = sample_key();
assert!(cache.get(&key).is_none());
}
#[test]
fn different_agents_different_key() {
let k1 = PlanKey::from_crew(&["a".into()], &["task".into()], "seq");
let k2 = PlanKey::from_crew(&["b".into()], &["task".into()], "seq");
assert_ne!(k1, k2);
}
#[test]
fn order_independent_hashing() {
let k1 = PlanKey::from_crew(
&["agent-b".into(), "agent-a".into()],
&["Y".into(), "X".into()],
"seq",
);
let k2 = PlanKey::from_crew(
&["agent-a".into(), "agent-b".into()],
&["X".into(), "Y".into()],
"seq",
);
assert_eq!(k1, k2);
}
#[test]
fn ttl_expiry() {
let mut cache = PlanCache::with_ttl(Duration::from_millis(1));
let key = sample_key();
cache.insert(key.clone(), sample_plan());
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get(&key).is_none());
}
#[test]
fn lru_eviction_at_capacity() {
let mut cache = PlanCache::new();
for i in 0..MAX_CACHED_PLANS {
let key = PlanKey::from_crew(&[format!("agent-{i}")], &["task".into()], "seq");
cache.insert(key, sample_plan());
}
assert_eq!(cache.len(), MAX_CACHED_PLANS);
let key = PlanKey::from_crew(&["new-agent".into()], &["task".into()], "seq");
cache.insert(key, sample_plan());
assert_eq!(cache.len(), MAX_CACHED_PLANS);
}
#[test]
fn empty_cache() {
let cache = PlanCache::new();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
}