use anyhow::Result;
use async_trait::async_trait;
use uuid::Uuid;
use crate::retrieval::memory::MemoryHit;
use crate::retrieval::memory_payload::SemanticMemory;
use crate::retrieval::qdrant::QdrantWrap;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MemoryOrder {
#[default]
Unordered,
UpdatedAtDesc,
}
#[derive(Debug, Clone, Default)]
pub struct MemoryFilter {
pub bucket: Option<String>,
pub anchor_path: Option<String>,
pub order_by: MemoryOrder,
pub limit: Option<usize>,
}
#[async_trait]
pub trait SemanticMemoryStore: Send + Sync {
async fn upsert(&self, m: &SemanticMemory, dense: &[f32]) -> Result<()>;
async fn search(
&self,
project_id: &str,
query: &[f32],
top_n: usize,
bucket: Option<&str>,
) -> Result<Vec<MemoryHit>>;
async fn delete(&self, project_id: &str, id: Uuid) -> Result<()>;
async fn list(&self, project_id: &str, filter: MemoryFilter) -> Result<Vec<MemoryHit>>;
}
pub struct QdrantSemanticMemoryStore {
qdrant: QdrantWrap,
collection: String,
}
impl QdrantSemanticMemoryStore {
pub async fn new(qdrant: QdrantWrap, collection: impl Into<String>, dim: u64) -> Result<Self> {
let collection = collection.into();
qdrant.ensure_memories_collection(&collection, dim).await?;
Ok(Self { qdrant, collection })
}
pub fn from_parts(qdrant: QdrantWrap, collection: impl Into<String>) -> Self {
Self {
qdrant,
collection: collection.into(),
}
}
}
#[async_trait]
impl SemanticMemoryStore for QdrantSemanticMemoryStore {
async fn upsert(&self, m: &SemanticMemory, dense: &[f32]) -> Result<()> {
self.qdrant
.memory_upsert(&self.collection, m, dense.to_vec())
.await
}
async fn search(
&self,
project_id: &str,
query: &[f32],
top_n: usize,
bucket: Option<&str>,
) -> Result<Vec<MemoryHit>> {
self.qdrant
.memory_search_dense(&self.collection, project_id, query.to_vec(), top_n, bucket)
.await
}
async fn delete(&self, _project_id: &str, id: Uuid) -> Result<()> {
self.qdrant.memory_delete(&self.collection, id).await
}
async fn list(&self, project_id: &str, filter: MemoryFilter) -> Result<Vec<MemoryHit>> {
let mut hits = self
.qdrant
.memory_list_filtered(
&self.collection,
project_id,
filter.bucket.as_deref(),
filter.anchor_path.as_deref(),
)
.await?;
if filter.order_by == MemoryOrder::UpdatedAtDesc {
hits.sort_by(|a, b| b.memory.updated_at.cmp(&a.memory.updated_at));
}
if let Some(n) = filter.limit {
hits.truncate(n);
}
Ok(hits)
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
pub struct InMemorySemanticMemoryStore {
inner: Mutex<HashMap<Uuid, (SemanticMemory, Vec<f32>)>>,
}
impl Default for InMemorySemanticMemoryStore {
fn default() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
}
}
}
impl InMemorySemanticMemoryStore {
pub fn new() -> Self {
Self::default()
}
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
0.0
} else {
dot / (na * nb)
}
}
#[async_trait]
impl SemanticMemoryStore for InMemorySemanticMemoryStore {
async fn upsert(&self, m: &SemanticMemory, dense: &[f32]) -> Result<()> {
self.inner
.lock()
.unwrap()
.insert(m.point_id(), (m.clone(), dense.to_vec()));
Ok(())
}
async fn search(
&self,
project_id: &str,
query: &[f32],
top_n: usize,
bucket: Option<&str>,
) -> Result<Vec<MemoryHit>> {
let guard = self.inner.lock().unwrap();
let mut scored: Vec<(Uuid, &SemanticMemory, f32)> = guard
.iter()
.filter(|(_, (m, _))| m.project_id == project_id)
.filter(|(_, (m, _))| bucket.is_none_or(|b| m.bucket == b))
.map(|(id, (m, v))| (*id, m, cosine(query, v)))
.collect();
scored.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
Ok(scored
.into_iter()
.take(top_n)
.map(|(id, m, s)| MemoryHit {
id,
memory: m.clone(),
score: Some(s),
})
.collect())
}
async fn delete(&self, _project_id: &str, id: Uuid) -> Result<()> {
self.inner.lock().unwrap().remove(&id);
Ok(())
}
async fn list(&self, project_id: &str, filter: MemoryFilter) -> Result<Vec<MemoryHit>> {
let guard = self.inner.lock().unwrap();
let mut hits: Vec<MemoryHit> = guard
.iter()
.filter(|(_, (m, _))| m.project_id == project_id)
.filter(|(_, (m, _))| filter.bucket.as_deref().is_none_or(|b| m.bucket == b))
.filter(|(_, (m, _))| {
filter
.anchor_path
.as_deref()
.is_none_or(|p| m.anchors.iter().any(|a| a.path == p))
})
.map(|(id, (m, _))| MemoryHit {
id: *id,
memory: m.clone(),
score: None,
})
.collect();
if filter.order_by == MemoryOrder::UpdatedAtDesc {
hits.sort_by(|a, b| b.memory.updated_at.cmp(&a.memory.updated_at));
}
if let Some(n) = filter.limit {
hits.truncate(n);
}
Ok(hits)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::retrieval::memory_payload::{MemoryAnchor, SemanticMemory};
fn sample(title: &str, anchor_path: &str) -> SemanticMemory {
SemanticMemory {
project_id: "test-proj".into(),
bucket: if title.contains("pref") {
"preferences"
} else {
"system"
}
.into(),
title: title.into(),
content: format!("content for {title}"),
anchors: vec![MemoryAnchor {
path: anchor_path.into(),
}],
created_at: "2026-05-13T00:00:00Z".into(),
updated_at: "2026-05-13T00:00:00Z".into(),
}
}
#[tokio::test]
#[ignore]
async fn semantic_memory_store_trait_roundtrip() {
let qdrant = QdrantWrap::connect("http://localhost:6334")
.await
.expect("connect");
let coll = "test_semantic_memory_store";
let _ = qdrant.client.delete_collection(coll).await;
let store = QdrantSemanticMemoryStore::new(qdrant, coll, 8)
.await
.expect("new");
let alpha = sample("alpha-system", "src/a.rs");
let beta = sample("beta-pref", "src/b.rs");
let v_alpha = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let v_beta = vec![0.7, 0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
store.upsert(&alpha, &v_alpha).await.unwrap();
store.upsert(&beta, &v_beta).await.unwrap();
let q = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let hits = store.search("test-proj", &q, 2, None).await.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].memory.title, "alpha-system");
let hits = store
.search("test-proj", &q, 5, Some("preferences"))
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].memory.title, "beta-pref");
assert_eq!(
store
.list("test-proj", MemoryFilter::default())
.await
.unwrap()
.len(),
2
);
let by = store
.list(
"test-proj",
MemoryFilter {
anchor_path: Some("src/a.rs".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(by.len(), 1);
assert_eq!(by[0].memory.title, "alpha-system");
let prefs = store
.list(
"test-proj",
MemoryFilter {
bucket: Some("preferences".into()),
limit: Some(10),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].memory.title, "beta-pref");
store.delete("test-proj", alpha.point_id()).await.unwrap();
let remaining = store
.list("test-proj", MemoryFilter::default())
.await
.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].memory.title, "beta-pref");
store.delete("test-proj", alpha.point_id()).await.unwrap();
let cleanup = QdrantWrap::connect("http://localhost:6334")
.await
.expect("reconnect");
cleanup.client.delete_collection(coll).await.unwrap();
}
#[tokio::test]
async fn in_memory_store_trait_roundtrip() {
use test_support::InMemorySemanticMemoryStore;
let store = InMemorySemanticMemoryStore::new();
let alpha = sample("alpha-system", "src/a.rs");
let beta = sample("beta-pref", "src/b.rs");
let v_alpha = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let v_beta = vec![0.7, 0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
store.upsert(&alpha, &v_alpha).await.unwrap();
store.upsert(&beta, &v_beta).await.unwrap();
let q = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let hits = store.search("test-proj", &q, 2, None).await.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].memory.title, "alpha-system");
let hits = store
.search("test-proj", &q, 5, Some("preferences"))
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].memory.title, "beta-pref");
assert_eq!(
store
.list("test-proj", MemoryFilter::default())
.await
.unwrap()
.len(),
2
);
let by = store
.list(
"test-proj",
MemoryFilter {
anchor_path: Some("src/a.rs".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(by.len(), 1);
assert_eq!(by[0].memory.title, "alpha-system");
let prefs = store
.list(
"test-proj",
MemoryFilter {
bucket: Some("preferences".into()),
limit: Some(10),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].memory.title, "beta-pref");
let other = SemanticMemory {
project_id: "other-proj".into(),
..sample("orphan", "src/x.rs")
};
store.upsert(&other, &v_alpha).await.unwrap();
assert_eq!(
store
.list("test-proj", MemoryFilter::default())
.await
.unwrap()
.len(),
2,
"other-proj memory must not appear under test-proj"
);
store.delete("test-proj", alpha.point_id()).await.unwrap();
let remaining = store
.list("test-proj", MemoryFilter::default())
.await
.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].memory.title, "beta-pref");
store.delete("test-proj", alpha.point_id()).await.unwrap();
}
#[test]
fn trait_is_dyn_compatible() {
fn _accepts_dyn(_: &dyn SemanticMemoryStore) {}
}
}