Skip to main content

meerkat_memory/
simple.rs

1//! SimpleMemoryStore — basic in-memory keyword-matching memory store.
2//!
3//! This is a simple implementation that uses substring matching for search.
4//! A production implementation would use vector embeddings (e.g., HNSW).
5
6use async_trait::async_trait;
7use meerkat_core::memory::{MemoryMetadata, MemoryResult, MemoryStore, MemoryStoreError};
8use tokio::sync::RwLock;
9
10/// Entry in the simple memory store.
11#[derive(Debug, Clone)]
12struct MemoryEntry {
13    content: String,
14    metadata: MemoryMetadata,
15}
16
17/// Simple in-memory store using substring matching.
18///
19/// This is a **test-only** implementation. Production use cases should use a
20/// vector-embedding-based store (e.g. HNSW). The substring matching here is
21/// not suitable for semantic search.
22pub struct SimpleMemoryStore {
23    entries: RwLock<Vec<MemoryEntry>>,
24}
25
26impl SimpleMemoryStore {
27    /// Create a new empty memory store.
28    pub fn new() -> Self {
29        Self {
30            entries: RwLock::new(Vec::new()),
31        }
32    }
33}
34
35impl Default for SimpleMemoryStore {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41#[async_trait]
42impl MemoryStore for SimpleMemoryStore {
43    async fn index(&self, content: &str, metadata: MemoryMetadata) -> Result<(), MemoryStoreError> {
44        let mut entries = self.entries.write().await;
45        entries.push(MemoryEntry {
46            content: content.to_string(),
47            metadata,
48        });
49        Ok(())
50    }
51
52    async fn search(
53        &self,
54        query: &str,
55        limit: usize,
56    ) -> Result<Vec<MemoryResult>, MemoryStoreError> {
57        let entries = self.entries.read().await;
58
59        let query_lower = query.to_lowercase();
60        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
61
62        let mut results: Vec<MemoryResult> = entries
63            .iter()
64            .filter_map(|entry| {
65                let content_lower = entry.content.to_lowercase();
66                let matching_words = query_words
67                    .iter()
68                    .filter(|w| content_lower.contains(**w))
69                    .count();
70
71                if matching_words == 0 {
72                    return None;
73                }
74
75                let score = matching_words as f32 / query_words.len().max(1) as f32;
76                Some(MemoryResult {
77                    content: entry.content.clone(),
78                    metadata: entry.metadata.clone(),
79                    score,
80                })
81            })
82            .collect();
83
84        // Sort by score descending
85        results.sort_by(|a, b| {
86            b.score
87                .partial_cmp(&a.score)
88                .unwrap_or(std::cmp::Ordering::Equal)
89        });
90        results.truncate(limit);
91
92        Ok(results)
93    }
94}
95
96#[cfg(test)]
97#[allow(clippy::unwrap_used, clippy::expect_used)]
98mod tests {
99    use super::*;
100    use meerkat_core::types::SessionId;
101    use std::time::SystemTime;
102
103    fn meta(_label: &str) -> MemoryMetadata {
104        MemoryMetadata {
105            session_id: SessionId::new(),
106            turn: Some(1),
107            indexed_at: SystemTime::now(),
108        }
109    }
110
111    #[tokio::test]
112    async fn test_index_and_search() {
113        let store = SimpleMemoryStore::new();
114
115        store
116            .index("The user wants to implement a REST API", meta("s1"))
117            .await
118            .unwrap();
119        store
120            .index("Configuration uses TOML format", meta("s2"))
121            .await
122            .unwrap();
123        store
124            .index("Authentication uses JWT tokens", meta("s3"))
125            .await
126            .unwrap();
127
128        let results = store.search("REST API", 10).await.unwrap();
129        assert!(!results.is_empty());
130        assert!(results[0].content.contains("REST API"));
131    }
132
133    #[tokio::test]
134    async fn test_search_empty_store() {
135        let store = SimpleMemoryStore::new();
136        let results = store.search("anything", 10).await.unwrap();
137        assert!(results.is_empty());
138    }
139
140    #[tokio::test]
141    async fn test_search_limit() {
142        let store = SimpleMemoryStore::new();
143
144        for i in 0..10 {
145            store
146                .index(&format!("Item {i} with keyword test"), meta("s1"))
147                .await
148                .unwrap();
149        }
150
151        let results = store.search("test", 3).await.unwrap();
152        assert_eq!(results.len(), 3);
153    }
154
155    #[tokio::test]
156    async fn test_search_no_match() {
157        let store = SimpleMemoryStore::new();
158        store.index("Hello world", meta("s1")).await.unwrap();
159
160        let results = store.search("quantum computing", 10).await.unwrap();
161        assert!(results.is_empty());
162    }
163}