1use crate::{
8 ComposeError, MEMORY_CONTENT_PROP, MEMORY_KINDS, MEMORY_KIND_DEFAULT, MEMORY_KIND_EPISODIC,
9 MEMORY_KIND_PROCEDURAL, MEMORY_KIND_PROP, MEMORY_LABEL, MEMORY_TOMBSTONE_PROPS,
10};
11use topodb::{Db, NodeId, Op, PropValue, ScopeSet};
12
13pub const LIFECYCLE_DEFAULT_LIMIT: usize = 20;
15pub const LIFECYCLE_HALF_LIFE_EPISODIC_DAYS: f64 = 14.0;
19pub const LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS: f64 = 120.0;
20pub const LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS: f64 = 365.0;
21
22const DAY_MS: f64 = 86_400_000.0;
23
24#[derive(Debug, Clone)]
26pub struct LifecycleParams {
27 pub limit: usize,
29 pub half_life_episodic_ms: i64,
30 pub half_life_semantic_ms: i64,
31 pub half_life_procedural_ms: i64,
32}
33
34impl Default for LifecycleParams {
35 fn default() -> Self {
36 Self {
37 limit: LIFECYCLE_DEFAULT_LIMIT,
38 half_life_episodic_ms: (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
39 half_life_semantic_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
40 half_life_procedural_ms: (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
41 }
42 }
43}
44
45#[derive(Debug, Clone, serde::Serialize)]
48pub struct LifecycleCandidate {
49 pub id: String,
50 pub content: String,
51 pub kind: String,
55 pub created_at: i64,
57 pub last_accessed_at: i64,
60 pub access_count: u64,
61 pub staleness: f64,
62}
63
64pub fn staleness(age_ms: i64, half_life_ms: i64, access_count: u64) -> f64 {
69 let age = age_ms.max(0) as f64;
70 (age / half_life_ms as f64) / (std::f64::consts::E + access_count as f64).ln()
71}
72
73pub fn lifecycle_candidates(
77 db: &Db,
78 scopes: &ScopeSet,
79 params: &LifecycleParams,
80 now_ms: i64,
81) -> Result<Vec<LifecycleCandidate>, ComposeError> {
82 if params.limit == 0 {
83 return Err(ComposeError::Invalid("limit must be at least 1".into()));
84 }
85 for (kind, hl) in [
86 (MEMORY_KIND_EPISODIC, params.half_life_episodic_ms),
87 (crate::MEMORY_KIND_SEMANTIC, params.half_life_semantic_ms),
88 (MEMORY_KIND_PROCEDURAL, params.half_life_procedural_ms),
89 ] {
90 if hl <= 0 {
91 return Err(ComposeError::Invalid(format!(
92 "half-lives must be positive, got {hl}ms for {kind}"
93 )));
94 }
95 }
96
97 let mut out: Vec<LifecycleCandidate> = Vec::new();
98 for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
99 if MEMORY_TOMBSTONE_PROPS
102 .iter()
103 .any(|p| node.props.contains_key(*p))
104 {
105 continue;
106 }
107 let content = match node.props.get(MEMORY_CONTENT_PROP) {
108 Some(PropValue::Str(c)) => c.clone(),
109 _ => continue,
110 };
111 let kind = match node.props.get(MEMORY_KIND_PROP) {
112 Some(PropValue::Str(k)) if MEMORY_KINDS.contains(&k.as_str()) => k.clone(),
113 _ => MEMORY_KIND_DEFAULT.to_string(),
114 };
115 let half_life_ms = if kind == MEMORY_KIND_EPISODIC {
116 params.half_life_episodic_ms
117 } else if kind == MEMORY_KIND_PROCEDURAL {
118 params.half_life_procedural_ms
119 } else {
120 params.half_life_semantic_ms
121 };
122 let stats = db.access_stats(scopes, node.id)?.unwrap_or_default();
123 let created_at = node.id.timestamp_ms() as i64;
124 let age_ms = now_ms - stats.last_accessed_at.max(created_at);
125 out.push(LifecycleCandidate {
126 id: node.id.to_string(),
127 content,
128 kind,
129 created_at,
130 last_accessed_at: stats.last_accessed_at,
131 access_count: stats.access_count,
132 staleness: staleness(age_ms, half_life_ms, stats.access_count),
133 });
134 }
135
136 out.sort_by(|a, b| {
140 b.staleness
141 .partial_cmp(&a.staleness)
142 .unwrap_or(std::cmp::Ordering::Equal)
143 .then_with(|| a.id.cmp(&b.id))
144 });
145 out.truncate(params.limit);
146 Ok(out)
147}
148
149pub fn plan_purge(
158 db: &Db,
159 scopes: &ScopeSet,
160 tombstoned_before_ms: i64,
161) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
162 if tombstoned_before_ms <= 0 {
163 return Err(ComposeError::Invalid(
164 "tombstoned-before must be a positive unix-ms timestamp".into(),
165 ));
166 }
167 let mut doomed: Vec<NodeId> = Vec::new();
168 for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
169 let qualifies = MEMORY_TOMBSTONE_PROPS.iter().any(
170 |p| matches!(node.props.get(*p), Some(PropValue::Int(t)) if *t < tombstoned_before_ms),
171 );
172 if qualifies {
173 doomed.push(node.id);
174 }
175 }
176 doomed.sort();
177 let ids = doomed.iter().map(|id| id.to_string()).collect();
178 let ops = doomed.into_iter().map(|id| Op::RemoveNode { id }).collect();
179 Ok((ops, ids))
180}