Skip to main content

totalreclaw_memory/
hotcache.rs

1//! In-memory hot cache for recently recalled facts.
2//!
3//! Wraps the generic `totalreclaw_core::hotcache::HotCache<T>` with
4//! `MemoryEntry`-specific types for the ZeroClaw memory backend.
5//!
6//! Matches the TypeScript plugin's hot cache behavior:
7//! - Caches up to 30 recent query results in memory
8//! - Skips remote subgraph query if a semantically similar query was recently answered
9//! - Similarity threshold: cosine >= 0.85 between query embeddings
10//! - Cache is per-session (lives on the TotalReclawMemory struct)
11
12use crate::backend::MemoryEntry;
13
14/// In-memory hot cache for semantic query dedup.
15///
16/// Thin wrapper around `totalreclaw_core::hotcache::HotCache<Vec<MemoryEntry>>`.
17pub struct HotCache {
18    inner: totalreclaw_core::hotcache::HotCache<Vec<MemoryEntry>>,
19}
20
21impl HotCache {
22    /// Create an empty hot cache with default settings (30 entries, cosine >= 0.85).
23    pub fn new() -> Self {
24        Self {
25            inner: totalreclaw_core::hotcache::HotCache::new(),
26        }
27    }
28
29    /// Check if a semantically similar query has already been answered.
30    ///
31    /// Returns cached results if a query with cosine >= 0.85 exists.
32    pub fn lookup(&self, query_embedding: &[f32]) -> Option<Vec<MemoryEntry>> {
33        self.inner.lookup(query_embedding).cloned()
34    }
35
36    /// Insert a query result into the cache.
37    ///
38    /// Evicts the oldest entry if the cache is full.
39    pub fn insert(&mut self, query_embedding: Vec<f32>, results: Vec<MemoryEntry>) {
40        self.inner.insert(query_embedding, results);
41    }
42
43    /// Clear the cache (e.g., after a store operation).
44    pub fn clear(&mut self) {
45        self.inner.clear();
46    }
47
48    /// Number of cached entries.
49    pub fn len(&self) -> usize {
50        self.inner.len()
51    }
52
53    /// Whether the cache is empty.
54    pub fn is_empty(&self) -> bool {
55        self.inner.is_empty()
56    }
57}
58
59impl Default for HotCache {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::backend::MemoryCategory;
69
70    fn make_entry(id: &str, content: &str) -> MemoryEntry {
71        MemoryEntry {
72            id: id.into(),
73            key: id.into(),
74            content: content.into(),
75            category: MemoryCategory::Core,
76            timestamp: String::new(),
77            session_id: None,
78            score: Some(0.9),
79        }
80    }
81
82    #[test]
83    fn test_hot_cache_miss_then_hit() {
84        let mut cache = HotCache::new();
85
86        let embedding = vec![1.0f32, 0.0, 0.0, 0.0];
87        assert!(cache.lookup(&embedding).is_none());
88
89        let results = vec![make_entry("1", "test fact")];
90        cache.insert(embedding.clone(), results.clone());
91
92        // Exact same embedding -> hit
93        let hit = cache.lookup(&embedding);
94        assert!(hit.is_some());
95        assert_eq!(hit.unwrap().len(), 1);
96    }
97
98    #[test]
99    fn test_hot_cache_similar_query_hit() {
100        let mut cache = HotCache::new();
101
102        let emb1 = vec![1.0f32, 0.0, 0.0, 0.0];
103        let results = vec![make_entry("1", "test fact")];
104        cache.insert(emb1, results);
105
106        // Very similar embedding (cosine > 0.85) -> hit
107        let emb2 = vec![0.99f32, 0.1, 0.0, 0.0];
108        assert!(cache.lookup(&emb2).is_some());
109    }
110
111    #[test]
112    fn test_hot_cache_dissimilar_query_miss() {
113        let mut cache = HotCache::new();
114
115        let emb1 = vec![1.0f32, 0.0, 0.0, 0.0];
116        let results = vec![make_entry("1", "test fact")];
117        cache.insert(emb1, results);
118
119        // Orthogonal embedding (cosine = 0) -> miss
120        let emb2 = vec![0.0f32, 1.0, 0.0, 0.0];
121        assert!(cache.lookup(&emb2).is_none());
122    }
123
124    #[test]
125    fn test_hot_cache_eviction() {
126        let mut cache = HotCache::new();
127
128        // Fill cache beyond max
129        for i in 0..35 {
130            let emb = vec![i as f32, 0.0, 0.0, 0.0];
131            cache.insert(emb, vec![make_entry(&i.to_string(), "fact")]);
132        }
133
134        assert_eq!(cache.len(), 30);
135    }
136
137    #[test]
138    fn test_hot_cache_clear() {
139        let mut cache = HotCache::new();
140        cache.insert(vec![1.0f32], vec![make_entry("1", "fact")]);
141        assert_eq!(cache.len(), 1);
142
143        cache.clear();
144        assert!(cache.is_empty());
145    }
146}