Skip to main content

lean_ctx/core/
memory_lifecycle.rs

1//! Memory Lifecycle Management — consolidation, decay, compaction, archival.
2//!
3//! Runs automatically on knowledge stores to keep memory healthy:
4//! - Confidence decay over time
5//! - Semantic consolidation of similar facts
6//! - Compaction when limits are exceeded
7//! - Archival of old/unused facts
8
9use chrono::{DateTime, Duration, Utc};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13use super::knowledge::KnowledgeFact;
14
15const DEFAULT_DECAY_RATE: f32 = 0.01;
16const DEFAULT_MAX_FACTS: usize = 1000;
17const LOW_CONFIDENCE_THRESHOLD: f32 = 0.3;
18const STALE_DAYS: i64 = 30;
19/// Bound archive-dir disk growth. The reader (`rehydrate_from_archives`) only ever
20/// consults the newest `KNOWLEDGE_REHYDRATE_MAX_ARCHIVES` (= 4) files, so any value
21/// well above that prunes only already-unreachable files.
22const MAX_ARCHIVE_FILES: usize = 16;
23
24#[derive(Debug, Clone)]
25pub struct LifecycleConfig {
26    pub decay_rate_per_day: f32,
27    pub max_facts: usize,
28    pub low_confidence_threshold: f32,
29    pub stale_days: i64,
30    pub consolidation_similarity: f32,
31}
32
33impl Default for LifecycleConfig {
34    fn default() -> Self {
35        Self {
36            decay_rate_per_day: DEFAULT_DECAY_RATE,
37            max_facts: DEFAULT_MAX_FACTS,
38            low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
39            stale_days: STALE_DAYS,
40            consolidation_similarity: 0.85,
41        }
42    }
43}
44
45#[derive(Debug, Default)]
46pub struct LifecycleReport {
47    pub decayed_count: usize,
48    pub consolidated_count: usize,
49    pub archived_count: usize,
50    pub compacted_count: usize,
51    pub remaining_facts: usize,
52}
53
54pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
55    let now = Utc::now();
56    let mut count = 0;
57
58    for fact in facts.iter_mut() {
59        if !fact.is_current() {
60            continue;
61        }
62
63        if let Some(valid_until) = fact.valid_until
64            && valid_until < now
65            && fact.confidence > 0.1
66        {
67            fact.confidence = 0.1;
68            count += 1;
69            continue;
70        }
71
72        let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
73        let days_since_retrieved = fact
74            .last_retrieved
75            .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
76        let retrieval_count = fact.retrieval_count as f32;
77
78        if days_since_confirmed > 0.0 {
79            // FadeMem-inspired: protect frequently/recently retrieved facts.
80            // Deterministic, local-only signals; never hard-delete (archive-only elsewhere).
81            let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p()); // 1.0 .. ~0.2
82            let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0); // 1.0 if today, 0.0 after 30d
83            let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
84            // Reward bridge: explicit thumbs-up/down feedback steers retention.
85            // Net-positive feedback scales decay down (keep longer); net-negative
86            // scales it up (forget faster). Logarithmic so a few votes matter but
87            // can't run away, and the penalty is capped so one downvote never
88            // collapses an otherwise healthy fact.
89            let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
90            let feedback_factor = match net_feedback.cmp(&0) {
91                std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
92                std::cmp::Ordering::Less => {
93                    (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0)
94                }
95                std::cmp::Ordering::Equal => 1.0,
96            };
97            let decay =
98                config.decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
99            let new_confidence = (fact.confidence - decay).max(0.05);
100            if (new_confidence - fact.confidence).abs() > 0.001 {
101                fact.confidence = new_confidence;
102                count += 1;
103            }
104        }
105    }
106
107    count
108}
109
110pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
111    let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
112
113    let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
114        std::collections::HashMap::new();
115    for (i, f) in facts.iter().enumerate() {
116        if f.is_current() {
117            category_groups
118                .entry(f.category.clone())
119                .or_default()
120                .push(i);
121        }
122    }
123
124    for indices in category_groups.values() {
125        for (pos_a, &i) in indices.iter().enumerate() {
126            if to_remove.contains(&i) {
127                continue;
128            }
129            for &j in &indices[pos_a + 1..] {
130                if to_remove.contains(&j) {
131                    continue;
132                }
133                let sim = word_similarity(&facts[i].value, &facts[j].value);
134                if sim >= similarity_threshold {
135                    if facts[i].confidence >= facts[j].confidence {
136                        facts[i].confirmation_count += facts[j].confirmation_count;
137                        if facts[j].last_confirmed > facts[i].last_confirmed {
138                            facts[i].last_confirmed = facts[j].last_confirmed;
139                        }
140                        to_remove.insert(j);
141                    } else {
142                        facts[j].confirmation_count += facts[i].confirmation_count;
143                        if facts[i].last_confirmed > facts[j].last_confirmed {
144                            facts[j].last_confirmed = facts[i].last_confirmed;
145                        }
146                        to_remove.insert(i);
147                        break;
148                    }
149                }
150            }
151        }
152    }
153
154    let count = to_remove.len();
155    let mut sorted: Vec<usize> = to_remove.into_iter().collect();
156    sorted.sort_unstable();
157    for idx in sorted.into_iter().rev() {
158        facts.remove(idx);
159    }
160
161    count
162}
163
164pub fn compact(
165    facts: &mut Vec<KnowledgeFact>,
166    config: &LifecycleConfig,
167) -> (usize, Vec<KnowledgeFact>) {
168    let mut archived: Vec<KnowledgeFact> = Vec::new();
169    let now = Utc::now();
170    let stale_threshold = now - Duration::days(config.stale_days);
171
172    let mut to_archive: Vec<usize> = Vec::new();
173
174    for (i, fact) in facts.iter().enumerate() {
175        let recently_retrieved = fact
176            .last_retrieved
177            .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
178        let frequently_retrieved = fact.retrieval_count >= 5;
179
180        if fact.confidence < config.low_confidence_threshold {
181            to_archive.push(i);
182            continue;
183        }
184
185        if fact.last_confirmed < stale_threshold
186            && fact.confirmation_count <= 1
187            && fact.confidence < 0.5
188            && !recently_retrieved
189            && !frequently_retrieved
190        {
191            to_archive.push(i);
192        }
193    }
194
195    to_archive.sort_unstable();
196    to_archive.dedup();
197    let count = to_archive.len();
198
199    for idx in to_archive.into_iter().rev() {
200        archived.push(facts.remove(idx));
201    }
202
203    if facts.len() > config.max_facts {
204        facts.sort_by(|a, b| {
205            b.confidence
206                .partial_cmp(&a.confidence)
207                .unwrap_or(std::cmp::Ordering::Equal)
208        });
209        let excess: Vec<KnowledgeFact> = facts.drain(config.max_facts..).collect();
210        archived.extend(excess);
211    }
212
213    (count, archived)
214}
215
216pub fn run_lifecycle(facts: &mut Vec<KnowledgeFact>, config: &LifecycleConfig) -> LifecycleReport {
217    let decayed = apply_confidence_decay(facts, config);
218    let consolidated = consolidate_similar(facts, config.consolidation_similarity);
219    let (compacted, archived) = compact(facts, config);
220
221    if !archived.is_empty() {
222        let _ = archive_facts(&archived);
223    }
224
225    LifecycleReport {
226        decayed_count: decayed,
227        consolidated_count: consolidated,
228        archived_count: archived.len(),
229        compacted_count: compacted,
230        remaining_facts: facts.len(),
231    }
232}
233
234#[derive(Debug, Serialize, Deserialize)]
235struct ArchivedFacts {
236    pub archived_at: DateTime<Utc>,
237    pub facts: Vec<KnowledgeFact>,
238}
239
240fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
241    let dir = crate::core::data_dir::lean_ctx_data_dir()?
242        .join("memory")
243        .join("archive");
244    std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
245
246    // Sub-second suffix avoids same-second filename collisions that would otherwise
247    // silently overwrite a prior archive written in the same wall-clock second.
248    let now = Utc::now();
249    let suffix = now.timestamp_subsec_nanos() % 1_000_000;
250    let filename = format!("archive-{}-{suffix:06}.json", now.format("%Y%m%d-%H%M%S"));
251    let archive = ArchivedFacts {
252        archived_at: now,
253        facts: facts.to_vec(),
254    };
255    let json = serde_json::to_string_pretty(&archive).map_err(|e| format!("{e}"))?;
256    std::fs::write(dir.join(filename), json).map_err(|e| format!("{e}"))?;
257
258    // Prune to the newest MAX_ARCHIVE_FILES; list_archives() is already sorted ascending
259    // (lexical == chronological for the zero-padded timestamp prefix). Best-effort: a
260    // prune failure must not fail the archive write itself.
261    let archives = list_archives();
262    if archives.len() > MAX_ARCHIVE_FILES {
263        for old in &archives[..archives.len() - MAX_ARCHIVE_FILES] {
264            let _ = std::fs::remove_file(old);
265        }
266    }
267    Ok(())
268}
269
270pub fn restore_archive(archive_path: &str) -> Result<Vec<KnowledgeFact>, String> {
271    let data = std::fs::read_to_string(archive_path).map_err(|e| format!("{e}"))?;
272    let archive: ArchivedFacts = serde_json::from_str(&data).map_err(|e| format!("{e}"))?;
273    Ok(archive.facts)
274}
275
276pub fn list_archives() -> Vec<PathBuf> {
277    let dir = match crate::core::data_dir::lean_ctx_data_dir() {
278        Ok(d) => d.join("memory").join("archive"),
279        Err(_) => return Vec::new(),
280    };
281
282    if !dir.exists() {
283        return Vec::new();
284    }
285
286    let mut archives: Vec<PathBuf> = std::fs::read_dir(&dir)
287        .into_iter()
288        .flatten()
289        .flatten()
290        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
291        .map(|e| e.path())
292        .collect();
293
294    archives.sort();
295    archives
296}
297
298fn word_similarity(a: &str, b: &str) -> f32 {
299    let a_lower = a.to_lowercase();
300    let b_lower = b.to_lowercase();
301    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
302    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
303
304    if a_words.is_empty() && b_words.is_empty() {
305        return 1.0;
306    }
307
308    let intersection = a_words.intersection(&b_words).count();
309    let union = a_words.union(&b_words).count();
310
311    if union == 0 {
312        return 0.0;
313    }
314
315    intersection as f32 / union as f32
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::core::knowledge::KnowledgeArchetype;
322
323    fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
324        KnowledgeFact {
325            category: category.to_string(),
326            key: key.to_string(),
327            value: value.to_string(),
328            source_session: "s1".to_string(),
329            confidence,
330            created_at: Utc::now(),
331            last_confirmed: Utc::now(),
332            retrieval_count: 0,
333            last_retrieved: None,
334            valid_from: Some(Utc::now()),
335            valid_until: None,
336            supersedes: None,
337            confirmation_count: 1,
338            feedback_up: 0,
339            feedback_down: 0,
340            last_feedback: None,
341            privacy: crate::core::memory_boundary::FactPrivacy::default(),
342            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
343            imported_from: None,
344            archetype: KnowledgeArchetype::default(),
345            fidelity: None,
346            revision_count: 0,
347        }
348    }
349
350    fn make_old_fact(
351        category: &str,
352        key: &str,
353        value: &str,
354        confidence: f32,
355        days_old: i64,
356    ) -> KnowledgeFact {
357        let past = Utc::now() - Duration::days(days_old);
358        KnowledgeFact {
359            category: category.to_string(),
360            key: key.to_string(),
361            value: value.to_string(),
362            source_session: "s1".to_string(),
363            confidence,
364            created_at: past,
365            last_confirmed: past,
366            retrieval_count: 0,
367            last_retrieved: None,
368            valid_from: Some(past),
369            valid_until: None,
370            supersedes: None,
371            confirmation_count: 1,
372            feedback_up: 0,
373            feedback_down: 0,
374            last_feedback: None,
375            privacy: crate::core::memory_boundary::FactPrivacy::default(),
376            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
377            imported_from: None,
378            archetype: KnowledgeArchetype::default(),
379            fidelity: None,
380            revision_count: 0,
381        }
382    }
383
384    #[test]
385    fn decay_reduces_confidence() {
386        let config = LifecycleConfig::default();
387        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
388
389        let count = apply_confidence_decay(&mut facts, &config);
390        assert_eq!(count, 1);
391        assert!(facts[0].confidence < 0.9);
392        assert!(facts[0].confidence > 0.7);
393    }
394
395    #[test]
396    fn decay_skips_recent_facts() {
397        let config = LifecycleConfig::default();
398        let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
399
400        let count = apply_confidence_decay(&mut facts, &config);
401        assert_eq!(count, 0);
402    }
403
404    #[test]
405    fn feedback_steers_decay_keep_vs_forget() {
406        let config = LifecycleConfig::default();
407        let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
408        praised.feedback_up = 5;
409        let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
410        panned.feedback_down = 5;
411        let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
412
413        let mut facts = vec![praised, panned, neutral];
414        apply_confidence_decay(&mut facts, &config);
415
416        let (praised_c, panned_c, neutral_c) = (
417            facts[0].confidence,
418            facts[1].confidence,
419            facts[2].confidence,
420        );
421
422        // Reward bridge: up-voted retains more than neutral, neutral more than down-voted.
423        assert!(
424            praised_c > neutral_c,
425            "praised {praised_c} should outlast neutral {neutral_c}"
426        );
427        assert!(
428            neutral_c > panned_c,
429            "neutral {neutral_c} should outlast panned {panned_c}"
430        );
431        // Even a heavily down-voted fact only fades toward the floor — never hard-deleted.
432        assert!(panned_c >= 0.05);
433    }
434
435    #[test]
436    fn consolidate_similar_facts() {
437        let mut facts = vec![
438            make_fact("arch", "db", "uses PostgreSQL database", 0.8),
439            make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
440            make_fact("ops", "deploy", "docker compose up", 0.9),
441        ];
442
443        let count = consolidate_similar(&mut facts, 0.7);
444        assert!(count > 0, "Should consolidate similar facts");
445        assert!(facts.len() < 3);
446    }
447
448    #[test]
449    fn consolidate_keeps_different_categories() {
450        let mut facts = vec![
451            make_fact("arch", "db", "PostgreSQL", 0.8),
452            make_fact("ops", "db", "PostgreSQL", 0.8),
453        ];
454
455        let count = consolidate_similar(&mut facts, 0.9);
456        assert_eq!(count, 0, "Different categories should not consolidate");
457    }
458
459    #[test]
460    fn compact_removes_low_confidence() {
461        let config = LifecycleConfig::default();
462        let mut facts = vec![
463            make_fact("arch", "db", "PostgreSQL", 0.9),
464            make_fact("arch", "cache", "Redis", 0.1),
465        ];
466
467        let (count, archived) = compact(&mut facts, &config);
468        assert_eq!(count, 1);
469        assert_eq!(facts.len(), 1);
470        assert_eq!(archived.len(), 1);
471        assert_eq!(archived[0].key, "cache");
472    }
473
474    #[test]
475    fn compact_archives_stale_facts() {
476        let config = LifecycleConfig::default();
477        let mut facts = vec![
478            make_fact("arch", "db", "PostgreSQL", 0.9),
479            make_old_fact("arch", "old", "ancient thing", 0.4, 60),
480        ];
481
482        let (count, archived) = compact(&mut facts, &config);
483        assert_eq!(count, 1);
484        assert_eq!(archived[0].key, "old");
485    }
486
487    #[test]
488    fn full_lifecycle_run() {
489        let config = LifecycleConfig {
490            max_facts: 5,
491            ..Default::default()
492        };
493
494        let mut facts = vec![
495            make_fact("arch", "db", "PostgreSQL", 0.9),
496            make_fact("arch", "cache", "Redis", 0.8),
497            make_old_fact("arch", "old1", "thing1", 0.2, 50),
498            make_old_fact("arch", "old2", "thing2", 0.15, 60),
499            make_fact("ops", "deploy", "docker compose", 0.7),
500        ];
501
502        let report = run_lifecycle(&mut facts, &config);
503        assert!(report.remaining_facts <= config.max_facts);
504        assert!(report.decayed_count > 0 || report.compacted_count > 0);
505    }
506
507    #[test]
508    fn word_similarity_identical() {
509        assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
510    }
511
512    #[test]
513    fn word_similarity_partial() {
514        let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
515        assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
516        assert!(sim < 1.0);
517    }
518
519    #[test]
520    fn word_similarity_different() {
521        let sim = word_similarity("Redis cache", "Docker compose");
522        assert!(sim < 0.1);
523    }
524}