use crate::{
ComposeError, MEMORY_CONTENT_PROP, MEMORY_KINDS, MEMORY_KIND_DEFAULT, MEMORY_KIND_EPISODIC,
MEMORY_KIND_PROCEDURAL, MEMORY_KIND_PROP, MEMORY_LABEL, MEMORY_TOMBSTONE_PROPS,
};
use topodb::{Db, NodeId, Op, PropValue, ScopeSet};
pub const LIFECYCLE_DEFAULT_LIMIT: usize = 20;
pub const LIFECYCLE_HALF_LIFE_EPISODIC_DAYS: f64 = 14.0;
pub const LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS: f64 = 120.0;
pub const LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS: f64 = 365.0;
const DAY_MS: f64 = 86_400_000.0;
#[derive(Debug, Clone)]
pub struct LifecycleParams {
pub limit: usize,
pub half_life_episodic_ms: i64,
pub half_life_semantic_ms: i64,
pub half_life_procedural_ms: i64,
}
impl Default for LifecycleParams {
fn default() -> Self {
Self {
limit: LIFECYCLE_DEFAULT_LIMIT,
half_life_episodic_ms: (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
half_life_semantic_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
half_life_procedural_ms: (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct LifecycleCandidate {
pub id: String,
pub content: String,
pub kind: String,
pub created_at: i64,
pub last_accessed_at: i64,
pub access_count: u64,
pub staleness: f64,
}
pub fn staleness(age_ms: i64, half_life_ms: i64, access_count: u64) -> f64 {
let age = age_ms.max(0) as f64;
(age / half_life_ms as f64) / (std::f64::consts::E + access_count as f64).ln()
}
pub fn lifecycle_candidates(
db: &Db,
scopes: &ScopeSet,
params: &LifecycleParams,
now_ms: i64,
) -> Result<Vec<LifecycleCandidate>, ComposeError> {
if params.limit == 0 {
return Err(ComposeError::Invalid("limit must be at least 1".into()));
}
for (kind, hl) in [
(MEMORY_KIND_EPISODIC, params.half_life_episodic_ms),
(crate::MEMORY_KIND_SEMANTIC, params.half_life_semantic_ms),
(MEMORY_KIND_PROCEDURAL, params.half_life_procedural_ms),
] {
if hl <= 0 {
return Err(ComposeError::Invalid(format!(
"half-lives must be positive, got {hl}ms for {kind}"
)));
}
}
let mut out: Vec<LifecycleCandidate> = Vec::new();
for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
if MEMORY_TOMBSTONE_PROPS
.iter()
.any(|p| node.props.contains_key(*p))
{
continue;
}
let content = match node.props.get(MEMORY_CONTENT_PROP) {
Some(PropValue::Str(c)) => c.clone(),
_ => continue,
};
let kind = match node.props.get(MEMORY_KIND_PROP) {
Some(PropValue::Str(k)) if MEMORY_KINDS.contains(&k.as_str()) => k.clone(),
_ => MEMORY_KIND_DEFAULT.to_string(),
};
let half_life_ms = if kind == MEMORY_KIND_EPISODIC {
params.half_life_episodic_ms
} else if kind == MEMORY_KIND_PROCEDURAL {
params.half_life_procedural_ms
} else {
params.half_life_semantic_ms
};
let stats = db.access_stats(scopes, node.id)?.unwrap_or_default();
let created_at = node.id.timestamp_ms() as i64;
let age_ms = now_ms - stats.last_accessed_at.max(created_at);
out.push(LifecycleCandidate {
id: node.id.to_string(),
content,
kind,
created_at,
last_accessed_at: stats.last_accessed_at,
access_count: stats.access_count,
staleness: staleness(age_ms, half_life_ms, stats.access_count),
});
}
out.sort_by(|a, b| {
b.staleness
.partial_cmp(&a.staleness)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
out.truncate(params.limit);
Ok(out)
}
pub fn plan_purge(
db: &Db,
scopes: &ScopeSet,
tombstoned_before_ms: i64,
) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
if tombstoned_before_ms <= 0 {
return Err(ComposeError::Invalid(
"tombstoned-before must be a positive unix-ms timestamp".into(),
));
}
let mut doomed: Vec<NodeId> = Vec::new();
for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
let qualifies = MEMORY_TOMBSTONE_PROPS.iter().any(
|p| matches!(node.props.get(*p), Some(PropValue::Int(t)) if *t < tombstoned_before_ms),
);
if qualifies {
doomed.push(node.id);
}
}
doomed.sort();
let ids = doomed.iter().map(|id| id.to_string()).collect();
let ops = doomed.into_iter().map(|id| Op::RemoveNode { id }).collect();
Ok((ops, ids))
}