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::{
8    MemoryEnumerationPage, MemoryEnumerationRequest, MemoryIndexBatch, MemoryIndexReceipt,
9    MemoryIndexScope, MemoryMetadata, MemoryOwner, MemoryRecord, MemoryResult,
10    MemoryScopeDropReceipt, MemorySearchScope, MemoryStore, MemoryStoreError,
11};
12use tokio::sync::RwLock;
13
14/// Entry in the simple memory store.
15#[derive(Debug, Clone)]
16struct MemoryEntry {
17    scope: MemoryIndexScope,
18    content: String,
19    metadata: MemoryMetadata,
20}
21
22/// Simple in-memory store using substring matching.
23///
24/// This is a **test-only** implementation. Production use cases should use a
25/// vector-embedding-based store (e.g. HNSW). The substring matching here is
26/// not suitable for semantic search.
27pub struct SimpleMemoryStore {
28    entries: RwLock<Vec<MemoryEntry>>,
29}
30
31impl SimpleMemoryStore {
32    /// Create a new empty memory store.
33    pub fn new() -> Self {
34        Self {
35            entries: RwLock::new(Vec::new()),
36        }
37    }
38
39    fn prepare_batch(batch: MemoryIndexBatch) -> (MemoryIndexScope, Vec<MemoryEntry>) {
40        let (receipt_scope, requests) = batch.into_parts();
41        let entries = requests
42            .into_iter()
43            .filter_map(|request| {
44                let (scope, content, metadata) = request.into_parts();
45                // Store-side include/exclude gate (#319): skip content the
46                // producer marked non-indexable (Excluded), index the rest.
47                content.is_indexable().then(|| MemoryEntry {
48                    scope,
49                    content: content.into_indexable_text(),
50                    metadata,
51                })
52            })
53            .collect();
54        (receipt_scope, entries)
55    }
56}
57
58impl Default for SimpleMemoryStore {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64#[async_trait]
65impl MemoryStore for SimpleMemoryStore {
66    fn compaction_projection_persistence(
67        &self,
68    ) -> meerkat_core::memory::CompactionProjectionPersistence {
69        // This implementation is process-local and supplies the mandatory
70        // synchronous all-or-none compaction publication method below.
71        meerkat_core::memory::CompactionProjectionPersistence::EphemeralImmediate
72    }
73
74    async fn index_scoped_batch(
75        &self,
76        batch: MemoryIndexBatch,
77    ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
78        let (receipt_scope, prepared) = Self::prepare_batch(batch);
79        let indexed_entries = prepared.len();
80        let mut entries = self.entries.write().await;
81        entries.extend(prepared);
82        Ok(MemoryIndexReceipt {
83            scope: receipt_scope,
84            indexed_entries,
85        })
86    }
87
88    fn publish_ephemeral_compaction_batch(
89        &self,
90        batch: MemoryIndexBatch,
91    ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
92        let (receipt_scope, prepared) = Self::prepare_batch(batch);
93        let indexed_entries = prepared.len();
94        let mut entries = self.entries.try_write().map_err(|_| {
95            MemoryStoreError::Storage(
96                "ephemeral compaction publication lock is busy; refusing cancellable fallback"
97                    .to_string(),
98            )
99        })?;
100        entries.extend(prepared);
101        Ok(MemoryIndexReceipt {
102            scope: receipt_scope,
103            indexed_entries,
104        })
105    }
106
107    async fn search(
108        &self,
109        scope: &MemorySearchScope,
110        query: &str,
111        limit: usize,
112    ) -> Result<Vec<MemoryResult>, MemoryStoreError> {
113        let entries = self.entries.read().await;
114
115        let query_lower = query.to_lowercase();
116        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
117
118        let mut results: Vec<MemoryResult> = entries
119            .iter()
120            .filter(|entry| entry.scope.owner == scope.owner && scope.includes(&entry.metadata))
121            .filter_map(|entry| {
122                let content_lower = entry.content.to_lowercase();
123                let matching_words = query_words
124                    .iter()
125                    .filter(|w| content_lower.contains(**w))
126                    .count();
127
128                if matching_words == 0 {
129                    return None;
130                }
131
132                let score = matching_words as f32 / query_words.len().max(1) as f32;
133                Some(MemoryResult {
134                    content: entry.content.clone(),
135                    metadata: entry.metadata.clone(),
136                    score,
137                })
138            })
139            .collect();
140
141        // Sort by score descending
142        results.sort_by(|a, b| {
143            b.score
144                .partial_cmp(&a.score)
145                .unwrap_or(std::cmp::Ordering::Equal)
146        });
147        results.truncate(limit);
148
149        Ok(results)
150    }
151
152    async fn drop_scope(
153        &self,
154        owner: &MemoryOwner,
155    ) -> Result<MemoryScopeDropReceipt, MemoryStoreError> {
156        let mut entries = self.entries.write().await;
157        let before = entries.len();
158        entries.retain(|entry| entry.scope.owner != *owner);
159        Ok(MemoryScopeDropReceipt {
160            owner: owner.clone(),
161            dropped_entries: before - entries.len(),
162        })
163    }
164
165    async fn enumerate_scoped(
166        &self,
167        scope: &MemorySearchScope,
168        request: MemoryEnumerationRequest,
169    ) -> Result<MemoryEnumerationPage, MemoryStoreError> {
170        if request.limit == 0 {
171            return Err(MemoryStoreError::EnumerationLimitZero);
172        }
173        let entries = self.entries.read().await;
174
175        // Raw scope rows in insertion order; paging counts raw rows while the
176        // typed-metadata filters run afterwards on the selected window
177        // (parity with the durable store's SQL LIMIT/OFFSET semantics).
178        let scoped: Vec<&MemoryEntry> = entries
179            .iter()
180            .filter(|entry| entry.scope.owner == scope.owner && scope.includes(&entry.metadata))
181            .collect();
182        let window_start = request.offset.min(scoped.len());
183        let window_end = request
184            .offset
185            .saturating_add(request.limit)
186            .min(scoped.len());
187        let rows_scanned = window_end - window_start;
188
189        let records = scoped[window_start..window_end]
190            .iter()
191            .filter(|entry| request.admits(&entry.metadata))
192            .map(|entry| MemoryRecord {
193                content: entry.content.clone(),
194                metadata: entry.metadata.clone(),
195            })
196            .collect();
197        let next_offset =
198            (window_end < scoped.len()).then(|| request.offset.saturating_add(rows_scanned));
199
200        Ok(MemoryEnumerationPage {
201            records,
202            next_offset,
203        })
204    }
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, clippy::expect_used)]
209mod tests {
210    use super::*;
211    use meerkat_core::memory::{MemoryIndexRequest, MemorySource, MessageRange};
212    use meerkat_core::types::SessionId;
213    use std::time::{Duration, SystemTime, UNIX_EPOCH};
214
215    fn meta(session_id: &SessionId) -> MemoryMetadata {
216        MemoryMetadata {
217            session_id: session_id.clone(),
218            source: MemorySource::Compaction {
219                source_range: MessageRange::single(0),
220            },
221            indexed_at: SystemTime::now(),
222        }
223    }
224
225    fn request(content: impl Into<String>, session_id: &SessionId) -> MemoryIndexRequest {
226        MemoryIndexRequest::new(
227            MemoryIndexScope::for_session(session_id.clone()),
228            meerkat_core::MemoryIndexableContent::Indexable(content.into()),
229            meta(session_id),
230        )
231        .unwrap()
232    }
233
234    #[tokio::test]
235    async fn test_index_and_search() {
236        let store = SimpleMemoryStore::new();
237        let session_id = SessionId::new();
238        let scope = MemorySearchScope::for_session(session_id.clone());
239        let other_session_id = SessionId::new();
240
241        store
242            .index_scoped(request(
243                "The user wants to implement a REST API",
244                &session_id,
245            ))
246            .await
247            .unwrap();
248        store
249            .index_scoped(request("Configuration uses TOML format", &session_id))
250            .await
251            .unwrap();
252        store
253            .index_scoped(request("Authentication uses JWT tokens", &other_session_id))
254            .await
255            .unwrap();
256
257        {
258            let entries = store.entries.read().await;
259            assert!(
260                entries
261                    .iter()
262                    .all(|entry| entry.scope.includes(&entry.metadata))
263            );
264            assert_eq!(entries[0].scope.session_id(), &session_id);
265        }
266
267        let results = store.search(&scope, "REST API", 10).await.unwrap();
268        assert!(!results.is_empty());
269        assert!(results[0].content.contains("REST API"));
270        assert!(
271            results
272                .iter()
273                .all(|result| scope.includes(&result.metadata))
274        );
275    }
276
277    #[tokio::test]
278    async fn test_search_empty_store() {
279        let store = SimpleMemoryStore::new();
280        let scope = MemorySearchScope::for_session(SessionId::new());
281        let results = store.search(&scope, "anything", 10).await.unwrap();
282        assert!(results.is_empty());
283    }
284
285    #[tokio::test]
286    async fn ephemeral_compaction_publication_is_synchronous_and_fails_closed_on_contention() {
287        let store = SimpleMemoryStore::new();
288        let session_id = SessionId::new();
289        let scope = MemoryIndexScope::for_session(session_id.clone());
290
291        let held_entries = store.entries.write().await;
292        let blocked = store.publish_ephemeral_compaction_batch(
293            MemoryIndexBatch::new(
294                scope.clone(),
295                vec![request("must remain unpublished", &session_id)],
296            )
297            .unwrap(),
298        );
299        assert!(matches!(blocked, Err(MemoryStoreError::Storage(_))));
300        assert!(
301            held_entries.is_empty(),
302            "lock contention must publish none of the compaction batch"
303        );
304        drop(held_entries);
305
306        let receipt = store
307            .publish_ephemeral_compaction_batch(
308                MemoryIndexBatch::new(scope, vec![request("published synchronously", &session_id)])
309                    .unwrap(),
310            )
311            .unwrap();
312        assert_eq!(receipt.indexed_entries, 1);
313        let entries = store.entries.read().await;
314        assert_eq!(entries.len(), 1);
315        assert_eq!(entries[0].content, "published synchronously");
316    }
317
318    #[tokio::test]
319    async fn test_search_limit() {
320        let store = SimpleMemoryStore::new();
321        let session_id = SessionId::new();
322        let scope = MemorySearchScope::for_session(session_id.clone());
323
324        for i in 0..10 {
325            store
326                .index_scoped(request(format!("Item {i} with keyword test"), &session_id))
327                .await
328                .unwrap();
329        }
330
331        let results = store.search(&scope, "test", 3).await.unwrap();
332        assert_eq!(results.len(), 3);
333    }
334
335    #[tokio::test]
336    async fn test_search_no_match() {
337        let store = SimpleMemoryStore::new();
338        let session_id = SessionId::new();
339        let scope = MemorySearchScope::for_session(session_id.clone());
340        store
341            .index_scoped(request("Hello world", &session_id))
342            .await
343            .unwrap();
344
345        let results = store.search(&scope, "quantum computing", 10).await.unwrap();
346        assert!(results.is_empty());
347    }
348
349    #[test]
350    fn test_index_request_rejects_metadata_outside_scope() {
351        let session_id = SessionId::new();
352        let other_session_id = SessionId::new();
353        let error = MemoryIndexRequest::new(
354            MemoryIndexScope::for_session(session_id),
355            meerkat_core::MemoryIndexableContent::Indexable("outside scope".to_string()),
356            meta(&other_session_id),
357        )
358        .unwrap_err();
359
360        assert!(matches!(error, MemoryStoreError::Scope(_)));
361    }
362
363    fn request_with(
364        content: impl Into<String>,
365        session_id: &SessionId,
366        source_range: MessageRange,
367        indexed_at: SystemTime,
368    ) -> MemoryIndexRequest {
369        MemoryIndexRequest::new(
370            MemoryIndexScope::for_session(session_id.clone()),
371            meerkat_core::MemoryIndexableContent::Indexable(content.into()),
372            MemoryMetadata {
373                session_id: session_id.clone(),
374                source: MemorySource::Compaction { source_range },
375                indexed_at,
376            },
377        )
378        .unwrap()
379    }
380
381    fn enumeration(limit: usize, offset: usize) -> MemoryEnumerationRequest {
382        MemoryEnumerationRequest {
383            limit,
384            offset,
385            source_overlap: None,
386            indexed_after: None,
387        }
388    }
389
390    /// Parity with HnswMemoryStore: drop removes exactly the owner's entries
391    /// and reports their count; other scopes are untouched.
392    #[tokio::test]
393    async fn test_drop_scope_removes_only_owner_entries() {
394        let store = SimpleMemoryStore::new();
395        let session_a = SessionId::new();
396        let session_b = SessionId::new();
397        let scope_a = MemorySearchScope::for_session(session_a.clone());
398        let scope_b = MemorySearchScope::for_session(session_b.clone());
399
400        store
401            .index_scoped(request("doomed alpha entry", &session_a))
402            .await
403            .unwrap();
404        store
405            .index_scoped(request("doomed beta entry", &session_a))
406            .await
407            .unwrap();
408        store
409            .index_scoped(request("surviving gamma entry", &session_b))
410            .await
411            .unwrap();
412
413        let receipt = store
414            .drop_scope(&MemoryOwner::canonical_session(session_a.clone()))
415            .await
416            .unwrap();
417        assert_eq!(receipt.dropped_entries, 2);
418        assert_eq!(receipt.owner.session_id(), &session_a);
419
420        let dropped = store.search(&scope_a, "doomed", 10).await.unwrap();
421        assert!(dropped.is_empty());
422        let surviving = store.search(&scope_b, "surviving gamma", 10).await.unwrap();
423        assert_eq!(surviving.len(), 1);
424
425        // Dropping the same scope again is a zero-count no-op.
426        let repeat = store
427            .drop_scope(&MemoryOwner::canonical_session(session_a))
428            .await
429            .unwrap();
430        assert_eq!(repeat.dropped_entries, 0);
431    }
432
433    /// Parity with HnswMemoryStore: enumeration pages raw scope rows in
434    /// insertion order with deterministic raw-offset accounting.
435    #[tokio::test]
436    async fn test_enumerate_scoped_pages_in_insertion_order() {
437        let store = SimpleMemoryStore::new();
438        let session_id = SessionId::new();
439        let other_session = SessionId::new();
440        let scope = MemorySearchScope::for_session(session_id.clone());
441
442        let texts = ["entry zero", "entry one", "entry two", "entry three"];
443        for (i, text) in texts.iter().enumerate() {
444            store
445                .index_scoped(request(*text, &session_id))
446                .await
447                .unwrap();
448            store
449                .index_scoped(request(format!("interloper {i}"), &other_session))
450                .await
451                .unwrap();
452        }
453
454        let first = store
455            .enumerate_scoped(&scope, enumeration(3, 0))
456            .await
457            .unwrap();
458        assert_eq!(first.records.len(), 3);
459        assert_eq!(first.records[0].content, "entry zero");
460        assert_eq!(first.records[1].content, "entry one");
461        assert_eq!(first.records[2].content, "entry two");
462        assert_eq!(first.next_offset, Some(3));
463
464        let last = store
465            .enumerate_scoped(&scope, enumeration(3, 3))
466            .await
467            .unwrap();
468        assert_eq!(last.records.len(), 1);
469        assert_eq!(last.records[0].content, "entry three");
470        assert_eq!(last.next_offset, None);
471
472        let beyond = store
473            .enumerate_scoped(&scope, enumeration(3, 9))
474            .await
475            .unwrap();
476        assert!(beyond.records.is_empty());
477        assert_eq!(beyond.next_offset, None);
478    }
479
480    /// Parity with HnswMemoryStore: filters run post-window on typed
481    /// metadata, so a page may return fewer than `limit` records while
482    /// `next_offset` advances by raw rows scanned.
483    #[tokio::test]
484    async fn test_enumerate_scoped_filters_apply_after_raw_paging() {
485        let store = SimpleMemoryStore::new();
486        let session_id = SessionId::new();
487        let scope = MemorySearchScope::for_session(session_id.clone());
488        let early = UNIX_EPOCH + Duration::from_secs(1_000);
489        let late = UNIX_EPOCH + Duration::from_secs(2_000);
490
491        store
492            .index_scoped(request_with(
493                "covers zero to five early",
494                &session_id,
495                MessageRange::new(0, 5).unwrap(),
496                early,
497            ))
498            .await
499            .unwrap();
500        store
501            .index_scoped(request_with(
502                "covers five to ten late",
503                &session_id,
504                MessageRange::new(5, 10).unwrap(),
505                late,
506            ))
507            .await
508            .unwrap();
509        store
510            .index_scoped(request_with(
511                "covers ten to fifteen late",
512                &session_id,
513                MessageRange::new(10, 15).unwrap(),
514                late,
515            ))
516            .await
517            .unwrap();
518
519        // source_overlap admits only the middle record; all three raw rows
520        // are scanned.
521        let overlap = store
522            .enumerate_scoped(
523                &scope,
524                MemoryEnumerationRequest {
525                    limit: 10,
526                    offset: 0,
527                    source_overlap: Some(MessageRange::new(6, 8).unwrap()),
528                    indexed_after: None,
529                },
530            )
531            .await
532            .unwrap();
533        assert_eq!(overlap.records.len(), 1);
534        assert_eq!(overlap.records[0].content, "covers five to ten late");
535        assert_eq!(overlap.next_offset, None);
536
537        // indexed_after is strict: the boundary instant is excluded.
538        let after = store
539            .enumerate_scoped(
540                &scope,
541                MemoryEnumerationRequest {
542                    limit: 10,
543                    offset: 0,
544                    source_overlap: None,
545                    indexed_after: Some(early),
546                },
547            )
548            .await
549            .unwrap();
550        assert_eq!(after.records.len(), 2);
551        assert!(after.records.iter().all(|r| r.content.contains("late")));
552
553        // Zero-limit pages cannot advance the cursor and are rejected with
554        // the typed error (a follow-`next_offset` loop would never
555        // terminate) — parity with the durable store.
556        let error = store
557            .enumerate_scoped(&scope, enumeration(0, 1))
558            .await
559            .expect_err("limit zero must be rejected");
560        assert!(matches!(
561            error,
562            meerkat_core::memory::MemoryStoreError::EnumerationLimitZero
563        ));
564    }
565}