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