Skip to main content

zeph_memory/
in_memory_store.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Purely in-memory [`VectorStore`] implementation for unit tests.
5//!
6//! All data lives in a `RwLock<HashMap>` and is discarded when the store is dropped.
7//! Cosine similarity is computed with [`zeph_common::math::cosine_similarity`].
8//! Not suitable for production — use [`crate::qdrant_ops::QdrantOps`] instead.
9
10use std::collections::HashMap;
11use std::sync::atomic::AtomicBool;
12
13use parking_lot::RwLock;
14
15use crate::vector_store::{
16    BoxFuture, FieldValue, ScoredVectorPoint, ScrollWithIdsResult, VectorFilter, VectorPoint,
17    VectorStore, VectorStoreError,
18};
19
20struct StoredPoint {
21    vector: Vec<f32>,
22    payload: HashMap<String, serde_json::Value>,
23}
24
25struct InMemoryCollection {
26    points: HashMap<String, StoredPoint>,
27}
28
29/// In-process vector store backed by a `RwLock<HashMap>`.
30///
31/// Intended for unit tests only.  All data is lost when the store is dropped.
32///
33/// # Examples
34///
35/// ```
36/// use zeph_memory::in_memory_store::InMemoryVectorStore;
37///
38/// let store = InMemoryVectorStore::new();
39/// // Use `store` as a `VectorStore` in tests.
40/// ```
41pub struct InMemoryVectorStore {
42    collections: RwLock<HashMap<String, InMemoryCollection>>,
43}
44
45impl InMemoryVectorStore {
46    #[must_use]
47    pub fn new() -> Self {
48        Self {
49            collections: RwLock::new(HashMap::new()),
50        }
51    }
52}
53
54impl Default for InMemoryVectorStore {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl std::fmt::Debug for InMemoryVectorStore {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("InMemoryVectorStore")
63            .finish_non_exhaustive()
64    }
65}
66
67use zeph_common::math::cosine_similarity;
68
69fn matches_filter(payload: &HashMap<String, serde_json::Value>, filter: &VectorFilter) -> bool {
70    for cond in &filter.must {
71        let Some(val) = payload.get(&cond.field) else {
72            return false;
73        };
74        if !field_matches(val, &cond.value) {
75            return false;
76        }
77    }
78    for cond in &filter.must_not {
79        if let Some(val) = payload.get(&cond.field)
80            && field_matches(val, &cond.value)
81        {
82            return false;
83        }
84    }
85    true
86}
87
88fn field_matches(val: &serde_json::Value, expected: &FieldValue) -> bool {
89    match expected {
90        FieldValue::Integer(i) => val.as_i64() == Some(*i),
91        FieldValue::Text(s) => val.as_str() == Some(s.as_str()),
92    }
93}
94
95impl VectorStore for InMemoryVectorStore {
96    fn ensure_collection(
97        &self,
98        collection: &str,
99        _vector_size: u64,
100    ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
101        let collection = collection.to_owned();
102        Box::pin(async move {
103            let mut cols = self.collections.write();
104            cols.entry(collection)
105                .or_insert_with(|| InMemoryCollection {
106                    points: HashMap::new(),
107                });
108            Ok(())
109        })
110    }
111
112    fn collection_exists(&self, collection: &str) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
113        let collection = collection.to_owned();
114        Box::pin(async move {
115            let cols = self.collections.read();
116            Ok(cols.contains_key(&collection))
117        })
118    }
119
120    fn delete_collection(&self, collection: &str) -> BoxFuture<'_, Result<(), VectorStoreError>> {
121        let collection = collection.to_owned();
122        Box::pin(async move {
123            let mut cols = self.collections.write();
124            cols.remove(&collection);
125            Ok(())
126        })
127    }
128
129    fn upsert(
130        &self,
131        collection: &str,
132        points: Vec<VectorPoint>,
133    ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
134        let collection = collection.to_owned();
135        Box::pin(async move {
136            let mut cols = self.collections.write();
137            let col = cols.get_mut(&collection).ok_or_else(|| {
138                VectorStoreError::Upsert(format!("collection {collection} not found"))
139            })?;
140            for p in points {
141                col.points.insert(
142                    p.id,
143                    StoredPoint {
144                        vector: p.vector,
145                        payload: p.payload,
146                    },
147                );
148            }
149            Ok(())
150        })
151    }
152
153    fn search_clamp_diagnostics(&self) -> (&'static str, &'static AtomicBool) {
154        static CLAMP_WARNED: AtomicBool = AtomicBool::new(false);
155        ("InMemoryVectorStore::search", &CLAMP_WARNED)
156    }
157
158    fn search_clamped(
159        &self,
160        collection: &str,
161        vector: Vec<f32>,
162        limit: u64,
163        filter: Option<VectorFilter>,
164    ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
165        let collection = collection.to_owned();
166        Box::pin(async move {
167            let cols = self.collections.read();
168            let col = cols.get(&collection).ok_or_else(|| {
169                VectorStoreError::Search(format!("collection {collection} not found"))
170            })?;
171
172            let empty_filter = VectorFilter::default();
173            let f = filter.as_ref().unwrap_or(&empty_filter);
174
175            let mut scored: Vec<ScoredVectorPoint> = col
176                .points
177                .iter()
178                .filter(|(_, sp)| matches_filter(&sp.payload, f))
179                .map(|(id, sp)| ScoredVectorPoint {
180                    id: id.clone(),
181                    score: cosine_similarity(&vector, &sp.vector),
182                    payload: sp.payload.clone(),
183                })
184                .collect();
185
186            scored.sort_by(|a, b| {
187                b.score
188                    .partial_cmp(&a.score)
189                    .unwrap_or(std::cmp::Ordering::Equal)
190            });
191            #[expect(clippy::cast_possible_truncation)]
192            scored.truncate(limit as usize);
193            Ok(scored)
194        })
195    }
196
197    fn delete_by_ids(
198        &self,
199        collection: &str,
200        ids: Vec<String>,
201    ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
202        let collection = collection.to_owned();
203        Box::pin(async move {
204            if ids.is_empty() {
205                return Ok(());
206            }
207            let mut cols = self.collections.write();
208            let col = cols.get_mut(&collection).ok_or_else(|| {
209                VectorStoreError::Delete(format!("collection {collection} not found"))
210            })?;
211            for id in &ids {
212                col.points.remove(id);
213            }
214            Ok(())
215        })
216    }
217
218    fn scroll_all(
219        &self,
220        collection: &str,
221        key_field: &str,
222    ) -> BoxFuture<'_, Result<HashMap<String, HashMap<String, String>>, VectorStoreError>> {
223        let collection = collection.to_owned();
224        let key_field = key_field.to_owned();
225        Box::pin(async move {
226            let cols = self.collections.read();
227            let col = cols.get(&collection).ok_or_else(|| {
228                VectorStoreError::Scroll(format!("collection {collection} not found"))
229            })?;
230
231            let mut result = HashMap::new();
232            for sp in col.points.values() {
233                let Some(key_val) = sp.payload.get(&key_field).and_then(|v| v.as_str()) else {
234                    continue;
235                };
236                let mut fields = HashMap::new();
237                for (k, v) in &sp.payload {
238                    if let Some(s) = v.as_str() {
239                        fields.insert(k.clone(), s.to_owned());
240                    }
241                }
242                result.insert(key_val.to_owned(), fields);
243            }
244            Ok(result)
245        })
246    }
247
248    fn scroll_all_with_point_ids(
249        &self,
250        collection: &str,
251        key_field: &str,
252    ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>> {
253        let collection = collection.to_owned();
254        let key_field = key_field.to_owned();
255        Box::pin(async move {
256            let cols = self.collections.read();
257            let col = cols.get(&collection).ok_or_else(|| {
258                VectorStoreError::Scroll(format!("collection {collection} not found"))
259            })?;
260
261            let mut result = Vec::new();
262            for (point_id, sp) in &col.points {
263                let Some(key_val) = sp.payload.get(&key_field).and_then(|v| v.as_str()) else {
264                    continue;
265                };
266                let mut fields = HashMap::new();
267                for (k, v) in &sp.payload {
268                    if let Some(s) = v.as_str() {
269                        fields.insert(k.clone(), s.to_owned());
270                    }
271                }
272                // Ensure the key_field value is always present in the fields map.
273                fields.insert(key_field.clone(), key_val.to_owned());
274                result.push((point_id.clone(), fields));
275            }
276            Ok(result)
277        })
278    }
279
280    fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
281        Box::pin(async { Ok(true) })
282    }
283
284    fn get_points(
285        &self,
286        collection: &str,
287        ids: Vec<String>,
288    ) -> BoxFuture<'_, Result<Vec<VectorPoint>, VectorStoreError>> {
289        let collection = collection.to_owned();
290        Box::pin(async move {
291            let cols = self.collections.read();
292            let col = cols.get(&collection).ok_or_else(|| {
293                VectorStoreError::Unsupported(format!("collection {collection} not found"))
294            })?;
295            let points = ids
296                .into_iter()
297                .filter_map(|id| {
298                    col.points.get(&id).map(|sp| VectorPoint {
299                        id: id.clone(),
300                        vector: sp.vector.clone(),
301                        payload: sp.payload.clone(),
302                    })
303                })
304                .collect();
305            Ok(points)
306        })
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[tokio::test]
315    async fn ensure_collection_and_exists() {
316        let store = InMemoryVectorStore::new();
317        assert!(!store.collection_exists("test").await.unwrap());
318        store.ensure_collection("test", 3).await.unwrap();
319        assert!(store.collection_exists("test").await.unwrap());
320    }
321
322    #[tokio::test]
323    async fn ensure_collection_idempotent() {
324        let store = InMemoryVectorStore::new();
325        store.ensure_collection("test", 3).await.unwrap();
326        store.ensure_collection("test", 3).await.unwrap();
327        assert!(store.collection_exists("test").await.unwrap());
328    }
329
330    #[tokio::test]
331    async fn delete_collection_removes() {
332        let store = InMemoryVectorStore::new();
333        store.ensure_collection("test", 3).await.unwrap();
334        store.delete_collection("test").await.unwrap();
335        assert!(!store.collection_exists("test").await.unwrap());
336    }
337
338    #[tokio::test]
339    async fn upsert_and_search() {
340        let store = InMemoryVectorStore::new();
341        store.ensure_collection("test", 3).await.unwrap();
342
343        let points = vec![
344            VectorPoint {
345                id: "a".into(),
346                vector: vec![1.0, 0.0, 0.0],
347                payload: HashMap::from([("name".into(), serde_json::json!("alpha"))]),
348            },
349            VectorPoint {
350                id: "b".into(),
351                vector: vec![0.0, 1.0, 0.0],
352                payload: HashMap::from([("name".into(), serde_json::json!("beta"))]),
353            },
354        ];
355        store.upsert("test", points).await.unwrap();
356
357        let results = store
358            .search("test", vec![1.0, 0.0, 0.0], 2, None)
359            .await
360            .unwrap();
361        assert_eq!(results.len(), 2);
362        assert_eq!(results[0].id, "a");
363        assert!((results[0].score - 1.0).abs() < f32::EPSILON);
364    }
365
366    #[tokio::test]
367    async fn search_with_filter() {
368        let store = InMemoryVectorStore::new();
369        store.ensure_collection("test", 3).await.unwrap();
370
371        let points = vec![
372            VectorPoint {
373                id: "a".into(),
374                vector: vec![1.0, 0.0, 0.0],
375                payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
376            },
377            VectorPoint {
378                id: "b".into(),
379                vector: vec![0.9, 0.1, 0.0],
380                payload: HashMap::from([("role".into(), serde_json::json!("assistant"))]),
381            },
382        ];
383        store.upsert("test", points).await.unwrap();
384
385        let filter = VectorFilter {
386            must: vec![crate::vector_store::FieldCondition {
387                field: "role".into(),
388                value: FieldValue::Text("user".into()),
389            }],
390            must_not: vec![],
391        };
392        let results = store
393            .search("test", vec![1.0, 0.0, 0.0], 10, Some(filter))
394            .await
395            .unwrap();
396        assert_eq!(results.len(), 1);
397        assert_eq!(results[0].id, "a");
398    }
399
400    #[tokio::test]
401    async fn delete_by_ids_removes_points() {
402        let store = InMemoryVectorStore::new();
403        store.ensure_collection("test", 3).await.unwrap();
404
405        let points = vec![VectorPoint {
406            id: "a".into(),
407            vector: vec![1.0, 0.0, 0.0],
408            payload: HashMap::new(),
409        }];
410        store.upsert("test", points).await.unwrap();
411        store.delete_by_ids("test", vec!["a".into()]).await.unwrap();
412
413        let results = store
414            .search("test", vec![1.0, 0.0, 0.0], 10, None)
415            .await
416            .unwrap();
417        assert!(results.is_empty());
418    }
419
420    #[tokio::test]
421    async fn scroll_all_extracts_strings() {
422        let store = InMemoryVectorStore::new();
423        store.ensure_collection("test", 3).await.unwrap();
424
425        let points = vec![VectorPoint {
426            id: "a".into(),
427            vector: vec![1.0, 0.0, 0.0],
428            payload: HashMap::from([
429                ("name".into(), serde_json::json!("alpha")),
430                ("desc".into(), serde_json::json!("first")),
431                ("num".into(), serde_json::json!(42)),
432            ]),
433        }];
434        store.upsert("test", points).await.unwrap();
435
436        let result = store.scroll_all("test", "name").await.unwrap();
437        assert_eq!(result.len(), 1);
438        let fields = result.get("alpha").unwrap();
439        assert_eq!(fields.get("desc").unwrap(), "first");
440        assert!(!fields.contains_key("num"));
441    }
442
443    #[tokio::test]
444    async fn scroll_all_with_point_ids_returns_point_id() {
445        let store = InMemoryVectorStore::new();
446        store.ensure_collection("test", 3).await.unwrap();
447
448        let points = vec![
449            VectorPoint {
450                id: "pid-1".into(),
451                vector: vec![1.0, 0.0, 0.0],
452                payload: HashMap::from([
453                    ("entity_id_str".into(), serde_json::json!("42")),
454                    ("name".into(), serde_json::json!("Alpha")),
455                    ("count".into(), serde_json::json!(7)), // non-string, must be excluded
456                ]),
457            },
458            VectorPoint {
459                id: "pid-2".into(),
460                vector: vec![0.0, 1.0, 0.0],
461                // Missing key_field: must be excluded from results.
462                payload: HashMap::from([("name".into(), serde_json::json!("Beta"))]),
463            },
464        ];
465        store.upsert("test", points).await.unwrap();
466
467        let result = store
468            .scroll_all_with_point_ids("test", "entity_id_str")
469            .await
470            .unwrap();
471
472        assert_eq!(
473            result.len(),
474            1,
475            "only the point with key_field should appear"
476        );
477        let (point_id, fields) = &result[0];
478        assert_eq!(point_id, "pid-1");
479        assert_eq!(fields.get("entity_id_str").map(String::as_str), Some("42"));
480        assert_eq!(fields.get("name").map(String::as_str), Some("Alpha"));
481        // Non-string field must be absent.
482        assert!(!fields.contains_key("count"));
483    }
484
485    #[test]
486    fn cosine_similarity_import_wired() {
487        // Smoke test: verifies the re-export binding is intact. Edge-case coverage is in math.rs.
488        assert!(!cosine_similarity(&[1.0, 0.0, 0.0], &[0.0, 1.0, 0.0]).is_nan());
489    }
490
491    #[tokio::test]
492    async fn default_impl() {
493        let store = InMemoryVectorStore::default();
494        assert!(!store.collection_exists("any").await.unwrap());
495    }
496
497    #[test]
498    fn debug_format() {
499        let store = InMemoryVectorStore::new();
500        let dbg = format!("{store:?}");
501        assert!(dbg.contains("InMemoryVectorStore"));
502    }
503
504    /// Issue #6616: `VectorStore::search` must clamp an oversized `limit` at the trait-impl
505    /// choke point itself, not only in the `EmbeddingStore`/`EmbeddingRegistry`/`ReasoningMemory`
506    /// wrapper methods (issue #6553) — a caller reaching this implementor directly (e.g. a
507    /// generic `V: VectorStore` pipeline step) must still get the same bound.
508    #[tokio::test]
509    #[tracing_test::traced_test]
510    async fn search_clamps_oversized_limit() {
511        let store = InMemoryVectorStore::new();
512        store.ensure_collection("test", 3).await.unwrap();
513
514        let points: Vec<VectorPoint> = (0..(crate::MAX_SEARCH_LIMIT + 10))
515            .map(|i| VectorPoint {
516                id: format!("p{i}"),
517                vector: vec![1.0, 0.0, 0.0],
518                payload: HashMap::new(),
519            })
520            .collect();
521        store.upsert("test", points).await.unwrap();
522
523        let results = store
524            .search("test", vec![1.0, 0.0, 0.0], u64::MAX, None)
525            .await
526            .unwrap();
527        assert_eq!(results.len(), crate::MAX_SEARCH_LIMIT);
528        assert!(
529            logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
530            "expected a one-shot warn when the trait-impl clamp actually reduces the requested limit"
531        );
532    }
533}