Merge pull request #2387 from reidliu41/time-decay

feat(memory): add time-decay scoring with Core evergreen exemption
This commit is contained in:
Argenis 2026-03-01 15:12:28 -05:00 committed by GitHub
commit 0787a9cebe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 170 additions and 4 deletions

View File

@ -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 {

View File

@ -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<String> {
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) {

152
src/memory/decay.rs Normal file
View File

@ -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<f64>, 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));
}
}

View File

@ -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;