Skip to main content

pe_core/
lobe_cache.rs

1//! Lobe output cache — skip re-execution when input hasn't changed.
2//!
3//! If a lobe receives the same input twice (common in multi-iteration
4//! cognitive graphs), the cache returns the previous result without
5//! calling the lobe's `process()` method. TTL-based expiry ensures
6//! stale results are evicted.
7
8use std::collections::HashMap;
9use std::hash::{DefaultHasher, Hash, Hasher};
10use std::time::{Duration, Instant};
11
12use crate::lobe::LobeOutput;
13
14/// Cache for lobe outputs, keyed by (lobe_name, input_hash).
15///
16/// # Example
17///
18/// ```
19/// use pe_core::lobe_cache::LobeCache;
20/// use pe_core::lobe::LobeOutput;
21/// use std::time::Duration;
22///
23/// let mut cache = LobeCache::new(Duration::from_secs(60));
24/// let output = LobeOutput::new("cached result", 0.9);
25///
26/// cache.put("analyst", "analyze this code", output.clone());
27/// assert_eq!(
28///     cache.get("analyst", "analyze this code").map(|o| o.content.as_str()),
29///     Some("cached result"),
30/// );
31///
32/// // Different input → cache miss
33/// assert!(cache.get("analyst", "different input").is_none());
34/// ```
35pub struct LobeCache {
36    entries: HashMap<(String, u64), CacheEntry>,
37    ttl: Duration,
38}
39
40struct CacheEntry {
41    output: LobeOutput,
42    input: String, // stored for exact-match collision guard
43    created_at: Instant,
44}
45
46impl LobeCache {
47    /// Create a new cache with the given TTL.
48    pub fn new(ttl: Duration) -> Self {
49        Self {
50            entries: HashMap::new(),
51            ttl,
52        }
53    }
54
55    /// Look up a cached result. Returns `None` on miss or expiry.
56    pub fn get(&self, lobe_name: &str, input: &str) -> Option<&LobeOutput> {
57        let key = (lobe_name.to_string(), hash_input(input));
58        self.entries.get(&key).and_then(|entry| {
59            // Exact match guard — hash collisions return None, not wrong data
60            if entry.input != input {
61                return None;
62            }
63            if entry.created_at.elapsed() < self.ttl {
64                Some(&entry.output)
65            } else {
66                None
67            }
68        })
69    }
70
71    /// Store a result in the cache.
72    pub fn put(&mut self, lobe_name: &str, input: &str, output: LobeOutput) {
73        let key = (lobe_name.to_string(), hash_input(input));
74        self.entries.insert(
75            key,
76            CacheEntry {
77                output,
78                input: input.to_string(),
79                created_at: Instant::now(),
80            },
81        );
82    }
83
84    /// Remove all expired entries.
85    pub fn evict_expired(&mut self) {
86        self.entries
87            .retain(|_, entry| entry.created_at.elapsed() < self.ttl);
88    }
89
90    /// Clear all entries.
91    pub fn clear(&mut self) {
92        self.entries.clear();
93    }
94
95    /// Number of entries (including potentially expired).
96    pub fn len(&self) -> usize {
97        self.entries.len()
98    }
99
100    /// Whether the cache has no entries.
101    pub fn is_empty(&self) -> bool {
102        self.entries.is_empty()
103    }
104}
105
106/// Hash the input string for cache key.
107fn hash_input(input: &str) -> u64 {
108    let mut hasher = DefaultHasher::new();
109    input.hash(&mut hasher);
110    hasher.finish()
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_cache_hit() {
119        let mut cache = LobeCache::new(Duration::from_secs(60));
120        let output = LobeOutput::new("analysis result", 0.9);
121
122        cache.put("analyst", "test input", output);
123
124        let cached = cache.get("analyst", "test input");
125        assert!(cached.is_some());
126        assert_eq!(cached.unwrap().content, "analysis result");
127    }
128
129    #[test]
130    fn test_cache_miss_different_input() {
131        let mut cache = LobeCache::new(Duration::from_secs(60));
132        cache.put("analyst", "input A", LobeOutput::new("result A", 0.9));
133
134        assert!(cache.get("analyst", "input B").is_none());
135    }
136
137    #[test]
138    fn test_cache_miss_different_lobe() {
139        let mut cache = LobeCache::new(Duration::from_secs(60));
140        cache.put("analyst", "input", LobeOutput::new("result", 0.9));
141
142        assert!(cache.get("critic", "input").is_none());
143    }
144
145    #[test]
146    fn test_cache_expiry() {
147        // Use zero TTL — everything expires immediately
148        let mut cache = LobeCache::new(Duration::ZERO);
149        cache.put("analyst", "input", LobeOutput::new("result", 0.9));
150
151        // Expired immediately
152        assert!(cache.get("analyst", "input").is_none());
153    }
154
155    #[test]
156    fn test_cache_overwrite() {
157        let mut cache = LobeCache::new(Duration::from_secs(60));
158        cache.put("analyst", "input", LobeOutput::new("old", 0.5));
159        cache.put("analyst", "input", LobeOutput::new("new", 0.9));
160
161        let cached = cache.get("analyst", "input").unwrap();
162        assert_eq!(cached.content, "new");
163        assert_eq!(cache.len(), 1);
164    }
165
166    #[test]
167    fn test_evict_expired() {
168        let mut cache = LobeCache::new(Duration::ZERO);
169        cache.put("a", "input", LobeOutput::new("a", 0.5));
170        cache.put("b", "input", LobeOutput::new("b", 0.5));
171        assert_eq!(cache.len(), 2);
172
173        cache.evict_expired();
174        assert!(cache.is_empty());
175    }
176
177    #[test]
178    fn test_clear() {
179        let mut cache = LobeCache::new(Duration::from_secs(60));
180        cache.put("a", "input", LobeOutput::new("a", 0.5));
181        cache.put("b", "input", LobeOutput::new("b", 0.5));
182
183        cache.clear();
184        assert!(cache.is_empty());
185    }
186}