1use 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#[derive(Debug, Clone)]
16struct MemoryEntry {
17 scope: MemoryIndexScope,
18 content: String,
19 metadata: MemoryMetadata,
20}
21
22pub struct SimpleMemoryStore {
28 entries: RwLock<Vec<MemoryEntry>>,
29}
30
31impl SimpleMemoryStore {
32 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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}