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        let mut entries = self.entries.lock().unwrap();
66        if let Some(existing) = entries.iter_mut().find(|e| e.id == entry.id) {
67            *existing = entry.clone();
68        } else {
69            entries.push(entry.clone());
70        }
71        Ok(())
72    }
73
74    async fn search(
75        &self,
76        _query_vec: Vec<f32>,
77        query_text: &str,
78        limit: usize,
79    ) -> Result<Vec<MemoryEntry>> {
80        let entries = self.entries.lock().unwrap();
81        let q = query_text.to_lowercase();
82        let matches: Vec<MemoryEntry> = entries
83            .iter()
84            .filter(|e| {
85                e.text.to_lowercase().contains(&q)
86                    || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
87            })
88            .take(limit)
89            .cloned()
90            .collect();
91        Ok(matches)
92    }
93
94    async fn remove(&self, id: &str) -> Result<()> {
95        let mut entries = self.entries.lock().unwrap();
96        entries.retain(|e| e.id != id);
97        Ok(())
98    }
99
100    async fn list_all(&self) -> Result<Vec<MemoryEntry>> {
101        let entries = self.entries.lock().unwrap();
102        Ok(entries.clone())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[tokio::test]
111    async fn noop_embedding_returns_empty() {
112        let emb = NoopEmbedding;
113        assert!(emb.embed("hello world").await.is_empty());
114    }
115
116    #[tokio::test]
117    async fn noop_store_upsert_and_search() {
118        let store = NoopVectorStore::new();
119
120        let entry = MemoryEntry {
121            id: "N1".into(),
122            text: "Rust is a systems language".into(),
123            tags: vec!["rust".into()],
124            ts: "2026-01-01T00:00:00Z".into(),
125        };
126        store.upsert(&entry, vec![]).await.unwrap();
127
128        let results = store.search(vec![], "systems", 5).await.unwrap();
129        assert_eq!(results.len(), 1);
130        assert_eq!(results[0].id, "N1");
131
132        let no_results = store.search(vec![], "python", 5).await.unwrap();
133        assert!(no_results.is_empty());
134    }
135
136    #[tokio::test]
137    async fn noop_store_overwrite_on_same_id() {
138        let store = NoopVectorStore::new();
139
140        let e1 = MemoryEntry {
141            id: "N1".into(),
142            text: "original".into(),
143            tags: vec![],
144            ts: "2026-01-01T00:00:00Z".into(),
145        };
146        let e2 = MemoryEntry {
147            id: "N1".into(),
148            text: "updated".into(),
149            tags: vec![],
150            ts: "2026-01-02T00:00:00Z".into(),
151        };
152        store.upsert(&e1, vec![]).await.unwrap();
153        store.upsert(&e2, vec![]).await.unwrap();
154
155        let all = store.list_all().await.unwrap();
156        assert_eq!(all.len(), 1);
157        assert_eq!(all[0].text, "updated");
158    }
159
160    #[tokio::test]
161    async fn noop_store_remove_by_id() {
162        let store = NoopVectorStore::new();
163        let e = MemoryEntry {
164            id: "N1".into(),
165            text: "to be deleted".into(),
166            tags: vec![],
167            ts: "2026-01-01T00:00:00Z".into(),
168        };
169        store.upsert(&e, vec![]).await.unwrap();
170        store.remove("N1").await.unwrap();
171        assert!(store.list_all().await.unwrap().is_empty());
172    }
173}