From 1431e9e864f1411f2589042a9497f74284381b5a Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Sun, 1 Mar 2026 19:25:54 +0800 Subject: [PATCH] feat(memory): add time-decay scoring with Core evergreen exemption --- src/agent/loop_/context.rs | 11 ++- src/agent/memory_loader.rs | 10 ++- src/memory/decay.rs | 152 +++++++++++++++++++++++++++++++++++++ src/memory/mod.rs | 1 + 4 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 src/memory/decay.rs diff --git a/src/agent/loop_/context.rs b/src/agent/loop_/context.rs index cc2564619..bb7f127e8 100644 --- a/src/agent/loop_/context.rs +++ b/src/agent/loop_/context.rs @@ -1,9 +1,13 @@ -use crate::memory::{self, Memory}; +use crate::memory::{self, decay, Memory}; use std::fmt::Write; +/// Default half-life (days) for time decay in context building. +const CONTEXT_DECAY_HALF_LIFE_DAYS: f64 = 7.0; + /// Build context preamble by searching memory for relevant entries. /// Entries with a hybrid score below `min_relevance_score` are dropped to /// prevent unrelated memories from bleeding into the conversation. +/// Core memories are exempt from time decay (evergreen). pub(super) async fn build_context( mem: &dyn Memory, user_msg: &str, @@ -13,7 +17,10 @@ pub(super) async fn build_context( let mut context = String::new(); // Pull relevant memories for this message - if let Ok(entries) = mem.recall(user_msg, 5, session_id).await { + if let Ok(mut entries) = mem.recall(user_msg, 5, session_id).await { + // Apply time decay: older non-Core memories score lower + decay::apply_time_decay(&mut entries, CONTEXT_DECAY_HALF_LIFE_DAYS); + let relevant: Vec<_> = entries .iter() .filter(|e| match e.score { diff --git a/src/agent/memory_loader.rs b/src/agent/memory_loader.rs index bb7bfb5c1..783650d64 100644 --- a/src/agent/memory_loader.rs +++ b/src/agent/memory_loader.rs @@ -1,7 +1,10 @@ -use crate::memory::{self, Memory}; +use crate::memory::{self, decay, Memory}; use async_trait::async_trait; use std::fmt::Write; +/// Default half-life (days) for time decay in memory loading. +const LOADER_DECAY_HALF_LIFE_DAYS: f64 = 7.0; + #[async_trait] pub trait MemoryLoader: Send + Sync { async fn load_context(&self, memory: &dyn Memory, user_message: &str) @@ -38,11 +41,14 @@ impl MemoryLoader for DefaultMemoryLoader { memory: &dyn Memory, user_message: &str, ) -> anyhow::Result { - let entries = memory.recall(user_message, self.limit, None).await?; + let mut entries = memory.recall(user_message, self.limit, None).await?; if entries.is_empty() { return Ok(String::new()); } + // Apply time decay: older non-Core memories score lower + decay::apply_time_decay(&mut entries, LOADER_DECAY_HALF_LIFE_DAYS); + let mut context = String::from("[Memory context]\n"); for entry in entries { if memory::is_assistant_autosave_key(&entry.key) { diff --git a/src/memory/decay.rs b/src/memory/decay.rs new file mode 100644 index 000000000..7fa9b1dfc --- /dev/null +++ b/src/memory/decay.rs @@ -0,0 +1,152 @@ +use super::traits::{MemoryCategory, MemoryEntry}; +use chrono::{DateTime, Utc}; + +/// Default half-life in days for time-decay scoring. +/// After this many days, a non-Core memory's score drops to 50%. +const DEFAULT_HALF_LIFE_DAYS: f64 = 7.0; + +/// Apply exponential time decay to memory entry scores. +/// +/// - `Core` memories are exempt ("evergreen") — their scores are never decayed. +/// - Entries without a parseable RFC3339 timestamp are left unchanged. +/// - Entries without a score (`None`) are left unchanged. +/// +/// Decay formula: `score * 2^(-age_days / half_life_days)` +pub fn apply_time_decay(entries: &mut [MemoryEntry], half_life_days: f64) { + let half_life = if half_life_days <= 0.0 { + DEFAULT_HALF_LIFE_DAYS + } else { + half_life_days + }; + + let now = Utc::now(); + + for entry in entries.iter_mut() { + // Core memories are evergreen — never decay + if entry.category == MemoryCategory::Core { + continue; + } + + let score = match entry.score { + Some(s) => s, + None => continue, + }; + + let ts = match DateTime::parse_from_rfc3339(&entry.timestamp) { + Ok(dt) => dt.with_timezone(&Utc), + Err(_) => continue, + }; + + let age_days = now + .signed_duration_since(ts) + .num_seconds() + .max(0) as f64 + / 86_400.0; + + let decay_factor = (-age_days / half_life * std::f64::consts::LN_2).exp(); + entry.score = Some(score * decay_factor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_entry(category: MemoryCategory, score: Option, timestamp: &str) -> MemoryEntry { + MemoryEntry { + id: "1".into(), + key: "test".into(), + content: "value".into(), + category, + timestamp: timestamp.into(), + session_id: None, + score, + } + } + + fn recent_rfc3339() -> String { + Utc::now().to_rfc3339() + } + + fn days_ago_rfc3339(days: i64) -> String { + (Utc::now() - chrono::Duration::days(days)).to_rfc3339() + } + + #[test] + fn core_memories_are_never_decayed() { + let mut entries = vec![make_entry( + MemoryCategory::Core, + Some(0.9), + &days_ago_rfc3339(30), + )]; + apply_time_decay(&mut entries, 7.0); + assert_eq!(entries[0].score, Some(0.9)); + } + + #[test] + fn recent_entry_score_barely_changes() { + let mut entries = vec![make_entry( + MemoryCategory::Conversation, + Some(0.8), + &recent_rfc3339(), + )]; + apply_time_decay(&mut entries, 7.0); + let decayed = entries[0].score.unwrap(); + assert!( + (decayed - 0.8).abs() < 0.01, + "recent entry should barely decay, got {decayed}" + ); + } + + #[test] + fn one_half_life_halves_score() { + let mut entries = vec![make_entry( + MemoryCategory::Conversation, + Some(1.0), + &days_ago_rfc3339(7), + )]; + apply_time_decay(&mut entries, 7.0); + let decayed = entries[0].score.unwrap(); + assert!( + (decayed - 0.5).abs() < 0.05, + "score after one half-life should be ~0.5, got {decayed}" + ); + } + + #[test] + fn two_half_lives_quarters_score() { + let mut entries = vec![make_entry( + MemoryCategory::Conversation, + Some(1.0), + &days_ago_rfc3339(14), + )]; + apply_time_decay(&mut entries, 7.0); + let decayed = entries[0].score.unwrap(); + assert!( + (decayed - 0.25).abs() < 0.05, + "score after two half-lives should be ~0.25, got {decayed}" + ); + } + + #[test] + fn no_score_entry_is_unchanged() { + let mut entries = vec![make_entry( + MemoryCategory::Conversation, + None, + &days_ago_rfc3339(30), + )]; + apply_time_decay(&mut entries, 7.0); + assert_eq!(entries[0].score, None); + } + + #[test] + fn unparseable_timestamp_is_unchanged() { + let mut entries = vec![make_entry( + MemoryCategory::Conversation, + Some(0.9), + "not-a-date", + )]; + apply_time_decay(&mut entries, 7.0); + assert_eq!(entries[0].score, Some(0.9)); + } +} diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 03979bb77..d6227f5a1 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -2,6 +2,7 @@ pub mod backend; pub mod chunker; pub mod cli; pub mod cortex; +pub mod decay; pub mod embeddings; pub mod hybrid; pub mod hygiene;