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/// Spacing/testing effect: how strongly each prior retrieval lengthens memory
25/// stability. 0.5 ⇒ ~10 retrievals make a fact roughly 6× more durable.
26const SPACING_GAIN: f32 = 0.5;
27/// Floor on derived stability (days) so even a heavily down-voted fact decays
28/// smoothly rather than collapsing in a single pass.
29const MIN_STABILITY_DAYS: f32 = 1.0;
30/// Confidence never decays below this — archival happens elsewhere, decay never
31/// hard-deletes.
32const CONFIDENCE_FLOOR: f32 = 0.05;
33/// Default characteristic memory stability (days) for the Ebbinghaus curve.
34pub const DEFAULT_BASE_STABILITY_DAYS: f32 = 90.0;
35
36/// Which forgetting curve drives confidence decay (#1).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum ForgettingModel {
39    /// Exponential retention `R = exp(-Δt / S)` with spacing-boosted stability
40    /// `S` (Ebbinghaus forgetting curve + SM-2 spacing). Deterministic, the
41    /// default: durable memories fade gracefully, rehearsed ones persist.
42    #[default]
43    Ebbinghaus,
44    /// Legacy linear subtraction, kept for reproducibility / explicit opt-out.
45    Linear,
46}
47
48impl ForgettingModel {
49    pub fn parse(s: &str) -> Self {
50        match s.trim().to_lowercase().as_str() {
51            "linear" => Self::Linear,
52            _ => Self::Ebbinghaus,
53        }
54    }
55
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Self::Ebbinghaus => "ebbinghaus",
59            Self::Linear => "linear",
60        }
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct LifecycleConfig {
66    pub decay_rate_per_day: f32,
67    pub max_facts: usize,
68    pub low_confidence_threshold: f32,
69    pub stale_days: i64,
70    pub consolidation_similarity: f32,
71    /// Forgetting curve (#1). Defaults to Ebbinghaus.
72    pub forgetting_model: ForgettingModel,
73    /// Characteristic stability (days) for the Ebbinghaus curve before spacing
74    /// and feedback modulation.
75    pub base_stability_days: f32,
76    /// When true, scale stability by the fact's archetype so structural *evidence*
77    /// (architecture/dependency/…) decays slower than *inference* (#802/cognition).
78    /// Default false keeps the baseline tuning byte-for-byte.
79    pub archetype_aware_decay: bool,
80}
81
82impl Default for LifecycleConfig {
83    fn default() -> Self {
84        Self {
85            decay_rate_per_day: DEFAULT_DECAY_RATE,
86            max_facts: DEFAULT_MAX_FACTS,
87            low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
88            stale_days: STALE_DAYS,
89            consolidation_similarity: 0.85,
90            forgetting_model: ForgettingModel::default(),
91            base_stability_days: DEFAULT_BASE_STABILITY_DAYS,
92            archetype_aware_decay: false,
93        }
94    }
95}
96
97#[derive(Debug, Default)]
98pub struct LifecycleReport {
99    pub decayed_count: usize,
100    pub consolidated_count: usize,
101    pub archived_count: usize,
102    pub compacted_count: usize,
103    pub remaining_facts: usize,
104}
105
106pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
107    let now = Utc::now();
108    let mut count = 0;
109
110    for fact in facts.iter_mut() {
111        if !fact.is_current() {
112            continue;
113        }
114
115        if let Some(valid_until) = fact.valid_until
116            && valid_until < now
117            && fact.confidence > 0.1
118        {
119            fact.confidence = 0.1;
120            count += 1;
121            continue;
122        }
123
124        let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
125        if days_since_confirmed <= 0.0 {
126            continue;
127        }
128        let days_since_retrieved = fact
129            .last_retrieved
130            .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
131        let retrieval_count = fact.retrieval_count as f32;
132        let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
133
134        // Archetype-aware stability (opt-in): structural evidence is more durable
135        // than inference. Off by default → identical to the prior baseline.
136        let base_stability = if config.archetype_aware_decay {
137            config.base_stability_days * fact.archetype.stability_multiplier()
138        } else {
139            config.base_stability_days
140        };
141
142        let new_confidence = match config.forgetting_model {
143            ForgettingModel::Ebbinghaus => ebbinghaus_confidence(
144                fact.confidence,
145                days_since_confirmed,
146                days_since_retrieved,
147                retrieval_count,
148                net_feedback,
149                base_stability,
150            ),
151            ForgettingModel::Linear => linear_confidence(
152                fact.confidence,
153                days_since_confirmed,
154                days_since_retrieved,
155                retrieval_count,
156                net_feedback,
157                config.decay_rate_per_day,
158            ),
159        };
160        if (new_confidence - fact.confidence).abs() > 0.001 {
161            fact.confidence = new_confidence;
162            count += 1;
163        }
164    }
165
166    if count > 0 && config.forgetting_model == ForgettingModel::Ebbinghaus {
167        crate::core::introspect::tick("power_law_decay");
168    }
169    count
170}
171
172/// Ebbinghaus retention `R = exp(-Δt / S)` (#1). Stability `S` grows with the
173/// spacing effect (each prior retrieval) and net feedback; `Δt` is time since
174/// the memory was last reinforced (confirmed *or* retrieved). Multiplicative so
175/// confidence approaches the floor smoothly and never overshoots. Deterministic.
176fn ebbinghaus_confidence(
177    confidence: f32,
178    days_since_confirmed: f32,
179    days_since_retrieved: f32,
180    retrieval_count: f32,
181    net_feedback: i64,
182    base_stability_days: f32,
183) -> f32 {
184    let elapsed = days_since_confirmed.min(days_since_retrieved).max(0.0);
185    let spacing = 1.0 + SPACING_GAIN * retrieval_count;
186    let feedback_mult = match net_feedback.cmp(&0) {
187        std::cmp::Ordering::Greater => 1.0 + (net_feedback as f32).ln_1p(),
188        std::cmp::Ordering::Less => 1.0 / (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()),
189        std::cmp::Ordering::Equal => 1.0,
190    };
191    let stability = (base_stability_days * spacing * feedback_mult).max(MIN_STABILITY_DAYS);
192    let retention = (-(f64::from(elapsed)) / f64::from(stability)).exp() as f32;
193    (confidence * retention).max(CONFIDENCE_FLOOR)
194}
195
196/// Legacy linear subtraction, preserved verbatim for `forgetting_model = linear`.
197/// FadeMem-inspired: protect frequently/recently retrieved facts; feedback
198/// steers retention. Deterministic, local-only.
199fn linear_confidence(
200    confidence: f32,
201    days_since_confirmed: f32,
202    days_since_retrieved: f32,
203    retrieval_count: f32,
204    net_feedback: i64,
205    decay_rate_per_day: f32,
206) -> f32 {
207    let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p());
208    let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0);
209    let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
210    let feedback_factor = match net_feedback.cmp(&0) {
211        std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
212        std::cmp::Ordering::Less => (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0),
213        std::cmp::Ordering::Equal => 1.0,
214    };
215    let decay = decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
216    (confidence - decay).max(CONFIDENCE_FLOOR)
217}
218
219pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
220    let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
221
222    let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
223        std::collections::HashMap::new();
224    for (i, f) in facts.iter().enumerate() {
225        if f.is_current() {
226            category_groups
227                .entry(f.category.clone())
228                .or_default()
229                .push(i);
230        }
231    }
232
233    for indices in category_groups.values() {
234        for (pos_a, &i) in indices.iter().enumerate() {
235            if to_remove.contains(&i) {
236                continue;
237            }
238            for &j in &indices[pos_a + 1..] {
239                if to_remove.contains(&j) {
240                    continue;
241                }
242                let sim = word_similarity(&facts[i].value, &facts[j].value);
243                if sim >= similarity_threshold {
244                    if facts[i].confidence >= facts[j].confidence {
245                        facts[i].confirmation_count += facts[j].confirmation_count;
246                        if facts[j].last_confirmed > facts[i].last_confirmed {
247                            facts[i].last_confirmed = facts[j].last_confirmed;
248                        }
249                        to_remove.insert(j);
250                    } else {
251                        facts[j].confirmation_count += facts[i].confirmation_count;
252                        if facts[i].last_confirmed > facts[j].last_confirmed {
253                            facts[j].last_confirmed = facts[i].last_confirmed;
254                        }
255                        to_remove.insert(i);
256                        break;
257                    }
258                }
259            }
260        }
261    }
262
263    let count = to_remove.len();
264    let mut sorted: Vec<usize> = to_remove.into_iter().collect();
265    sorted.sort_unstable();
266    for idx in sorted.into_iter().rev() {
267        facts.remove(idx);
268    }
269
270    count
271}
272
273pub fn compact(
274    facts: &mut Vec<KnowledgeFact>,
275    config: &LifecycleConfig,
276) -> (usize, Vec<KnowledgeFact>) {
277    let mut archived: Vec<KnowledgeFact> = Vec::new();
278    let now = Utc::now();
279    let stale_threshold = now - Duration::days(config.stale_days);
280
281    let mut to_archive: Vec<usize> = Vec::new();
282
283    for (i, fact) in facts.iter().enumerate() {
284        let recently_retrieved = fact
285            .last_retrieved
286            .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
287        let frequently_retrieved = fact.retrieval_count >= 5;
288
289        if fact.confidence < config.low_confidence_threshold {
290            to_archive.push(i);
291            continue;
292        }
293
294        if fact.last_confirmed < stale_threshold
295            && fact.confirmation_count <= 1
296            && fact.confidence < 0.5
297            && !recently_retrieved
298            && !frequently_retrieved
299        {
300            to_archive.push(i);
301        }
302    }
303
304    to_archive.sort_unstable();
305    to_archive.dedup();
306    let count = to_archive.len();
307
308    for idx in to_archive.into_iter().rev() {
309        archived.push(facts.remove(idx));
310    }
311
312    if facts.len() > config.max_facts {
313        facts.sort_by(|a, b| {
314            b.confidence
315                .partial_cmp(&a.confidence)
316                .unwrap_or(std::cmp::Ordering::Equal)
317        });
318        let excess: Vec<KnowledgeFact> = facts.drain(config.max_facts..).collect();
319        archived.extend(excess);
320    }
321
322    (count, archived)
323}
324
325pub fn run_lifecycle(facts: &mut Vec<KnowledgeFact>, config: &LifecycleConfig) -> LifecycleReport {
326    let decayed = apply_confidence_decay(facts, config);
327    let consolidated = consolidate_similar(facts, config.consolidation_similarity);
328    let (compacted, archived) = compact(facts, config);
329
330    if !archived.is_empty() {
331        let _ = archive_facts(&archived);
332    }
333
334    LifecycleReport {
335        decayed_count: decayed,
336        consolidated_count: consolidated,
337        archived_count: archived.len(),
338        compacted_count: compacted,
339        remaining_facts: facts.len(),
340    }
341}
342
343#[derive(Debug, Serialize, Deserialize)]
344struct ArchivedFacts {
345    pub archived_at: DateTime<Utc>,
346    pub facts: Vec<KnowledgeFact>,
347}
348
349fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
350    let dir = crate::core::data_dir::lean_ctx_data_dir()?
351        .join("memory")
352        .join("archive");
353    std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
354
355    // Sub-second suffix avoids same-second filename collisions that would otherwise
356    // silently overwrite a prior archive written in the same wall-clock second.
357    let now = Utc::now();
358    let suffix = now.timestamp_subsec_nanos() % 1_000_000;
359    let filename = format!("archive-{}-{suffix:06}.json", now.format("%Y%m%d-%H%M%S"));
360    let archive = ArchivedFacts {
361        archived_at: now,
362        facts: facts.to_vec(),
363    };
364    let json = serde_json::to_string_pretty(&archive).map_err(|e| format!("{e}"))?;
365    std::fs::write(dir.join(filename), json).map_err(|e| format!("{e}"))?;
366
367    // Prune to the newest MAX_ARCHIVE_FILES; list_archives() is already sorted ascending
368    // (lexical == chronological for the zero-padded timestamp prefix). Best-effort: a
369    // prune failure must not fail the archive write itself.
370    let archives = list_archives();
371    if archives.len() > MAX_ARCHIVE_FILES {
372        for old in &archives[..archives.len() - MAX_ARCHIVE_FILES] {
373            let _ = std::fs::remove_file(old);
374        }
375    }
376    Ok(())
377}
378
379pub fn restore_archive(archive_path: &str) -> Result<Vec<KnowledgeFact>, String> {
380    let data = std::fs::read_to_string(archive_path).map_err(|e| format!("{e}"))?;
381    let archive: ArchivedFacts = serde_json::from_str(&data).map_err(|e| format!("{e}"))?;
382    Ok(archive.facts)
383}
384
385pub fn list_archives() -> Vec<PathBuf> {
386    let dir = match crate::core::data_dir::lean_ctx_data_dir() {
387        Ok(d) => d.join("memory").join("archive"),
388        Err(_) => return Vec::new(),
389    };
390
391    if !dir.exists() {
392        return Vec::new();
393    }
394
395    let mut archives: Vec<PathBuf> = std::fs::read_dir(&dir)
396        .into_iter()
397        .flatten()
398        .flatten()
399        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
400        .map(|e| e.path())
401        .collect();
402
403    archives.sort();
404    archives
405}
406
407fn word_similarity(a: &str, b: &str) -> f32 {
408    let a_lower = a.to_lowercase();
409    let b_lower = b.to_lowercase();
410    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
411    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
412
413    if a_words.is_empty() && b_words.is_empty() {
414        return 1.0;
415    }
416
417    let intersection = a_words.intersection(&b_words).count();
418    let union = a_words.union(&b_words).count();
419
420    if union == 0 {
421        return 0.0;
422    }
423
424    intersection as f32 / union as f32
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::core::knowledge::KnowledgeArchetype;
431
432    fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
433        KnowledgeFact {
434            category: category.to_string(),
435            key: key.to_string(),
436            value: value.to_string(),
437            source_session: "s1".to_string(),
438            confidence,
439            created_at: Utc::now(),
440            last_confirmed: Utc::now(),
441            retrieval_count: 0,
442            last_retrieved: None,
443            valid_from: Some(Utc::now()),
444            valid_until: None,
445            supersedes: None,
446            confirmation_count: 1,
447            feedback_up: 0,
448            feedback_down: 0,
449            last_feedback: None,
450            privacy: crate::core::memory_boundary::FactPrivacy::default(),
451            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
452            imported_from: None,
453            archetype: KnowledgeArchetype::default(),
454            fidelity: None,
455            revision_count: 0,
456        }
457    }
458
459    fn make_old_fact(
460        category: &str,
461        key: &str,
462        value: &str,
463        confidence: f32,
464        days_old: i64,
465    ) -> KnowledgeFact {
466        let past = Utc::now() - Duration::days(days_old);
467        KnowledgeFact {
468            category: category.to_string(),
469            key: key.to_string(),
470            value: value.to_string(),
471            source_session: "s1".to_string(),
472            confidence,
473            created_at: past,
474            last_confirmed: past,
475            retrieval_count: 0,
476            last_retrieved: None,
477            valid_from: Some(past),
478            valid_until: None,
479            supersedes: None,
480            confirmation_count: 1,
481            feedback_up: 0,
482            feedback_down: 0,
483            last_feedback: None,
484            privacy: crate::core::memory_boundary::FactPrivacy::default(),
485            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
486            imported_from: None,
487            archetype: KnowledgeArchetype::default(),
488            fidelity: None,
489            revision_count: 0,
490        }
491    }
492
493    #[test]
494    fn decay_reduces_confidence() {
495        let config = LifecycleConfig::default();
496        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
497
498        let count = apply_confidence_decay(&mut facts, &config);
499        assert_eq!(count, 1);
500        assert!(facts[0].confidence < 0.9);
501        assert!(facts[0].confidence > 0.7);
502    }
503
504    #[test]
505    fn archetype_aware_decay_protects_evidence() {
506        // Opt-in: structural evidence (Architecture) decays slower than inference
507        // (Preference). Off (default), archetype is ignored and both decay alike.
508        let mut evidence = make_old_fact("arch", "db", "PostgreSQL", 0.9, 30);
509        evidence.archetype = KnowledgeArchetype::Architecture;
510        let mut inference = make_old_fact("pref", "style", "tabs", 0.9, 30);
511        inference.archetype = KnowledgeArchetype::Preference;
512
513        let off = LifecycleConfig::default();
514        let mut a = vec![evidence.clone(), inference.clone()];
515        apply_confidence_decay(&mut a, &off);
516        assert!(
517            (a[0].confidence - a[1].confidence).abs() < 1e-6,
518            "flag off → archetype ignored, equal decay"
519        );
520
521        let on = LifecycleConfig {
522            archetype_aware_decay: true,
523            ..Default::default()
524        };
525        let mut b = vec![evidence, inference];
526        apply_confidence_decay(&mut b, &on);
527        assert!(
528            b[0].confidence > b[1].confidence,
529            "evidence {} should outlast inference {}",
530            b[0].confidence,
531            b[1].confidence
532        );
533    }
534
535    #[test]
536    fn decay_skips_recent_facts() {
537        let config = LifecycleConfig::default();
538        let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
539
540        let count = apply_confidence_decay(&mut facts, &config);
541        assert_eq!(count, 0);
542    }
543
544    #[test]
545    fn feedback_steers_decay_keep_vs_forget() {
546        let config = LifecycleConfig::default();
547        let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
548        praised.feedback_up = 5;
549        let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
550        panned.feedback_down = 5;
551        let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
552
553        let mut facts = vec![praised, panned, neutral];
554        apply_confidence_decay(&mut facts, &config);
555
556        let (praised_c, panned_c, neutral_c) = (
557            facts[0].confidence,
558            facts[1].confidence,
559            facts[2].confidence,
560        );
561
562        // Reward bridge: up-voted retains more than neutral, neutral more than down-voted.
563        assert!(
564            praised_c > neutral_c,
565            "praised {praised_c} should outlast neutral {neutral_c}"
566        );
567        assert!(
568            neutral_c > panned_c,
569            "neutral {neutral_c} should outlast panned {panned_c}"
570        );
571        // Even a heavily down-voted fact only fades toward the floor — never hard-deleted.
572        assert!(panned_c >= 0.05);
573    }
574
575    #[test]
576    fn spacing_effect_protects_frequently_retrieved() {
577        // #1: under the Ebbinghaus curve, a fact retrieved many times must decay
578        // slower than an identical never-retrieved fact of the same age.
579        let config = LifecycleConfig::default();
580        let rarely = make_old_fact("arch", "rare", "x", 0.9, 20);
581        let mut often = make_old_fact("arch", "often", "y", 0.9, 20);
582        often.retrieval_count = 20;
583        let mut facts = vec![rarely, often];
584        apply_confidence_decay(&mut facts, &config);
585        assert!(
586            facts[1].confidence > facts[0].confidence,
587            "spacing effect: rehearsed {} should outlast un-rehearsed {}",
588            facts[1].confidence,
589            facts[0].confidence
590        );
591    }
592
593    #[test]
594    fn ebbinghaus_decay_is_deterministic() {
595        // Determinism contract (#498): same input → same output, no RNG.
596        let config = LifecycleConfig::default();
597        let mut a = vec![make_old_fact("arch", "k", "v", 0.8, 15)];
598        let mut b = a.clone();
599        apply_confidence_decay(&mut a, &config);
600        apply_confidence_decay(&mut b, &config);
601        assert_eq!(a[0].confidence, b[0].confidence);
602    }
603
604    #[test]
605    fn linear_model_still_available() {
606        // Opt-out path keeps the legacy subtractive behavior.
607        let config = LifecycleConfig {
608            forgetting_model: ForgettingModel::Linear,
609            ..Default::default()
610        };
611        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
612        let count = apply_confidence_decay(&mut facts, &config);
613        assert_eq!(count, 1);
614        assert!(facts[0].confidence < 0.9 && facts[0].confidence > 0.7);
615    }
616
617    #[test]
618    fn forgetting_model_parses() {
619        assert_eq!(ForgettingModel::parse("linear"), ForgettingModel::Linear);
620        assert_eq!(
621            ForgettingModel::parse("ebbinghaus"),
622            ForgettingModel::Ebbinghaus
623        );
624        assert_eq!(
625            ForgettingModel::parse("garbage"),
626            ForgettingModel::Ebbinghaus
627        );
628    }
629
630    #[test]
631    fn consolidate_similar_facts() {
632        let mut facts = vec![
633            make_fact("arch", "db", "uses PostgreSQL database", 0.8),
634            make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
635            make_fact("ops", "deploy", "docker compose up", 0.9),
636        ];
637
638        let count = consolidate_similar(&mut facts, 0.7);
639        assert!(count > 0, "Should consolidate similar facts");
640        assert!(facts.len() < 3);
641    }
642
643    #[test]
644    fn consolidate_keeps_different_categories() {
645        let mut facts = vec![
646            make_fact("arch", "db", "PostgreSQL", 0.8),
647            make_fact("ops", "db", "PostgreSQL", 0.8),
648        ];
649
650        let count = consolidate_similar(&mut facts, 0.9);
651        assert_eq!(count, 0, "Different categories should not consolidate");
652    }
653
654    #[test]
655    fn compact_removes_low_confidence() {
656        let config = LifecycleConfig::default();
657        let mut facts = vec![
658            make_fact("arch", "db", "PostgreSQL", 0.9),
659            make_fact("arch", "cache", "Redis", 0.1),
660        ];
661
662        let (count, archived) = compact(&mut facts, &config);
663        assert_eq!(count, 1);
664        assert_eq!(facts.len(), 1);
665        assert_eq!(archived.len(), 1);
666        assert_eq!(archived[0].key, "cache");
667    }
668
669    #[test]
670    fn compact_archives_stale_facts() {
671        let config = LifecycleConfig::default();
672        let mut facts = vec![
673            make_fact("arch", "db", "PostgreSQL", 0.9),
674            make_old_fact("arch", "old", "ancient thing", 0.4, 60),
675        ];
676
677        let (count, archived) = compact(&mut facts, &config);
678        assert_eq!(count, 1);
679        assert_eq!(archived[0].key, "old");
680    }
681
682    #[test]
683    fn full_lifecycle_run() {
684        let config = LifecycleConfig {
685            max_facts: 5,
686            ..Default::default()
687        };
688
689        let mut facts = vec![
690            make_fact("arch", "db", "PostgreSQL", 0.9),
691            make_fact("arch", "cache", "Redis", 0.8),
692            make_old_fact("arch", "old1", "thing1", 0.2, 50),
693            make_old_fact("arch", "old2", "thing2", 0.15, 60),
694            make_fact("ops", "deploy", "docker compose", 0.7),
695        ];
696
697        let report = run_lifecycle(&mut facts, &config);
698        assert!(report.remaining_facts <= config.max_facts);
699        assert!(report.decayed_count > 0 || report.compacted_count > 0);
700    }
701
702    #[test]
703    fn word_similarity_identical() {
704        assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
705    }
706
707    #[test]
708    fn word_similarity_partial() {
709        let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
710        assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
711        assert!(sim < 1.0);
712    }
713
714    #[test]
715    fn word_similarity_different() {
716        let sim = word_similarity("Redis cache", "Docker compose");
717        assert!(sim < 0.1);
718    }
719}