use std::cmp::Ordering;
use crate::memory::clock::Clock;
use crate::memory::config::MemoryConfig;
use crate::memory::embedding::EmbeddingProvider;
use crate::memory::error::{EmbeddingError, MemoryError};
use crate::memory::index::{BruteForceIndex, VectorIndex};
use crate::memory::store::{Memory, VectorStore};
#[cfg(feature = "ann")]
use crate::memory::index::InstantDistanceIndex;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct RankedMemory {
pub memory: Memory,
pub score: f64,
}
#[allow(dead_code)]
pub async fn recall(
store: &dyn VectorStore,
embedder: &dyn EmbeddingProvider,
clock: &dyn Clock,
cfg: &MemoryConfig,
query: &str,
_budget: usize,
scope: &str,
) -> Result<Vec<RankedMemory>, MemoryError> {
let qv = embed_query(embedder, query).await?;
let mems = store.active(scope).await?;
let current_model = embedder.model_id();
let current_dim = embedder.dim();
let filtered: Vec<(usize, &Memory)> = mems
.iter()
.enumerate()
.filter(|(_, m)| {
m.model_id == current_model && m.dim == current_dim && !m.embedding.is_empty()
})
.collect();
if filtered.is_empty() {
return Ok(vec![]);
}
let points: Vec<(usize, Vec<f32>)> = filtered
.iter()
.map(|(i, m)| (*i, m.embedding.clone()))
.collect();
let hits: Vec<(usize, f32)> = {
#[cfg(feature = "ann")]
if cfg.index == "ann" {
let idx = InstantDistanceIndex::build(&points, cfg.seed);
idx.search(&qv, cfg.top_k)
} else {
BruteForceIndex::build(&points, cfg.seed).search(&qv, cfg.top_k)
}
#[cfg(not(feature = "ann"))]
{
if cfg.index == "ann" {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
eprintln!(
"WARN [magi-rs]: index=\"ann\" is set but this binary was compiled \
without the `ann` feature; falling back to brute-force exact search. \
Recompile with `--features ann` to enable HNSW."
);
}
}
BruteForceIndex::build(&points, cfg.seed).search(&qv, cfg.top_k)
}
};
let now = clock.now();
let w_sum = cfg.weight_similarity + cfg.weight_recency + cfg.weight_salience;
let w_sum = if w_sum == 0.0 { 1.0 } else { w_sum };
let half_life = cfg.decay_half_life_days.max(f64::MIN_POSITIVE);
let mut ranked: Vec<RankedMemory> = hits
.iter()
.map(|(i, sim)| {
let m = &mems[*i];
let age_secs = (now - m.last_accessed_at).max(0);
let age_days = age_secs as f64 / 86_400.0;
let recency = 0.5f64.powf(age_days / half_life);
let score = (cfg.weight_similarity * f64::from(*sim)
+ cfg.weight_recency * recency
+ cfg.weight_salience * m.salience)
/ w_sum;
RankedMemory {
memory: m.clone(),
score,
}
})
.collect();
ranked.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(Ordering::Equal)
.then_with(|| b.memory.created_at.cmp(&a.memory.created_at))
.then_with(|| a.memory.id.cmp(&b.memory.id))
});
let hit_ids: Vec<String> = ranked.iter().map(|r| r.memory.id.clone()).collect();
store.mark_accessed(&hit_ids, now).await?;
Ok(ranked)
}
#[allow(dead_code)]
pub async fn reembed_pending(
store: &dyn VectorStore,
embedder: &dyn EmbeddingProvider,
cfg: &MemoryConfig,
scope: &str,
) -> Result<usize, MemoryError> {
let mems = store.active(scope).await?;
let current_model = embedder.model_id();
let pending: Vec<&Memory> = mems
.iter()
.filter(|m| {
m.embedding.is_empty() || m.model_id != current_model || m.dim != embedder.dim()
})
.collect();
if pending.is_empty() {
return Ok(0);
}
let batch_size = cfg.reembed_batch_size.max(1);
let doc_prefix = embedder.document_prefix();
let mut re_embedded = 0usize;
for chunk in pending.chunks(batch_size) {
let texts: Vec<String> = chunk
.iter()
.map(|m| {
if doc_prefix.is_empty() {
m.text.clone()
} else {
format!("{doc_prefix}{}", m.text)
}
})
.collect();
let embeddings = match embedder.embed(&texts).await {
Ok(v) => v,
Err(EmbeddingError::RateLimited) => {
return Ok(re_embedded);
}
Err(e) => return Err(MemoryError::Embedding(e)),
};
for (m, emb) in chunk.iter().zip(embeddings.iter()) {
let dim = emb.len();
store
.update_embedding(&m.id, emb, current_model, dim)
.await?;
}
re_embedded += chunk.len();
}
Ok(re_embedded)
}
async fn embed_query(embedder: &dyn EmbeddingProvider, raw: &str) -> Result<Vec<f32>, MemoryError> {
let prefix = embedder.query_prefix();
let prefixed = if prefix.is_empty() {
raw.to_string()
} else {
format!("{prefix}{raw}")
};
let mut vecs = embedder.embed(&[prefixed]).await?;
vecs.pop().ok_or_else(|| {
MemoryError::Embedding(EmbeddingError::Malformed("empty response for query".into()))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::clock::FixedClock;
use crate::memory::config::MemoryConfig;
use crate::memory::error::EmbeddingError;
use crate::memory::store::{Memory, SqliteVectorStore};
use crate::memory::MemoryKind;
use crate::system::database::EncryptedSqliteMemory;
use async_trait::async_trait;
fn bow(text: &str, dim: usize) -> Vec<f32> {
let mut v = vec![0f32; dim];
for w in text.to_lowercase().split_whitespace() {
let h = w
.bytes()
.fold(0usize, |a, b| a.wrapping_mul(31).wrapping_add(b as usize))
% dim;
v[h] += 1.0;
}
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if n > 0.0 {
for x in &mut v {
*x /= n;
}
}
v
}
struct FakeEmbedder {
dim: usize,
model: String,
}
#[async_trait]
impl EmbeddingProvider for FakeEmbedder {
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Ok(texts.iter().map(|t| bow(t, self.dim)).collect())
}
fn model_id(&self) -> &str {
&self.model
}
fn dim(&self) -> usize {
self.dim
}
fn query_prefix(&self) -> &str {
""
}
fn document_prefix(&self) -> &str {
""
}
}
fn make_test_store() -> (tempfile::NamedTempFile, SqliteVectorStore) {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mem = EncryptedSqliteMemory::new(tmp.path().to_path_buf(), "pw".into()).unwrap();
let store = SqliteVectorStore::new(mem.shared_conn(), mem.data_key()).unwrap();
(tmp, store)
}
async fn insert_mem(
store: &SqliteVectorStore,
id: &str,
text: &str,
embedder: &FakeEmbedder,
created_at: i64,
last_accessed_at: i64,
salience: f64,
) {
let emb = bow(text, embedder.dim);
let m = Memory {
id: id.into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: text.into(),
embedding: emb,
model_id: embedder.model_id().into(),
dim: embedder.dim(),
created_at,
salience,
access_count: 0,
last_accessed_at,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
store.insert(&m).await.unwrap();
}
#[tokio::test]
async fn test_relevant_memory_surfaces_above_distractors() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
top_k: 5,
..MemoryConfig::default()
};
insert_mem(
&store,
"target",
"context budget is 8000 tokens",
&emb,
1000,
1000,
0.5,
)
.await;
for i in 0..20 {
insert_mem(
&store,
&format!("d{i}"),
&format!("unrelated network latency distractor {i}"),
&emb,
900,
900,
0.3,
)
.await;
}
let results = recall(&store, &emb, &clock, &cfg, "what is the budget", 0, "root")
.await
.unwrap();
assert!(
!results.is_empty(),
"recall must return at least one result"
);
assert!(
results.iter().any(|r| r.memory.id == "target"),
"the target memory should surface in top-{} (SC-06)",
cfg.top_k
);
}
#[tokio::test]
async fn test_recency_breaks_ties_deterministically() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let now = 1_000_000i64;
let clock = FixedClock::new(now);
let cfg = MemoryConfig {
top_k: 2,
..MemoryConfig::default()
};
insert_mem(&store, "old", "the budget policy", &emb, 500, 100, 0.5).await;
insert_mem(&store, "new", "the budget policy", &emb, 1000, 900_000, 0.5).await;
let r1 = recall(&store, &emb, &clock, &cfg, "budget policy", 0, "root")
.await
.unwrap();
assert_eq!(r1.len(), 2, "both memories should be returned");
assert_eq!(
r1[0].memory.id, "new",
"more recently accessed memory should rank first (SC-07)"
);
let r2 = recall(&store, &emb, &clock, &cfg, "budget policy", 0, "root")
.await
.unwrap();
assert_eq!(
r2[0].memory.id, r1[0].memory.id,
"R-06: deterministic ordering across consecutive calls"
);
}
#[tokio::test]
async fn test_model_dim_filter_excludes_foreign_vectors() {
let (_tmp, store) = make_test_store();
let emb_a = FakeEmbedder {
dim: 32,
model: "model-A".into(),
};
let emb_b = FakeEmbedder {
dim: 32,
model: "model-B".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
top_k: 5,
..MemoryConfig::default()
};
insert_mem(&store, "m_a", "budget policy", &emb_a, 1000, 1000, 0.5).await;
insert_mem(&store, "m_b", "budget policy", &emb_b, 1000, 1000, 0.5).await;
let results = recall(&store, &emb_b, &clock, &cfg, "budget", 0, "root")
.await
.unwrap();
assert!(
results.iter().all(|r| r.memory.model_id == "model-B"),
"all returned memories must have the current model_id"
);
assert!(
results.iter().any(|r| r.memory.id == "m_b"),
"model-B memory should appear in results"
);
assert!(
!results.iter().any(|r| r.memory.id == "m_a"),
"model-A memory must be excluded (D-06)"
);
}
#[tokio::test]
async fn test_recall_is_public_and_independent_of_assembler() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
top_k: 3,
..MemoryConfig::default()
};
insert_mem(
&store,
"m1",
"the context budget is 8000 tokens",
&emb,
1000,
1000,
0.5,
)
.await;
insert_mem(&store, "m2", "unrelated distractor", &emb, 1000, 1000, 0.3).await;
let results = recall(&store, &emb, &clock, &cfg, "budget tokens", 0, "root")
.await
.unwrap();
assert!(
!results.is_empty(),
"recall must return at least one result (SC-40)"
);
assert!(
results[0].score > 0.0,
"ranked memories must have a positive score"
);
assert_eq!(
results[0].memory.id, "m1",
"the relevant memory should be ranked first"
);
}
#[tokio::test]
async fn test_pending_vectors_get_reembedded_with_current_model() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let cfg = MemoryConfig {
reembed_batch_size: 10,
..MemoryConfig::default()
};
let pending = Memory {
id: "pending".into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: "some text to re-embed".into(),
embedding: vec![], model_id: "".into(),
dim: 0,
created_at: 1000,
salience: 0.3,
access_count: 0,
last_accessed_at: 1000,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
store.insert(&pending).await.unwrap();
let count = reembed_pending(&store, &emb, &cfg, "root").await.unwrap();
assert_eq!(count, 1, "should re-embed 1 pending memory");
let updated = store.get("pending").await.unwrap().unwrap();
assert!(
!updated.embedding.is_empty(),
"embedding must be non-empty after reembed_pending"
);
assert_eq!(
updated.model_id,
emb.model_id(),
"model_id must match the embedder after re-embedding"
);
assert_eq!(
updated.dim,
emb.dim(),
"dim must match the embedder after re-embedding"
);
}
#[tokio::test]
async fn test_dim_mismatch_triggers_reembed() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 64,
model: "fake".into(),
};
let cfg = MemoryConfig {
reembed_batch_size: 10,
..MemoryConfig::default()
};
let stale_vec: Vec<f32> = vec![1.0 / 4.0; 16]; store
.insert(&crate::memory::store::Memory {
id: "stale_dim".into(),
session_id: "s".into(),
kind: crate::memory::MemoryKind::Episodic,
text: "some text".into(),
embedding: stale_vec,
model_id: "fake".into(), dim: 16, created_at: 1_000,
last_accessed_at: 1_000,
salience: 0.5,
access_count: 0,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
})
.await
.unwrap();
let count = reembed_pending(&store, &emb, &cfg, "root").await.unwrap();
assert_eq!(count, 1, "C1: dim-mismatch memory must be re-embedded");
let updated = store.get("stale_dim").await.unwrap().unwrap();
assert_eq!(
updated.dim,
emb.dim(),
"C1: dim must match the embedder after re-embedding (was 16, expected 64)"
);
assert_eq!(
updated.embedding.len(),
64,
"C1: embedding length must equal the new dim"
);
}
#[tokio::test]
async fn test_index_only_holds_active_set() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000_000);
let cfg = MemoryConfig {
top_k: 10,
..MemoryConfig::default()
};
insert_mem(
&store,
"active",
"the budget is 8000",
&emb,
1000,
1000,
0.5,
)
.await;
insert_mem(
&store,
"evicted",
"budget limit exceeded",
&emb,
1000,
1000,
0.5,
)
.await;
store.set_evicted("evicted", Some(999)).await.unwrap();
insert_mem(&store, "old_fact", "budget was 6000", &emb, 900, 900, 0.5).await;
insert_mem(
&store,
"new_fact",
"budget is 8000 now",
&emb,
1000,
1000,
0.5,
)
.await;
store.set_superseded("old_fact", "new_fact").await.unwrap();
let results = recall(&store, &emb, &clock, &cfg, "what is the budget", 0, "root")
.await
.unwrap();
assert!(
!results.iter().any(|r| r.memory.id == "evicted"),
"evicted memory must not appear (CP2-E)"
);
assert!(
!results.iter().any(|r| r.memory.id == "old_fact"),
"superseded memory must not appear (CP2-E)"
);
assert!(
results.iter().any(|r| r.memory.id == "active"),
"active memory must appear"
);
assert!(
results.iter().any(|r| r.memory.id == "new_fact"),
"the superseding memory (new_fact) must appear"
);
}
}