1use crate::{
8 ComposeError, MEMORY_CONTENT_PROP, MEMORY_KINDS, MEMORY_KIND_DECISION, MEMORY_KIND_DEFAULT,
9 MEMORY_KIND_EPISODIC, MEMORY_KIND_PROCEDURAL, MEMORY_KIND_PROP, MEMORY_LABEL,
10 MEMORY_TOMBSTONE_PROPS,
11};
12use topodb::{Db, NodeId, Op, PropValue, ScopeSet};
13
14pub const LIFECYCLE_DEFAULT_LIMIT: usize = 20;
16pub const LIFECYCLE_HALF_LIFE_EPISODIC_DAYS: f64 = 14.0;
20pub const LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS: f64 = 120.0;
21pub const LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS: f64 = 365.0;
22pub const LIFECYCLE_HALF_LIFE_DECISION_DAYS: f64 = LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS;
25
26const DAY_MS: f64 = 86_400_000.0;
27
28#[derive(Debug, Clone)]
30pub struct LifecycleParams {
31 pub limit: usize,
33 pub half_life_episodic_ms: i64,
34 pub half_life_semantic_ms: i64,
35 pub half_life_procedural_ms: i64,
36 pub half_life_decision_ms: i64,
37}
38
39impl Default for LifecycleParams {
40 fn default() -> Self {
41 Self {
42 limit: LIFECYCLE_DEFAULT_LIMIT,
43 half_life_episodic_ms: (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
44 half_life_semantic_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
45 half_life_procedural_ms: (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
46 half_life_decision_ms: (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64,
47 }
48 }
49}
50
51#[derive(Debug, Clone, serde::Serialize)]
54pub struct LifecycleCandidate {
55 pub id: String,
56 pub content: String,
57 pub kind: String,
61 pub created_at: i64,
63 pub last_accessed_at: i64,
66 pub access_count: u64,
67 pub staleness: f64,
68}
69
70pub fn staleness(age_ms: i64, half_life_ms: i64, access_count: u64) -> f64 {
75 let age = age_ms.max(0) as f64;
76 (age / half_life_ms as f64) / (std::f64::consts::E + access_count as f64).ln()
77}
78
79pub fn lifecycle_candidates(
83 db: &Db,
84 scopes: &ScopeSet,
85 params: &LifecycleParams,
86 now_ms: i64,
87) -> Result<Vec<LifecycleCandidate>, ComposeError> {
88 if params.limit == 0 {
89 return Err(ComposeError::Invalid("limit must be at least 1".into()));
90 }
91 for (kind, hl) in [
92 (MEMORY_KIND_EPISODIC, params.half_life_episodic_ms),
93 (crate::MEMORY_KIND_SEMANTIC, params.half_life_semantic_ms),
94 (MEMORY_KIND_PROCEDURAL, params.half_life_procedural_ms),
95 (MEMORY_KIND_DECISION, params.half_life_decision_ms),
96 ] {
97 if hl <= 0 {
98 return Err(ComposeError::Invalid(format!(
99 "half-lives must be positive, got {hl}ms for {kind}"
100 )));
101 }
102 }
103
104 let mut out: Vec<LifecycleCandidate> = Vec::new();
105 for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
106 if MEMORY_TOMBSTONE_PROPS
109 .iter()
110 .any(|p| node.props.contains_key(*p))
111 {
112 continue;
113 }
114 let content = match node.props.get(MEMORY_CONTENT_PROP) {
115 Some(PropValue::Str(c)) => c.clone(),
116 _ => continue,
117 };
118 let kind = match node.props.get(MEMORY_KIND_PROP) {
119 Some(PropValue::Str(k)) if MEMORY_KINDS.contains(&k.as_str()) => k.clone(),
120 _ => MEMORY_KIND_DEFAULT.to_string(),
121 };
122 let half_life_ms = match kind.as_str() {
123 MEMORY_KIND_EPISODIC => params.half_life_episodic_ms,
124 MEMORY_KIND_PROCEDURAL => params.half_life_procedural_ms,
125 MEMORY_KIND_DECISION => params.half_life_decision_ms,
128 _ => params.half_life_semantic_ms,
129 };
130 let stats = db.access_stats(scopes, node.id)?.unwrap_or_default();
131 let created_at = node.id.timestamp_ms() as i64;
132 let age_ms = now_ms - stats.last_accessed_at.max(created_at);
133 out.push(LifecycleCandidate {
134 id: node.id.to_string(),
135 content,
136 kind,
137 created_at,
138 last_accessed_at: stats.last_accessed_at,
139 access_count: stats.access_count,
140 staleness: staleness(age_ms, half_life_ms, stats.access_count),
141 });
142 }
143
144 out.sort_by(|a, b| {
148 b.staleness
149 .partial_cmp(&a.staleness)
150 .unwrap_or(std::cmp::Ordering::Equal)
151 .then_with(|| a.id.cmp(&b.id))
152 });
153 out.truncate(params.limit);
154 Ok(out)
155}
156
157pub fn plan_purge(
166 db: &Db,
167 scopes: &ScopeSet,
168 tombstoned_before_ms: i64,
169) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
170 if tombstoned_before_ms <= 0 {
171 return Err(ComposeError::Invalid(
172 "tombstoned-before must be a positive unix-ms timestamp".into(),
173 ));
174 }
175 let mut doomed: Vec<NodeId> = Vec::new();
176 for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
177 let qualifies = MEMORY_TOMBSTONE_PROPS.iter().any(
178 |p| matches!(node.props.get(*p), Some(PropValue::Int(t)) if *t < tombstoned_before_ms),
179 );
180 if qualifies {
181 doomed.push(node.id);
182 }
183 }
184 doomed.sort();
185 let ids = doomed.iter().map(|id| id.to_string()).collect();
186 let ops = doomed.into_iter().map(|id| Op::RemoveNode { id }).collect();
187 Ok((ops, ids))
188}
189
190pub fn memory_kind_half_life() -> topodb::PropHalfLife {
196 topodb::PropHalfLife {
197 prop: MEMORY_KIND_PROP.to_string(),
198 per_value: vec![
199 (
200 MEMORY_KIND_EPISODIC.to_string(),
201 (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
202 ),
203 (
204 MEMORY_KIND_PROCEDURAL.to_string(),
205 (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
206 ),
207 (
211 MEMORY_KIND_DECISION.to_string(),
212 (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64,
213 ),
214 ],
215 default_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
234 fn memory_kind_half_life_mirrors_lifecycle_constants() {
235 let map = memory_kind_half_life();
236 assert_eq!(map.prop, MEMORY_KIND_PROP);
237 assert_eq!(
238 map.default_ms,
239 (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64
240 );
241 assert_eq!(
242 map.per_value,
243 vec![
244 (
245 MEMORY_KIND_EPISODIC.to_string(),
246 (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64
247 ),
248 (
249 MEMORY_KIND_PROCEDURAL.to_string(),
250 (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64
251 ),
252 (
253 MEMORY_KIND_DECISION.to_string(),
254 (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64
255 ),
256 ]
257 );
258 let explicit: Vec<&str> = map.per_value.iter().map(|(k, _)| k.as_str()).collect();
262 for kind in MEMORY_KINDS {
263 assert!(
264 explicit.contains(&kind) || kind == crate::MEMORY_KIND_DEFAULT,
265 "kind {kind:?} has no half-life wiring"
266 );
267 }
268 let params = LifecycleParams::default();
271 assert_eq!(
272 params.half_life_decision_ms,
273 (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64,
274 "lifecycle sweep's decision half-life must equal LIFECYCLE_HALF_LIFE_DECISION_DAYS constant"
275 );
276 let map_decision_ms = map
278 .per_value
279 .iter()
280 .find(|(k, _)| k == MEMORY_KIND_DECISION)
281 .map(|(_, ms)| *ms)
282 .expect("decision must have an explicit entry");
283 assert_eq!(
284 params.half_life_decision_ms, map_decision_ms,
285 "lifecycle sweep and search ranking must use the same decision half-life"
286 );
287 }
288
289 #[test]
293 fn decision_half_life_ties_to_semantic() {
294 assert_eq!(
295 LIFECYCLE_HALF_LIFE_DECISION_DAYS,
296 LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS
297 );
298 }
299}