Skip to main content

recursive/memory/
noop.rs

1//! No-op / fallback implementations of [`EmbeddingProvider`] and
2//! [`VectorStore`].
3//!
4//! `NoopEmbedding` always returns an empty vector, signalling to the store
5//! that keyword search should be used instead of cosine similarity.
6//!
7//! `NoopVectorStore` keeps entries in memory and performs case-insensitive
8//! substring search — identical to the original `recall` tool behaviour.
9
10use std::sync::Mutex;
11
12use async_trait::async_trait;
13
14use super::{EmbeddingProvider, MemoryEntry, VectorStore};
15use crate::error::Result;
16
17// ──────────────────────────────────────────────────────────────────────────────
18// NoopEmbedding
19// ──────────────────────────────────────────────────────────────────────────────
20
21/// An [`EmbeddingProvider`] that returns an empty vector for every input.
22///
23/// When paired with [`NoopVectorStore`] this causes the store to fall back to
24/// linear keyword search, preserving backward-compatible behaviour.
25pub struct NoopEmbedding;
26
27#[async_trait]
28impl EmbeddingProvider for NoopEmbedding {
29    async fn embed(&self, _text: &str) -> Vec<f32> {
30        vec![]
31    }
32}
33
34// ──────────────────────────────────────────────────────────────────────────────
35// NoopVectorStore
36// ──────────────────────────────────────────────────────────────────────────────
37
38/// An in-memory [`VectorStore`] that ignores embedding vectors and performs
39/// case-insensitive substring search on `query_text`.
40///
41/// This provides backward-compatible `recall` behaviour with no new
42/// dependencies. The store is **not** persisted across process restarts —
43/// for durability use `SqliteVecStore` or a cloud-backed store.
44pub struct NoopVectorStore {
45    entries: Mutex<Vec<MemoryEntry>>,
46}
47
48impl NoopVectorStore {
49    pub fn new() -> Self {
50        Self {
51            entries: Mutex::new(Vec::new()),
52        }
53    }
54}
55
56impl Default for NoopVectorStore {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62#[async_trait]
63impl VectorStore for NoopVectorStore {
64    async fn upsert(&self, entry: &MemoryEntry, _vector: Vec<f32>) -> Result<()> {
65        #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
66        let mut entries = self.entries.lock().unwrap();
67        if let Some(existing) = entries.iter_mut().find(|e| e.id == entry.id) {
68            *existing = entry.clone();
69        } else {
70            entries.push(entry.clone());
71        }
72        Ok(())
73    }
74
75    async fn search(
76        &self,
77        _query_vec: Vec<f32>,
78        query_text: &str,
79        limit: usize,
80    ) -> Result<Vec<MemoryEntry>> {
81        #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
82        let entries = self.entries.lock().unwrap();
83        let q = query_text.to_lowercase();
84        let matches: Vec<MemoryEntry> = entries
85            .iter()
86            .filter(|e| {
87                e.text.to_lowercase().contains(&q)
88                    || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
89            })
90            .take(limit)
91            .cloned()
92            .collect();
93        Ok(matches)
94    }
95
96    async fn remove(&self, id: &str) -> Result<()> {
97        #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
98        let mut entries = self.entries.lock().unwrap();
99        entries.retain(|e| e.id != id);
100        Ok(())
101    }
102
103    async fn list_all(&self) -> Result<Vec<MemoryEntry>> {
104        #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
105        let entries = self.entries.lock().unwrap();
106        Ok(entries.clone())
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[tokio::test]
115    async fn noop_embedding_returns_empty() {
116        let emb = NoopEmbedding;
117        assert!(emb.embed("hello world").await.is_empty());
118    }
119
120    #[tokio::test]
121    async fn noop_store_upsert_and_search() {
122        let store = NoopVectorStore::new();
123
124        let entry = MemoryEntry {
125            id: "N1".into(),
126            text: "Rust is a systems language".into(),
127            tags: vec!["rust".into()],
128            ts: "2026-01-01T00:00:00Z".into(),
129        };
130        store.upsert(&entry, vec![]).await.unwrap();
131
132        let results = store.search(vec![], "systems", 5).await.unwrap();
133        assert_eq!(results.len(), 1);
134        assert_eq!(results[0].id, "N1");
135
136        let no_results = store.search(vec![], "python", 5).await.unwrap();
137        assert!(no_results.is_empty());
138    }
139
140    #[tokio::test]
141    async fn noop_store_overwrite_on_same_id() {
142        let store = NoopVectorStore::new();
143
144        let e1 = MemoryEntry {
145            id: "N1".into(),
146            text: "original".into(),
147            tags: vec![],
148            ts: "2026-01-01T00:00:00Z".into(),
149        };
150        let e2 = MemoryEntry {
151            id: "N1".into(),
152            text: "updated".into(),
153            tags: vec![],
154            ts: "2026-01-02T00:00:00Z".into(),
155        };
156        store.upsert(&e1, vec![]).await.unwrap();
157        store.upsert(&e2, vec![]).await.unwrap();
158
159        let all = store.list_all().await.unwrap();
160        assert_eq!(all.len(), 1);
161        assert_eq!(all[0].text, "updated");
162    }
163
164    #[tokio::test]
165    async fn noop_store_remove_by_id() {
166        let store = NoopVectorStore::new();
167        let e = MemoryEntry {
168            id: "N1".into(),
169            text: "to be deleted".into(),
170            tags: vec![],
171            ts: "2026-01-01T00:00:00Z".into(),
172        };
173        store.upsert(&e, vec![]).await.unwrap();
174        store.remove("N1").await.unwrap();
175        assert!(store.list_all().await.unwrap().is_empty());
176    }
177
178    #[tokio::test]
179    async fn noop_store_search_matches_by_tag() {
180        // kills `|| e.tags.iter().any(...)` removal mutation
181        let store = NoopVectorStore::new();
182        let e = MemoryEntry {
183            id: "T1".into(),
184            text: "irrelevant text".into(),
185            tags: vec!["RustLang".into()],
186            ts: "2026-01-01T00:00:00Z".into(),
187        };
188        store.upsert(&e, vec![]).await.unwrap();
189        // Search by tag substring (case-insensitive)
190        let results = store.search(vec![], "rustlang", 5).await.unwrap();
191        assert_eq!(results.len(), 1, "tag match must be returned");
192        assert_eq!(results[0].id, "T1");
193    }
194
195    #[tokio::test]
196    async fn noop_store_search_respects_limit() {
197        // kills `.take(limit)` removal or `limit + 1` mutation
198        let store = NoopVectorStore::new();
199        for i in 0..5 {
200            let e = MemoryEntry {
201                id: format!("E{i}"),
202                text: "common keyword".into(),
203                tags: vec![],
204                ts: "2026-01-01T00:00:00Z".into(),
205            };
206            store.upsert(&e, vec![]).await.unwrap();
207        }
208        let results = store.search(vec![], "common", 3).await.unwrap();
209        assert_eq!(results.len(), 3, "search must be capped at the limit");
210    }
211
212    #[tokio::test]
213    async fn noop_store_list_all_returns_all_entries() {
214        // kills mutations that drop entries from list_all
215        let store = NoopVectorStore::new();
216        for i in 0..3 {
217            let e = MemoryEntry {
218                id: format!("L{i}"),
219                text: format!("entry {i}"),
220                tags: vec![],
221                ts: "2026-01-01T00:00:00Z".into(),
222            };
223            store.upsert(&e, vec![]).await.unwrap();
224        }
225        let all = store.list_all().await.unwrap();
226        assert_eq!(all.len(), 3);
227    }
228}