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::{Duration, Utc};
10use std::path::PathBuf;
11
12use super::knowledge::KnowledgeFact;
13use super::memory_archive::{ArchiveConfig, MemoryStore};
14
15const DEFAULT_DECAY_RATE: f32 = 0.01;
16const LOW_CONFIDENCE_THRESHOLD: f32 = 0.3;
17const STALE_DAYS: i64 = 30;
18/// Default proactive headroom on a capacity reclaim: settle a full store at 75%
19/// so it keeps real working room instead of churning at its cap.
20pub const DEFAULT_RECLAIM_HEADROOM_PCT: f32 = 0.25;
21
22/// Spacing/testing effect: how strongly each prior retrieval lengthens memory
23/// stability. 0.5 ⇒ ~10 retrievals make a fact roughly 6× more durable.
24const SPACING_GAIN: f32 = 0.5;
25/// Floor on derived stability (days) so even a heavily down-voted fact decays
26/// smoothly rather than collapsing in a single pass.
27const MIN_STABILITY_DAYS: f32 = 1.0;
28/// Confidence never decays below this — archival happens elsewhere, decay never
29/// hard-deletes.
30const CONFIDENCE_FLOOR: f32 = 0.05;
31/// Default characteristic memory stability (days) for the Ebbinghaus curve.
32pub const DEFAULT_BASE_STABILITY_DAYS: f32 = 90.0;
33
34/// Which forgetting curve drives confidence decay (#1).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum ForgettingModel {
37    /// Exponential retention `R = exp(-Δt / S)` with spacing-boosted stability
38    /// `S` (Ebbinghaus forgetting curve + SM-2 spacing). Deterministic, the
39    /// default: durable memories fade gracefully, rehearsed ones persist.
40    #[default]
41    Ebbinghaus,
42    /// Legacy linear subtraction, kept for reproducibility / explicit opt-out.
43    Linear,
44}
45
46impl ForgettingModel {
47    pub fn parse(s: &str) -> Self {
48        match s.trim().to_lowercase().as_str() {
49            "linear" => Self::Linear,
50            _ => Self::Ebbinghaus,
51        }
52    }
53
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Ebbinghaus => "ebbinghaus",
57            Self::Linear => "linear",
58        }
59    }
60}
61
62#[derive(Debug, Clone)]
63pub struct LifecycleConfig {
64    pub decay_rate_per_day: f32,
65    pub low_confidence_threshold: f32,
66    pub stale_days: i64,
67    pub consolidation_similarity: f32,
68    /// Forgetting curve (#1). Defaults to Ebbinghaus.
69    pub forgetting_model: ForgettingModel,
70    /// Characteristic stability (days) for the Ebbinghaus curve before spacing
71    /// and feedback modulation.
72    pub base_stability_days: f32,
73    /// When true, scale stability by the fact's archetype so structural *evidence*
74    /// (architecture/dependency/…) decays slower than *inference* (#802/cognition).
75    /// Default false keeps the baseline tuning byte-for-byte.
76    pub archetype_aware_decay: bool,
77    /// Archive facts untouched for this many days that were **never** retrieved —
78    /// dead weight that costs injection tokens regardless of confidence (#962).
79    /// `None` disables it (the default, so existing tuning is unchanged); the
80    /// production policy can opt in. Reversible: pruned facts go to the archive.
81    pub prune_unretrieved_after_days: Option<i64>,
82    /// Proactive headroom on a capacity reclaim of the capped non-fact stores
83    /// (#995; patterns, history, procedures): drop down to
84    /// `reclaim_target(max, reclaim_headroom_pct)`. Facts have no cap and never
85    /// reach a capacity reclaim. `0.25` = 75%.
86    pub reclaim_headroom_pct: f32,
87    /// Master switch for the proactive capacity reclaim (#995). `false` restores
88    /// the legacy "trim only the overflow" behavior — the documented escape
89    /// hatch. Eviction stays lossless either way (excess is archived).
90    pub reclaim_enabled: bool,
91}
92
93impl Default for LifecycleConfig {
94    fn default() -> Self {
95        Self {
96            decay_rate_per_day: DEFAULT_DECAY_RATE,
97            low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
98            stale_days: STALE_DAYS,
99            consolidation_similarity: 0.85,
100            forgetting_model: ForgettingModel::default(),
101            base_stability_days: DEFAULT_BASE_STABILITY_DAYS,
102            archetype_aware_decay: false,
103            prune_unretrieved_after_days: None,
104            reclaim_headroom_pct: DEFAULT_RECLAIM_HEADROOM_PCT,
105            reclaim_enabled: true,
106        }
107    }
108}
109
110impl LifecycleConfig {
111    /// Map the persisted [`crate::core::memory_policy::MemoryPolicy`] to the
112    /// runtime lifecycle config. The single mapping site, so adding a knob
113    /// touches exactly one place (previously duplicated across the lifecycle and
114    /// cognition callers).
115    pub fn from_policy(policy: &crate::core::memory_policy::MemoryPolicy) -> Self {
116        Self {
117            decay_rate_per_day: policy.lifecycle.decay_rate,
118            low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
119            stale_days: policy.lifecycle.stale_days,
120            consolidation_similarity: policy.lifecycle.similarity_threshold,
121            forgetting_model: ForgettingModel::parse(&policy.lifecycle.forgetting_model),
122            base_stability_days: policy.lifecycle.base_stability_days,
123            archetype_aware_decay: policy.lifecycle.archetype_aware_decay,
124            prune_unretrieved_after_days: policy.lifecycle.prune_unretrieved_after_days,
125            reclaim_headroom_pct: policy.lifecycle.reclaim_headroom_pct,
126            reclaim_enabled: policy.lifecycle.reclaim_enabled,
127        }
128    }
129}
130
131#[derive(Debug, Default)]
132pub struct LifecycleReport {
133    pub decayed_count: usize,
134    pub consolidated_count: usize,
135    pub archived_count: usize,
136    pub compacted_count: usize,
137    pub remaining_facts: usize,
138}
139
140pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
141    let now = Utc::now();
142    let mut count = 0;
143
144    for fact in facts.iter_mut() {
145        if !fact.is_current() {
146            continue;
147        }
148
149        if let Some(valid_until) = fact.valid_until
150            && valid_until < now
151            && fact.confidence > 0.1
152        {
153            fact.confidence = 0.1;
154            count += 1;
155            continue;
156        }
157
158        let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
159        if days_since_confirmed <= 0.0 {
160            continue;
161        }
162        let days_since_retrieved = fact
163            .last_retrieved
164            .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
165        let retrieval_count = fact.retrieval_count as f32;
166        let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
167
168        // Archetype-aware stability (opt-in): structural evidence is more durable
169        // than inference. Off by default → identical to the prior baseline.
170        let base_stability = if config.archetype_aware_decay {
171            config.base_stability_days * fact.archetype.stability_multiplier()
172        } else {
173            config.base_stability_days
174        };
175
176        let new_confidence = match config.forgetting_model {
177            ForgettingModel::Ebbinghaus => ebbinghaus_confidence(
178                fact.confidence,
179                days_since_confirmed,
180                days_since_retrieved,
181                retrieval_count,
182                net_feedback,
183                base_stability,
184            ),
185            ForgettingModel::Linear => linear_confidence(
186                fact.confidence,
187                days_since_confirmed,
188                days_since_retrieved,
189                retrieval_count,
190                net_feedback,
191                config.decay_rate_per_day,
192            ),
193        };
194        if (new_confidence - fact.confidence).abs() > 0.001 {
195            fact.confidence = new_confidence;
196            count += 1;
197        }
198    }
199
200    if count > 0 && config.forgetting_model == ForgettingModel::Ebbinghaus {
201        crate::core::introspect::tick("power_law_decay");
202    }
203    count
204}
205
206/// Ebbinghaus retention `R = exp(-Δt / S)` (#1). Stability `S` grows with the
207/// spacing effect (each prior retrieval) and net feedback; `Δt` is time since
208/// the memory was last reinforced (confirmed *or* retrieved). Multiplicative so
209/// confidence approaches the floor smoothly and never overshoots. Deterministic.
210fn ebbinghaus_confidence(
211    confidence: f32,
212    days_since_confirmed: f32,
213    days_since_retrieved: f32,
214    retrieval_count: f32,
215    net_feedback: i64,
216    base_stability_days: f32,
217) -> f32 {
218    let elapsed = days_since_confirmed.min(days_since_retrieved).max(0.0);
219    let spacing = 1.0 + SPACING_GAIN * retrieval_count;
220    let feedback_mult = match net_feedback.cmp(&0) {
221        std::cmp::Ordering::Greater => 1.0 + (net_feedback as f32).ln_1p(),
222        std::cmp::Ordering::Less => 1.0 / (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()),
223        std::cmp::Ordering::Equal => 1.0,
224    };
225    let stability = (base_stability_days * spacing * feedback_mult).max(MIN_STABILITY_DAYS);
226    let retention = (-(f64::from(elapsed)) / f64::from(stability)).exp() as f32;
227    (confidence * retention).max(CONFIDENCE_FLOOR)
228}
229
230/// Legacy linear subtraction, preserved verbatim for `forgetting_model = linear`.
231/// FadeMem-inspired: protect frequently/recently retrieved facts; feedback
232/// steers retention. Deterministic, local-only.
233fn linear_confidence(
234    confidence: f32,
235    days_since_confirmed: f32,
236    days_since_retrieved: f32,
237    retrieval_count: f32,
238    net_feedback: i64,
239    decay_rate_per_day: f32,
240) -> f32 {
241    let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p());
242    let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0);
243    let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
244    let feedback_factor = match net_feedback.cmp(&0) {
245        std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
246        std::cmp::Ordering::Less => (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0),
247        std::cmp::Ordering::Equal => 1.0,
248    };
249    let decay = decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
250    (confidence - decay).max(CONFIDENCE_FLOOR)
251}
252
253pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
254    let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
255
256    let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
257        std::collections::HashMap::new();
258    for (i, f) in facts.iter().enumerate() {
259        if f.is_current() {
260            category_groups
261                .entry(f.category.clone())
262                .or_default()
263                .push(i);
264        }
265    }
266
267    for indices in category_groups.values() {
268        for (pos_a, &i) in indices.iter().enumerate() {
269            if to_remove.contains(&i) {
270                continue;
271            }
272            for &j in &indices[pos_a + 1..] {
273                if to_remove.contains(&j) {
274                    continue;
275                }
276                let sim = word_similarity(&facts[i].value, &facts[j].value);
277                if sim >= similarity_threshold {
278                    if facts[i].confidence >= facts[j].confidence {
279                        facts[i].confirmation_count += facts[j].confirmation_count;
280                        if facts[j].last_confirmed > facts[i].last_confirmed {
281                            facts[i].last_confirmed = facts[j].last_confirmed;
282                        }
283                        to_remove.insert(j);
284                    } else {
285                        facts[j].confirmation_count += facts[i].confirmation_count;
286                        if facts[i].last_confirmed > facts[j].last_confirmed {
287                            facts[j].last_confirmed = facts[i].last_confirmed;
288                        }
289                        to_remove.insert(i);
290                        break;
291                    }
292                }
293            }
294        }
295    }
296
297    let count = to_remove.len();
298    let mut sorted: Vec<usize> = to_remove.into_iter().collect();
299    sorted.sort_unstable();
300    for idx in sorted.into_iter().rev() {
301        facts.remove(idx);
302    }
303
304    count
305}
306
307pub fn compact(
308    facts: &mut Vec<KnowledgeFact>,
309    config: &LifecycleConfig,
310) -> (usize, Vec<KnowledgeFact>) {
311    let mut archived: Vec<KnowledgeFact> = Vec::new();
312    let now = Utc::now();
313    let stale_threshold = now - Duration::days(config.stale_days);
314
315    let mut to_archive: Vec<usize> = Vec::new();
316
317    for (i, fact) in facts.iter().enumerate() {
318        let recently_retrieved = fact
319            .last_retrieved
320            .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
321        let frequently_retrieved = fact.retrieval_count >= 5;
322
323        if fact.confidence < config.low_confidence_threshold {
324            to_archive.push(i);
325            continue;
326        }
327
328        // Real pruning (#962): a single-confirmation fact untouched for the
329        // configured horizon that was *never* retrieved is dead weight even at
330        // high confidence — archive it. Gated on `confirmation_count <= 1` so
331        // repeatedly-confirmed (structurally important) facts are always kept.
332        if let Some(days) = config.prune_unretrieved_after_days {
333            let cutoff = now - Duration::days(days);
334            if fact.last_confirmed < cutoff
335                && fact.retrieval_count == 0
336                && fact.last_retrieved.is_none()
337                && fact.confirmation_count <= 1
338            {
339                to_archive.push(i);
340                continue;
341            }
342        }
343
344        if fact.last_confirmed < stale_threshold
345            && fact.confirmation_count <= 1
346            && fact.confidence < 0.5
347            && !recently_retrieved
348            && !frequently_retrieved
349        {
350            to_archive.push(i);
351        }
352    }
353
354    to_archive.sort_unstable();
355    to_archive.dedup();
356
357    for idx in to_archive.into_iter().rev() {
358        archived.push(facts.remove(idx));
359    }
360
361    // Quality-only archival here. Capacity reclaim moved to `run_lifecycle` so it
362    // flows through the single capacity manager ([`crate::core::memory_capacity`])
363    // like every other store, keeping `compact` a pure quality pass.
364    (archived.len(), archived)
365}
366
367pub fn run_lifecycle(
368    facts: &mut Vec<KnowledgeFact>,
369    config: &LifecycleConfig,
370) -> Result<LifecycleReport, String> {
371    let decayed = apply_confidence_decay(facts, config);
372    let consolidated = consolidate_similar(facts, config.consolidation_similarity);
373    let (compacted, archived) = compact(facts, config);
374
375    if !archived.is_empty() {
376        archive_facts(&archived)?;
377    }
378
379    // No capacity reclaim for facts: knowledge has no count limit (owner
380    // decision 24.10.); only the quality rules above archive a fact.
381    Ok(LifecycleReport {
382        decayed_count: decayed,
383        consolidated_count: consolidated,
384        archived_count: archived.len(),
385        compacted_count: compacted,
386        remaining_facts: facts.len(),
387    })
388}
389
390/// Archive evicted facts (lossless) into the request project's facts
391/// partition; the generic multi-store archive lives in
392/// [`crate::core::memory_archive`].
393pub fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
394    crate::core::memory_archive::archive_items(
395        MemoryStore::Facts,
396        None,
397        facts,
398        &ArchiveConfig::from_env(),
399    )
400    .map(|_| ())
401}
402
403/// Run `f` with `project_root` as the request project, so the facts archive
404/// resolves to that project's partition and never to the process directory.
405fn in_project<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
406    crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
407        std::cell::RefCell::new(Some(PathBuf::from(project_root))),
408        f,
409    )
410}
411
412/// Restore the facts from a single archive file of `project_root`'s partition
413/// (legacy `facts` key supported).
414pub fn restore_archive(
415    project_root: &str,
416    archive_path: &str,
417) -> Result<Vec<KnowledgeFact>, String> {
418    in_project(project_root, || {
419        crate::core::memory_archive::restore_items(std::path::Path::new(archive_path))
420    })
421}
422
423/// All facts archive files of `project_root`, sorted ascending (chronological).
424pub fn list_archives(project_root: &str) -> Vec<PathBuf> {
425    in_project(project_root, || {
426        crate::core::memory_archive::list_archives(MemoryStore::Facts, None)
427    })
428}
429
430/// The newest reachable facts archives of `project_root` for the recall-miss
431/// rehydrate path — bounded by [`ArchiveConfig::rehydrate_reach`] so every
432/// retained archive is reachable (closes the pre-#995 retained-vs-reachable gap).
433pub fn reachable_archives(project_root: &str, cfg: &ArchiveConfig) -> Vec<PathBuf> {
434    in_project(project_root, || {
435        crate::core::memory_archive::reachable_archives(MemoryStore::Facts, None, cfg)
436    })
437}
438
439fn word_similarity(a: &str, b: &str) -> f32 {
440    let a_lower = a.to_lowercase();
441    let b_lower = b.to_lowercase();
442    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
443    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
444
445    if a_words.is_empty() && b_words.is_empty() {
446        return 1.0;
447    }
448
449    let intersection = a_words.intersection(&b_words).count();
450    let union = a_words.union(&b_words).count();
451
452    if union == 0 {
453        return 0.0;
454    }
455
456    intersection as f32 / union as f32
457}
458
459#[cfg(test)]
460pub mod tests {
461    use super::*;
462    use crate::core::knowledge::KnowledgeArchetype;
463
464    /// Capacity reclaim archives the evicted tail to disk, so any test that drives
465    /// [`run_lifecycle`] over capacity must sandbox the data dir.
466    fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
467        let _lock = crate::core::data_dir::test_env_lock();
468        let dir = std::env::temp_dir().join(format!(
469            "lctx-lifecycle-{}-{}",
470            std::process::id(),
471            Utc::now().timestamp_nanos_opt().unwrap_or(0)
472        ));
473        let _ = std::fs::create_dir_all(&dir);
474        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
475        let out = f();
476        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
477        let _ = std::fs::remove_dir_all(&dir);
478        out
479    }
480
481    fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
482        KnowledgeFact {
483            anchors: Vec::new(),
484            review: None,
485            capture: Default::default(),
486            origin: crate::core::knowledge::FactOrigin::Local,
487            category: category.to_string(),
488            key: key.to_string(),
489            value: value.to_string(),
490            source_session: "s1".to_string(),
491            confidence,
492            created_at: Utc::now(),
493            last_confirmed: Utc::now(),
494            retrieval_count: 0,
495            last_retrieved: None,
496            valid_from: Some(Utc::now()),
497            valid_until: None,
498            supersedes: None,
499            confirmation_count: 1,
500            feedback_up: 0,
501            feedback_down: 0,
502            last_feedback: None,
503            privacy: crate::core::memory_boundary::FactPrivacy::default(),
504            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
505            imported_from: None,
506            archetype: KnowledgeArchetype::default(),
507            fidelity: None,
508            revision_count: 0,
509            status: None,
510            validity: None,
511        }
512    }
513
514    fn make_old_fact(
515        category: &str,
516        key: &str,
517        value: &str,
518        confidence: f32,
519        days_old: i64,
520    ) -> KnowledgeFact {
521        let past = Utc::now() - Duration::days(days_old);
522        KnowledgeFact {
523            anchors: Vec::new(),
524            review: None,
525            capture: Default::default(),
526            origin: crate::core::knowledge::FactOrigin::Local,
527            category: category.to_string(),
528            key: key.to_string(),
529            value: value.to_string(),
530            source_session: "s1".to_string(),
531            confidence,
532            created_at: past,
533            last_confirmed: past,
534            retrieval_count: 0,
535            last_retrieved: None,
536            valid_from: Some(past),
537            valid_until: None,
538            supersedes: None,
539            confirmation_count: 1,
540            feedback_up: 0,
541            feedback_down: 0,
542            last_feedback: None,
543            privacy: crate::core::memory_boundary::FactPrivacy::default(),
544            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
545            imported_from: None,
546            archetype: KnowledgeArchetype::default(),
547            fidelity: None,
548            revision_count: 0,
549            status: None,
550            validity: None,
551        }
552    }
553
554    #[test]
555    fn decay_reduces_confidence() {
556        let config = LifecycleConfig::default();
557        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
558
559        let count = apply_confidence_decay(&mut facts, &config);
560        assert_eq!(count, 1);
561        assert!(facts[0].confidence < 0.9);
562        assert!(facts[0].confidence > 0.7);
563    }
564
565    #[test]
566    fn archetype_aware_decay_protects_evidence() {
567        // Opt-in: structural evidence (Architecture) decays slower than inference
568        // (Preference). Off (default), archetype is ignored and both decay alike.
569        let mut evidence = make_old_fact("arch", "db", "PostgreSQL", 0.9, 30);
570        evidence.archetype = KnowledgeArchetype::Architecture;
571        let mut inference = make_old_fact("pref", "style", "tabs", 0.9, 30);
572        inference.archetype = KnowledgeArchetype::Preference;
573
574        let off = LifecycleConfig::default();
575        let mut a = vec![evidence.clone(), inference.clone()];
576        apply_confidence_decay(&mut a, &off);
577        assert!(
578            (a[0].confidence - a[1].confidence).abs() < 1e-6,
579            "flag off → archetype ignored, equal decay"
580        );
581
582        let on = LifecycleConfig {
583            archetype_aware_decay: true,
584            ..Default::default()
585        };
586        let mut b = vec![evidence, inference];
587        apply_confidence_decay(&mut b, &on);
588        assert!(
589            b[0].confidence > b[1].confidence,
590            "evidence {} should outlast inference {}",
591            b[0].confidence,
592            b[1].confidence
593        );
594    }
595
596    #[test]
597    fn decay_skips_recent_facts() {
598        let config = LifecycleConfig::default();
599        let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
600
601        let count = apply_confidence_decay(&mut facts, &config);
602        assert_eq!(count, 0);
603    }
604
605    #[test]
606    fn feedback_steers_decay_keep_vs_forget() {
607        let config = LifecycleConfig::default();
608        let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
609        praised.feedback_up = 5;
610        let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
611        panned.feedback_down = 5;
612        let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
613
614        let mut facts = vec![praised, panned, neutral];
615        apply_confidence_decay(&mut facts, &config);
616
617        let (praised_c, panned_c, neutral_c) = (
618            facts[0].confidence,
619            facts[1].confidence,
620            facts[2].confidence,
621        );
622
623        // Reward bridge: up-voted retains more than neutral, neutral more than down-voted.
624        assert!(
625            praised_c > neutral_c,
626            "praised {praised_c} should outlast neutral {neutral_c}"
627        );
628        assert!(
629            neutral_c > panned_c,
630            "neutral {neutral_c} should outlast panned {panned_c}"
631        );
632        // Even a heavily down-voted fact only fades toward the floor — never hard-deleted.
633        assert!(panned_c >= 0.05);
634    }
635
636    #[test]
637    fn spacing_effect_protects_frequently_retrieved() {
638        // #1: under the Ebbinghaus curve, a fact retrieved many times must decay
639        // slower than an identical never-retrieved fact of the same age.
640        let config = LifecycleConfig::default();
641        let rarely = make_old_fact("arch", "rare", "x", 0.9, 20);
642        let mut often = make_old_fact("arch", "often", "y", 0.9, 20);
643        often.retrieval_count = 20;
644        let mut facts = vec![rarely, often];
645        apply_confidence_decay(&mut facts, &config);
646        assert!(
647            facts[1].confidence > facts[0].confidence,
648            "spacing effect: rehearsed {} should outlast un-rehearsed {}",
649            facts[1].confidence,
650            facts[0].confidence
651        );
652    }
653
654    #[test]
655    fn ebbinghaus_decay_is_deterministic() {
656        // Determinism contract (#498): same input → same output, no RNG.
657        let config = LifecycleConfig::default();
658        let mut a = vec![make_old_fact("arch", "k", "v", 0.8, 15)];
659        let mut b = a.clone();
660        apply_confidence_decay(&mut a, &config);
661        apply_confidence_decay(&mut b, &config);
662        assert_eq!(a[0].confidence, b[0].confidence);
663    }
664
665    #[test]
666    fn linear_model_still_available() {
667        // Opt-out path keeps the legacy subtractive behavior.
668        let config = LifecycleConfig {
669            forgetting_model: ForgettingModel::Linear,
670            ..Default::default()
671        };
672        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
673        let count = apply_confidence_decay(&mut facts, &config);
674        assert_eq!(count, 1);
675        assert!(facts[0].confidence < 0.9 && facts[0].confidence > 0.7);
676    }
677
678    #[test]
679    fn forgetting_model_parses() {
680        assert_eq!(ForgettingModel::parse("linear"), ForgettingModel::Linear);
681        assert_eq!(
682            ForgettingModel::parse("ebbinghaus"),
683            ForgettingModel::Ebbinghaus
684        );
685        assert_eq!(
686            ForgettingModel::parse("garbage"),
687            ForgettingModel::Ebbinghaus
688        );
689    }
690
691    #[test]
692    fn consolidate_similar_facts() {
693        let mut facts = vec![
694            make_fact("arch", "db", "uses PostgreSQL database", 0.8),
695            make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
696            make_fact("ops", "deploy", "docker compose up", 0.9),
697        ];
698
699        let count = consolidate_similar(&mut facts, 0.7);
700        assert!(count > 0, "Should consolidate similar facts");
701        assert!(facts.len() < 3);
702    }
703
704    #[test]
705    fn consolidate_keeps_different_categories() {
706        let mut facts = vec![
707            make_fact("arch", "db", "PostgreSQL", 0.8),
708            make_fact("ops", "db", "PostgreSQL", 0.8),
709        ];
710
711        let count = consolidate_similar(&mut facts, 0.9);
712        assert_eq!(count, 0, "Different categories should not consolidate");
713    }
714
715    #[test]
716    fn compact_removes_low_confidence() {
717        let config = LifecycleConfig::default();
718        let mut facts = vec![
719            make_fact("arch", "db", "PostgreSQL", 0.9),
720            make_fact("arch", "cache", "Redis", 0.1),
721        ];
722
723        let (count, archived) = compact(&mut facts, &config);
724        assert_eq!(count, 1);
725        assert_eq!(facts.len(), 1);
726        assert_eq!(archived.len(), 1);
727        assert_eq!(archived[0].key, "cache");
728    }
729
730    #[test]
731    fn compact_is_quality_only_and_ignores_capacity() {
732        // Knowledge has no fact limit: `compact` archives by quality only, so a
733        // store full of healthy, current, high-confidence facts stays untouched.
734        let config = LifecycleConfig::default();
735        let mut facts: Vec<KnowledgeFact> = (0..8)
736            .map(|i| make_fact("finding", &format!("k{i}"), &format!("value {i}"), 0.8))
737            .collect();
738
739        let (count, archived) = compact(&mut facts, &config);
740
741        assert_eq!(count, 0, "quality compact must not evict for capacity");
742        assert!(archived.is_empty());
743        assert_eq!(facts.len(), 8);
744    }
745
746    #[test]
747    fn run_lifecycle_never_reclaims_capacity() {
748        with_temp_data_dir(|| {
749            // Owner decision 24.10.: knowledge has no count limit. A store far
750            // over the old default cap (200) with only healthy facts keeps all
751            // of them, the expired one included (only quality rules archive).
752            let config = LifecycleConfig::default();
753            let mut facts: Vec<KnowledgeFact> = (0..450)
754                .map(|i| {
755                    make_fact(
756                        "finding",
757                        &format!("k{i}"),
758                        &format!("distinct finding number {i} about module m{i}"),
759                        0.8,
760                    )
761                })
762                .collect();
763            facts[0].valid_until = Some(Utc::now() - Duration::seconds(1));
764
765            let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
766
767            assert_eq!(report.archived_count, 0);
768            assert_eq!(facts.len(), 450);
769            assert_eq!(report.remaining_facts, 450);
770        });
771    }
772
773    #[test]
774    fn prune_unretrieved_archives_old_never_retrieved_facts() {
775        // Opt-in (#962): a 60-day-old, high-confidence, never-retrieved,
776        // single-confirmation fact is dead weight and must be archived even
777        // though its confidence is well above the low-confidence floor.
778        let config = LifecycleConfig {
779            prune_unretrieved_after_days: Some(30),
780            ..Default::default()
781        };
782        let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
783        let (count, archived) = compact(&mut facts, &config);
784        assert_eq!(count, 1);
785        assert_eq!(archived.len(), 1);
786        assert!(facts.is_empty());
787    }
788
789    #[test]
790    fn prune_unretrieved_is_off_by_default() {
791        // Default config (None) must not touch a high-confidence stale fact —
792        // existing tuning stays byte-for-byte.
793        let config = LifecycleConfig::default();
794        let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
795        let (count, _) = compact(&mut facts, &config);
796        assert_eq!(count, 0);
797        assert_eq!(facts.len(), 1);
798    }
799
800    #[test]
801    fn prune_unretrieved_keeps_retrieved_and_confirmed_facts() {
802        let config = LifecycleConfig {
803            prune_unretrieved_after_days: Some(30),
804            ..Default::default()
805        };
806        let mut retrieved = make_old_fact("arch", "used", "v", 0.9, 60);
807        retrieved.retrieval_count = 3;
808        let mut confirmed = make_old_fact("arch", "confirmed", "v", 0.9, 60);
809        confirmed.confirmation_count = 4;
810        let mut facts = vec![retrieved, confirmed];
811        let (count, _) = compact(&mut facts, &config);
812        assert_eq!(count, 0, "retrieved or repeatedly-confirmed facts are kept");
813        assert_eq!(facts.len(), 2);
814    }
815
816    #[test]
817    fn compact_archives_stale_facts() {
818        let config = LifecycleConfig::default();
819        let mut facts = vec![
820            make_fact("arch", "db", "PostgreSQL", 0.9),
821            make_old_fact("arch", "old", "ancient thing", 0.4, 60),
822        ];
823
824        let (count, archived) = compact(&mut facts, &config);
825        assert_eq!(count, 1);
826        assert_eq!(archived[0].key, "old");
827    }
828
829    #[test]
830    fn full_lifecycle_run() {
831        with_temp_data_dir(|| {
832            let config = LifecycleConfig::default();
833
834            let mut facts = vec![
835                make_fact("arch", "db", "PostgreSQL", 0.9),
836                make_fact("arch", "cache", "Redis", 0.8),
837                make_old_fact("arch", "old1", "thing1", 0.2, 50),
838                make_old_fact("arch", "old2", "thing2", 0.15, 60),
839                make_fact("ops", "deploy", "docker compose", 0.7),
840            ];
841
842            let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
843            assert!(report.remaining_facts < 5, "the stale facts are archived");
844            assert!(report.decayed_count > 0 || report.compacted_count > 0);
845        });
846    }
847
848    #[test]
849    fn word_similarity_identical() {
850        assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
851    }
852
853    #[test]
854    fn word_similarity_partial() {
855        let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
856        assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
857        assert!(sim < 1.0);
858    }
859
860    #[test]
861    fn word_similarity_different() {
862        let sim = word_similarity("Redis cache", "Docker compose");
863        assert!(sim < 0.1);
864    }
865}