mentedb_cognitive/
speculative.rs1use 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 if self.entries.iter().any(|e| e.topic == topic) {
78 continue;
79 }
80
81 if let Some((context_text, memory_ids)) = builder(&topic) {
82 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 cache.evict_stale(0, u64::MAX);
219 assert_eq!(cache.stats().cache_size, 0);
220 }
221}