Skip to main content

mentedb_cognitive/
speculative.rs

1use mentedb_core::types::{MemoryId, Timestamp};
2
3#[derive(Debug, Clone)]
4pub struct CacheEntry {
5    pub topic: String,
6    pub context_text: String,
7    pub memory_ids: Vec<MemoryId>,
8    pub created_at: Timestamp,
9    pub hit_count: u32,
10    last_accessed: Timestamp,
11}
12
13#[derive(Debug, Clone, Default)]
14pub struct CacheStats {
15    pub hits: u64,
16    pub misses: u64,
17    pub evictions: u64,
18    pub cache_size: usize,
19}
20
21pub struct SpeculativeCache {
22    entries: Vec<CacheEntry>,
23    stats: CacheStats,
24    max_size: usize,
25    hit_threshold: f32,
26}
27
28fn keyword_overlap_score(query: &str, topic: &str) -> f32 {
29    let query_words: ahash::AHashSet<String> = query
30        .to_lowercase()
31        .split_whitespace()
32        .filter(|w| w.len() >= 2)
33        .map(String::from)
34        .collect();
35    let topic_words: ahash::AHashSet<String> = topic
36        .to_lowercase()
37        .split_whitespace()
38        .filter(|w| w.len() >= 2)
39        .map(String::from)
40        .collect();
41
42    if query_words.is_empty() || topic_words.is_empty() {
43        return 0.0;
44    }
45
46    let intersection = query_words.intersection(&topic_words).count();
47    let union = query_words.union(&topic_words).count();
48    if union == 0 {
49        0.0
50    } else {
51        intersection as f32 / union as f32
52    }
53}
54
55impl SpeculativeCache {
56    pub fn new(max_size: usize, hit_threshold: f32) -> Self {
57        Self {
58            entries: Vec::new(),
59            stats: CacheStats::default(),
60            max_size,
61            hit_threshold,
62        }
63    }
64
65    pub fn pre_assemble(
66        &mut self,
67        predictions: Vec<String>,
68        builder: impl Fn(&str) -> Option<(String, Vec<MemoryId>)>,
69    ) {
70        let now = std::time::SystemTime::now()
71            .duration_since(std::time::UNIX_EPOCH)
72            .unwrap_or_default()
73            .as_micros() as u64;
74
75        for topic in predictions {
76            // Skip if already cached
77            if self.entries.iter().any(|e| e.topic == topic) {
78                continue;
79            }
80
81            if let Some((context_text, memory_ids)) = builder(&topic) {
82                // Evict LRU if at capacity
83                if self.entries.len() >= self.max_size {
84                    self.evict_lru();
85                }
86
87                self.entries.push(CacheEntry {
88                    topic,
89                    context_text,
90                    memory_ids,
91                    created_at: now,
92                    hit_count: 0,
93                    last_accessed: now,
94                });
95            }
96        }
97    }
98
99    pub fn try_hit(&mut self, query: &str) -> Option<&CacheEntry> {
100        let now = std::time::SystemTime::now()
101            .duration_since(std::time::UNIX_EPOCH)
102            .unwrap_or_default()
103            .as_micros() as u64;
104
105        let mut best_idx = None;
106        let mut best_score = 0.0f32;
107
108        for (i, entry) in self.entries.iter().enumerate() {
109            let score = keyword_overlap_score(query, &entry.topic);
110            if score > best_score {
111                best_score = score;
112                best_idx = Some(i);
113            }
114        }
115
116        if best_score > self.hit_threshold
117            && let Some(idx) = best_idx
118        {
119            self.entries[idx].hit_count += 1;
120            self.entries[idx].last_accessed = now;
121            self.stats.hits += 1;
122            return Some(&self.entries[idx]);
123        }
124
125        self.stats.misses += 1;
126        None
127    }
128
129    pub fn evict_stale(&mut self, max_age_us: u64, now: Timestamp) {
130        let before = self.entries.len();
131        self.entries.retain(|e| now - e.created_at <= max_age_us);
132        let evicted = before - self.entries.len();
133        self.stats.evictions += evicted as u64;
134    }
135
136    pub fn stats(&self) -> CacheStats {
137        CacheStats {
138            cache_size: self.entries.len(),
139            ..self.stats.clone()
140        }
141    }
142
143    fn evict_lru(&mut self) {
144        if self.entries.is_empty() {
145            return;
146        }
147        let lru_idx = self
148            .entries
149            .iter()
150            .enumerate()
151            .min_by_key(|(_, e)| e.last_accessed)
152            .map(|(i, _)| i)
153            .unwrap();
154        self.entries.remove(lru_idx);
155        self.stats.evictions += 1;
156    }
157}
158
159impl Default for SpeculativeCache {
160    fn default() -> Self {
161        Self::new(10, 0.5)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_pre_assemble_and_hit() {
171        let mut cache = SpeculativeCache::default();
172        cache.pre_assemble(
173            vec![
174                "database schema design".to_string(),
175                "API authentication".to_string(),
176            ],
177            |topic| Some((format!("Context for {}", topic), vec![uuid::Uuid::new_v4()])),
178        );
179
180        assert_eq!(cache.stats().cache_size, 2);
181
182        let hit = cache.try_hit("database schema");
183        assert!(hit.is_some());
184        assert!(hit.unwrap().context_text.contains("database schema design"));
185    }
186
187    #[test]
188    fn test_cache_miss() {
189        let mut cache = SpeculativeCache::default();
190        cache.pre_assemble(vec!["database schema".to_string()], |topic| {
191            Some((format!("Context for {}", topic), vec![]))
192        });
193
194        let hit = cache.try_hit("cooking recipes");
195        assert!(hit.is_none());
196        assert_eq!(cache.stats().misses, 1);
197    }
198
199    #[test]
200    fn test_lru_eviction() {
201        let mut cache = SpeculativeCache::new(10, 0.5);
202        for i in 0..12 {
203            cache.pre_assemble(vec![format!("topic {}", i)], |topic| {
204                Some((format!("Context for {}", topic), vec![]))
205            });
206        }
207        assert!(cache.stats().cache_size <= 10);
208        assert!(cache.stats().evictions > 0);
209    }
210
211    #[test]
212    fn test_evict_stale() {
213        let mut cache = SpeculativeCache::default();
214        cache.pre_assemble(vec!["old topic".to_string()], |topic| {
215            Some((format!("Context for {}", topic), vec![]))
216        });
217        // Evict anything older than 0 microseconds from a far-future time
218        cache.evict_stale(0, u64::MAX);
219        assert_eq!(cache.stats().cache_size, 0);
220    }
221}