use crate::memory::clock::Clock;
use crate::memory::config::MemoryConfig;
use crate::memory::error::MemoryError;
use crate::memory::store::{Memory, VectorStore};
use crate::memory::MemoryKind;
#[allow(dead_code)]
pub fn strength(m: &Memory, now: i64, cfg: &MemoryConfig) -> f64 {
let age_secs = (now - m.last_accessed_at).max(0) as f64;
let age_days = age_secs / 86_400.0;
let half_life = cfg.decay_half_life_days.max(f64::MIN_POSITIVE);
let recency = 0.5_f64.powf(age_days / half_life);
let reinforcement = if cfg.access_saturation_cap == 0 {
0.0
} else {
m.access_count.min(cfg.access_saturation_cap) as f64 / cfg.access_saturation_cap as f64
};
let salience = m.salience;
let w_rec = cfg.weight_recency;
let w_sal = cfg.weight_salience;
let divisor = w_rec + 1.0 + w_sal;
(w_rec * recency + reinforcement + w_sal * salience) / divisor
}
fn is_protected(m: &Memory, cfg: &MemoryConfig) -> bool {
m.kind == MemoryKind::Preference || m.salience >= cfg.protect_salience_threshold
}
async fn apply_eviction(
store: &dyn VectorStore,
id: &str,
now: i64,
cfg: &MemoryConfig,
) -> Result<(), MemoryError> {
if cfg.evicted_retention_days == 0 {
store.hard_delete(&[id.to_string()]).await
} else {
store.set_evicted(id, Some(now)).await
}
}
#[allow(dead_code)]
pub async fn run_forgetting(
store: &dyn VectorStore,
clock: &dyn Clock,
cfg: &MemoryConfig,
scope: &str,
) -> Result<usize, MemoryError> {
let now = clock.now();
let active = store.active(scope).await?;
let mut candidates: Vec<(String, f64)> = active
.iter()
.filter(|m| !is_protected(m, cfg))
.filter_map(|m| {
let s = strength(m, now, cfg);
if s < cfg.forget_strength_threshold {
Some((m.id.clone(), s))
} else {
None
}
})
.collect();
candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut count = 0usize;
for (id, _) in candidates.iter().take(cfg.max_evictions_per_pass) {
apply_eviction(store, id, now, cfg).await?;
count += 1;
}
Ok(count)
}
#[allow(dead_code)]
pub async fn enforce_size_cap(
store: &dyn VectorStore,
clock: &dyn Clock,
cfg: &MemoryConfig,
scope: &str,
) -> Result<usize, MemoryError> {
if cfg.max_records == 0 {
return Ok(0); }
let now = clock.now();
let active = store.active(scope).await?;
let total = active.len();
if total <= cfg.max_records {
return Ok(0);
}
let mut unprotected: Vec<(String, f64)> = active
.iter()
.filter(|m| !is_protected(m, cfg))
.map(|m| (m.id.clone(), strength(m, now, cfg)))
.collect();
unprotected.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut protected: Vec<(String, f64)> = active
.iter()
.filter(|m| is_protected(m, cfg))
.map(|m| (m.id.clone(), strength(m, now, cfg)))
.collect();
protected.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut remaining = total;
let mut count = 0usize;
for (id, _) in &unprotected {
if remaining <= cfg.max_records {
break;
}
apply_eviction(store, id, now, cfg).await?;
count += 1;
remaining -= 1;
}
if remaining > cfg.max_records {
eprintln!(
"WARN [magi-memory] enforce_size_cap: evicting protected memories as last resort \
(active={remaining}, cap={}, CP2-Y)",
cfg.max_records
);
for (id, _) in &protected {
if remaining <= cfg.max_records {
break;
}
apply_eviction(store, id, now, cfg).await?;
count += 1;
remaining -= 1;
}
}
Ok(count)
}
#[allow(dead_code)]
pub async fn purge_expired_archives(
store: &dyn VectorStore,
clock: &dyn Clock,
cfg: &MemoryConfig,
) -> Result<usize, MemoryError> {
if cfg.evicted_retention_days <= 0 {
return Ok(0);
}
let now = clock.now();
let retention_secs = cfg.evicted_retention_days.saturating_mul(86_400);
let archived = store.archived().await?;
let expired_ids: Vec<String> = archived
.into_iter()
.filter_map(|m| {
m.evicted_at.and_then(|evicted_at| {
if now - evicted_at > retention_secs {
Some(m.id)
} else {
None
}
})
})
.collect();
let count = expired_ids.len();
if !expired_ids.is_empty() {
store.hard_delete(&expired_ids).await?;
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::clock::FixedClock;
use crate::memory::config::MemoryConfig;
use crate::memory::error::EmbeddingError;
use crate::memory::retrieval::recall;
use crate::memory::store::{Memory, SqliteVectorStore, VectorStore};
use crate::memory::MemoryKind;
use crate::system::database::EncryptedSqliteMemory;
use async_trait::async_trait;
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)
}
fn weak_mem(id: &str, kind: MemoryKind, salience: f64) -> Memory {
Memory {
id: id.into(),
session_id: "s".into(),
kind,
text: "weak memory".into(),
embedding: vec![],
model_id: "".into(),
dim: 0,
created_at: 0,
salience,
access_count: 0,
last_accessed_at: 0, superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
}
}
fn mem(last_accessed_at: i64, salience: f64, access_count: u64) -> Memory {
Memory {
id: "m".into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: "t".into(),
embedding: vec![],
model_id: "".into(),
dim: 0,
created_at: last_accessed_at,
salience,
access_count,
last_accessed_at,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
}
}
#[test]
fn test_older_memory_decays_below_recent() {
let cfg = MemoryConfig::default();
let recent = mem(1_000_000, 0.5, 0);
let old = mem(1_000_000 - 60 * 86_400, 0.5, 0); assert!(strength(&recent, 1_000_000, &cfg) > strength(&old, 1_000_000, &cfg));
}
#[test]
fn test_decay_is_deterministic_and_clock_driven() {
let cfg = MemoryConfig::default();
let m = mem(0, 0.5, 0);
assert_eq!(strength(&m, 100, &cfg), strength(&m, 100, &cfg)); assert!(strength(&m, 0, &cfg) > strength(&m, 90 * 86_400, &cfg)); }
#[test]
fn test_strength_is_normalized_to_unit_interval() {
let cfg = MemoryConfig::default();
for m in [mem(0, 0.0, 0), mem(0, 1.0, u64::MAX), mem(0, 0.5, 10)] {
let s = strength(&m, 365 * 86_400, &cfg);
assert!((0.0..=1.0).contains(&s), "strength out of [0,1]: {s}");
}
}
#[test]
fn test_access_count_overflow_safe_and_reinforcement_bounded() {
let cfg = MemoryConfig::default();
let huge = mem(0, 0.3, u64::MAX);
let capped = mem(0, 0.3, cfg.access_saturation_cap);
let _ = strength(&huge, 1_000, &cfg); assert!((strength(&huge, 1_000, &cfg) - strength(&capped, 1_000, &cfg)).abs() < 1e-9);
}
#[test]
fn test_negative_age_is_clamped() {
let cfg = MemoryConfig::default();
let m = mem(1_000, 0.5, 0);
assert_eq!(strength(&m, 500, &cfg), strength(&m, 1_000, &cfg));
}
#[tokio::test]
async fn test_sub_threshold_memory_is_evicted_and_excluded() {
let (_tmp, store) = make_test_store();
let now_secs = 1_000i64 * 86_400;
let clock = FixedClock::new(now_secs);
let cfg = MemoryConfig {
forget_strength_threshold: 0.5, evicted_retention_days: -1, ..MemoryConfig::default()
};
let weak = weak_mem("weak", MemoryKind::Episodic, 0.0);
assert!(
strength(&weak, now_secs, &cfg) < cfg.forget_strength_threshold,
"test setup: weak memory must be below threshold"
);
store.insert(&weak).await.unwrap();
assert_eq!(store.active("root").await.unwrap().len(), 1);
let evicted = run_forgetting(&store, &clock, &cfg, "root").await.unwrap();
assert_eq!(evicted, 1, "exactly 1 memory should be evicted (SC-12)");
assert!(
store.active("root").await.unwrap().is_empty(),
"evicted memory must not appear in active set (SC-12)"
);
}
#[tokio::test]
async fn test_preference_and_high_salience_never_evicted_by_decay() {
let (_tmp, store) = make_test_store();
let now_secs = 1_000i64 * 86_400;
let clock = FixedClock::new(now_secs);
let cfg = MemoryConfig {
forget_strength_threshold: 0.99, evicted_retention_days: -1,
..MemoryConfig::default()
};
let evictable = weak_mem("evictable", MemoryKind::Episodic, 0.0);
let pref = weak_mem("pref", MemoryKind::Preference, 0.0);
let hi_sal = weak_mem("hi_sal", MemoryKind::Episodic, 0.95);
store.insert(&evictable).await.unwrap();
store.insert(&pref).await.unwrap();
store.insert(&hi_sal).await.unwrap();
run_forgetting(&store, &clock, &cfg, "root").await.unwrap();
let active_ids: Vec<String> = store
.active("root")
.await
.unwrap()
.into_iter()
.map(|m| m.id)
.collect();
assert!(
!active_ids.contains(&"evictable".to_string()),
"non-protected memory must be evicted (SC-13 control)"
);
assert!(
active_ids.contains(&"pref".to_string()),
"Preference memory must NOT be evicted (SC-13)"
);
assert!(
active_ids.contains(&"hi_sal".to_string()),
"high-salience memory must NOT be evicted (SC-13)"
);
}
#[tokio::test]
async fn test_retention_modes_archive_immediate_and_delayed() {
{
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000 * 86_400);
let cfg = MemoryConfig {
forget_strength_threshold: 0.99,
evicted_retention_days: -1,
..MemoryConfig::default()
};
store
.insert(&weak_mem("a", MemoryKind::Episodic, 0.0))
.await
.unwrap();
run_forgetting(&store, &clock, &cfg, "root").await.unwrap();
assert!(
store.active("root").await.unwrap().is_empty(),
"Case A: evicted memory must not be in active"
);
let ar = store.archived().await.unwrap();
assert_eq!(
ar.len(),
1,
"Case A: evicted memory must appear in archived()"
);
assert_eq!(ar[0].id, "a");
}
{
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000 * 86_400);
let cfg = MemoryConfig {
forget_strength_threshold: 0.99,
evicted_retention_days: 0,
..MemoryConfig::default()
};
store
.insert(&weak_mem("b", MemoryKind::Episodic, 0.0))
.await
.unwrap();
run_forgetting(&store, &clock, &cfg, "root").await.unwrap();
assert!(
store.active("root").await.unwrap().is_empty(),
"Case B: evicted memory must not be in active"
);
assert!(
store.get("b").await.unwrap().is_none(),
"Case B: hard-deleted memory must not be retrievable via get()"
);
}
{
let (_tmp, store) = make_test_store();
let start = 1_000i64 * 86_400;
let clock = FixedClock::new(start);
let retention_days = 7i64;
let cfg = MemoryConfig {
forget_strength_threshold: 0.99,
evicted_retention_days: retention_days,
..MemoryConfig::default()
};
store
.insert(&weak_mem("c", MemoryKind::Episodic, 0.0))
.await
.unwrap();
run_forgetting(&store, &clock, &cfg, "root").await.unwrap();
assert_eq!(
store.archived().await.unwrap().len(),
1,
"Case C: archived immediately after eviction"
);
clock.advance_days(3.0); let purged = purge_expired_archives(&store, &clock, &cfg).await.unwrap();
assert_eq!(purged, 0, "Case C: must not purge within retention window");
assert_eq!(
store.archived().await.unwrap().len(),
1,
"Case C: row still archived within window"
);
clock.advance_days(5.0); let purged = purge_expired_archives(&store, &clock, &cfg).await.unwrap();
assert_eq!(
purged, 1,
"Case C: must purge after retention window (SC-34)"
);
assert!(
store.get("c").await.unwrap().is_none(),
"Case C: hard-deleted after retention window"
);
}
}
#[tokio::test]
async fn test_size_cap_evicts_lowest_strength_unprotected_first() {
let (_tmp, store) = make_test_store();
let now_secs = 1_000i64 * 86_400;
let clock = FixedClock::new(now_secs);
let cfg = MemoryConfig {
max_records: 2,
evicted_retention_days: -1,
..MemoryConfig::default()
};
for i in 0..5usize {
let last = (i as i64) * 10 * 86_400; let m = Memory {
id: format!("m{i}"),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: "t".into(),
embedding: vec![],
model_id: "".into(),
dim: 0,
created_at: last,
salience: 0.0,
access_count: 0,
last_accessed_at: last,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
store.insert(&m).await.unwrap();
}
let evicted = enforce_size_cap(&store, &clock, &cfg, "root")
.await
.unwrap();
assert_eq!(evicted, 3, "must evict 3 to reach cap of 2 (SC-15)");
let remaining_ids: Vec<String> = store
.active("root")
.await
.unwrap()
.into_iter()
.map(|m| m.id)
.collect();
assert_eq!(remaining_ids.len(), 2, "exactly 2 should remain after cap");
assert!(
remaining_ids.contains(&"m3".to_string()),
"m3 (second-newest) must survive (SC-15)"
);
assert!(
remaining_ids.contains(&"m4".to_string()),
"m4 (newest) must survive (SC-15)"
);
}
#[tokio::test]
async fn test_size_cap_is_hard_ceiling_even_against_protected_flood() {
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000 * 86_400);
let cfg = MemoryConfig {
max_records: 2,
evicted_retention_days: -1,
..MemoryConfig::default()
};
for i in 0..5usize {
store
.insert(&weak_mem(&format!("p{i}"), MemoryKind::Preference, 0.0))
.await
.unwrap();
}
let evicted = enforce_size_cap(&store, &clock, &cfg, "root")
.await
.unwrap();
assert_eq!(
evicted, 3,
"CP2-Y: must evict protected memories as last resort"
);
assert_eq!(
store.active("root").await.unwrap().len(),
2,
"hard ceiling must be respected even against a protected flood"
);
}
#[tokio::test]
async fn test_clock_jump_does_not_mass_evict() {
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000 * 86_400);
let cap = 5usize;
let cfg = MemoryConfig {
forget_strength_threshold: 0.99, max_evictions_per_pass: cap,
evicted_retention_days: -1,
..MemoryConfig::default()
};
for i in 0..20usize {
store
.insert(&weak_mem(&format!("m{i}"), MemoryKind::Episodic, 0.0))
.await
.unwrap();
}
let evicted = run_forgetting(&store, &clock, &cfg, "root").await.unwrap();
assert!(
evicted <= cap,
"must not evict more than max_evictions_per_pass (CP2-AD)"
);
assert!(evicted > 0, "at least one memory should be evicted");
assert_eq!(
evicted, cap,
"when all memories are sub-threshold, exactly max_evictions_per_pass should be evicted"
);
}
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 crate::memory::embedding::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 {
""
}
}
#[tokio::test]
async fn test_soft_supersession_recency_ranks_newer_first() {
let (_tmp, store) = make_test_store();
let dim = 32usize;
let emb = FakeEmbedder {
dim,
model: "fake".into(),
};
let now_secs = 1_000_000i64;
let clock = FixedClock::new(now_secs);
let cfg = MemoryConfig {
top_k: 2,
..MemoryConfig::default()
};
let subject_text = "the api budget is 8000 tokens";
let embedding = bow(subject_text, dim);
let f1 = Memory {
id: "F1".into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: subject_text.into(),
embedding: embedding.clone(),
model_id: "fake".into(),
dim,
created_at: 100,
salience: 0.5,
access_count: 0,
last_accessed_at: 100, superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
let f2 = Memory {
id: "F2".into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: subject_text.into(),
embedding: embedding.clone(),
model_id: "fake".into(),
dim,
created_at: 900_000,
salience: 0.5,
access_count: 0,
last_accessed_at: 900_000, superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
store.insert(&f1).await.unwrap();
store.insert(&f2).await.unwrap();
let results = recall(&store, &emb, &clock, &cfg, subject_text, 0, "root")
.await
.unwrap();
assert_eq!(results.len(), 2, "both memories should be returned");
assert_eq!(
results[0].memory.id, "F2",
"SC-14: the more recent fact (F2) must rank above the older (F1) via recency"
);
}
#[test]
fn test_strength_with_zero_half_life_returns_finite() {
let cfg = MemoryConfig {
decay_half_life_days: 0.0,
..MemoryConfig::default()
};
let m = mem(0, 0.5, 0);
let s = strength(&m, 1000, &cfg);
assert!(
s.is_finite(),
"B1: strength() with half_life=0.0 must be finite, got {s}"
);
assert!(
!s.is_nan(),
"B1: strength() must not be NaN with half_life=0.0"
);
}
#[tokio::test]
async fn test_purge_expired_archives_with_large_retention_does_not_panic() {
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000 * 86_400);
let cfg = MemoryConfig {
evicted_retention_days: i64::MAX / 86_400 + 1,
..MemoryConfig::default()
};
let result = purge_expired_archives(&store, &clock, &cfg).await;
assert!(
result.is_ok(),
"B2: purge must not panic on large retention: {result:?}"
);
}
#[tokio::test]
async fn test_recall_and_forgetting_are_serialized() {
let (_tmp, store) = make_test_store();
let store = std::sync::Arc::new(store);
let now_secs = 1_000i64 * 86_400;
let clock = std::sync::Arc::new(FixedClock::new(now_secs));
let cfg = MemoryConfig {
forget_strength_threshold: 0.99, max_evictions_per_pass: 20,
evicted_retention_days: -1,
..MemoryConfig::default()
};
for i in 0..20usize {
store
.insert(&weak_mem(&format!("m{i}"), MemoryKind::Episodic, 0.0))
.await
.unwrap();
}
let store1 = store.clone();
let store2 = store.clone();
let clock1 = clock.clone();
let cfg1 = cfg.clone();
let h_forget = tokio::spawn(async move {
run_forgetting(store1.as_ref(), clock1.as_ref(), &cfg1, "root").await
});
let h_active = tokio::spawn(async move { store2.active("root").await });
let (r_forget, r_active) = tokio::join!(h_forget, h_active);
let evicted = r_forget.unwrap().unwrap();
let _ = r_active.unwrap().unwrap();
assert!(
evicted > 0,
"at least one memory should have been evicted (CP2-D)"
);
let remaining = store.active("root").await.unwrap();
assert!(
remaining.len() + evicted <= 20,
"active + evicted must not exceed the total (store consistency, CP2-D)"
);
}
}