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 std::path::PathBuf;
11
12use super::knowledge::{KnowledgeFact, sort_fact_for_output};
13use super::memory_archive::{ArchiveConfig, MemoryStore};
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/// Default proactive headroom on a capacity reclaim: settle a full store at 75%
20/// so it keeps real working room instead of churning at its cap.
21pub const DEFAULT_RECLAIM_HEADROOM_PCT: f32 = 0.25;
22
23/// Spacing/testing effect: how strongly each prior retrieval lengthens memory
24/// stability. 0.5 ⇒ ~10 retrievals make a fact roughly 6× more durable.
25const SPACING_GAIN: f32 = 0.5;
26/// Floor on derived stability (days) so even a heavily down-voted fact decays
27/// smoothly rather than collapsing in a single pass.
28const MIN_STABILITY_DAYS: f32 = 1.0;
29/// Confidence never decays below this — archival happens elsewhere, decay never
30/// hard-deletes.
31const CONFIDENCE_FLOOR: f32 = 0.05;
32/// Default characteristic memory stability (days) for the Ebbinghaus curve.
33pub const DEFAULT_BASE_STABILITY_DAYS: f32 = 90.0;
34
35/// Which forgetting curve drives confidence decay (#1).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum ForgettingModel {
38    /// Exponential retention `R = exp(-Δt / S)` with spacing-boosted stability
39    /// `S` (Ebbinghaus forgetting curve + SM-2 spacing). Deterministic, the
40    /// default: durable memories fade gracefully, rehearsed ones persist.
41    #[default]
42    Ebbinghaus,
43    /// Legacy linear subtraction, kept for reproducibility / explicit opt-out.
44    Linear,
45}
46
47impl ForgettingModel {
48    pub fn parse(s: &str) -> Self {
49        match s.trim().to_lowercase().as_str() {
50            "linear" => Self::Linear,
51            _ => Self::Ebbinghaus,
52        }
53    }
54
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::Ebbinghaus => "ebbinghaus",
58            Self::Linear => "linear",
59        }
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct LifecycleConfig {
65    pub decay_rate_per_day: f32,
66    pub max_facts: usize,
67    pub low_confidence_threshold: f32,
68    pub stale_days: i64,
69    pub consolidation_similarity: f32,
70    /// Forgetting curve (#1). Defaults to Ebbinghaus.
71    pub forgetting_model: ForgettingModel,
72    /// Characteristic stability (days) for the Ebbinghaus curve before spacing
73    /// and feedback modulation.
74    pub base_stability_days: f32,
75    /// When true, scale stability by the fact's archetype so structural *evidence*
76    /// (architecture/dependency/…) decays slower than *inference* (#802/cognition).
77    /// Default false keeps the baseline tuning byte-for-byte.
78    pub archetype_aware_decay: bool,
79    /// Archive facts untouched for this many days that were **never** retrieved —
80    /// dead weight that costs injection tokens regardless of confidence (#962).
81    /// `None` disables it (the default, so existing tuning is unchanged); the
82    /// production policy can opt in. Reversible: pruned facts go to the archive.
83    pub prune_unretrieved_after_days: Option<i64>,
84    /// Proactive headroom on a capacity reclaim (#995): when a store reaches its
85    /// cap, drop down to `reclaim_target(max_facts, reclaim_headroom_pct)` so it
86    /// settles with working room instead of churning at the cap. `0.25` = 75%.
87    pub reclaim_headroom_pct: f32,
88    /// Master switch for the proactive capacity reclaim (#995). `false` restores
89    /// the legacy "trim only the overflow" behavior — the documented escape
90    /// hatch. Eviction stays lossless either way (excess is archived).
91    pub reclaim_enabled: bool,
92}
93
94impl Default for LifecycleConfig {
95    fn default() -> Self {
96        Self {
97            decay_rate_per_day: DEFAULT_DECAY_RATE,
98            max_facts: DEFAULT_MAX_FACTS,
99            low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
100            stale_days: STALE_DAYS,
101            consolidation_similarity: 0.85,
102            forgetting_model: ForgettingModel::default(),
103            base_stability_days: DEFAULT_BASE_STABILITY_DAYS,
104            archetype_aware_decay: false,
105            prune_unretrieved_after_days: None,
106            reclaim_headroom_pct: DEFAULT_RECLAIM_HEADROOM_PCT,
107            reclaim_enabled: true,
108        }
109    }
110}
111
112impl LifecycleConfig {
113    /// Map the persisted [`crate::core::memory_policy::MemoryPolicy`] to the
114    /// runtime lifecycle config. The single mapping site, so adding a knob
115    /// touches exactly one place (previously duplicated across the lifecycle and
116    /// cognition callers).
117    pub fn from_policy(policy: &crate::core::memory_policy::MemoryPolicy) -> Self {
118        Self {
119            max_facts: policy.knowledge.max_facts,
120            decay_rate_per_day: policy.lifecycle.decay_rate,
121            low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
122            stale_days: policy.lifecycle.stale_days,
123            consolidation_similarity: policy.lifecycle.similarity_threshold,
124            forgetting_model: ForgettingModel::parse(&policy.lifecycle.forgetting_model),
125            base_stability_days: policy.lifecycle.base_stability_days,
126            archetype_aware_decay: policy.lifecycle.archetype_aware_decay,
127            prune_unretrieved_after_days: policy.lifecycle.prune_unretrieved_after_days,
128            reclaim_headroom_pct: policy.lifecycle.reclaim_headroom_pct,
129            reclaim_enabled: policy.lifecycle.reclaim_enabled,
130        }
131    }
132}
133
134#[derive(Debug, Default)]
135pub struct LifecycleReport {
136    pub decayed_count: usize,
137    pub consolidated_count: usize,
138    pub archived_count: usize,
139    pub compacted_count: usize,
140    /// Of `archived_count`, how many facts were evicted purely for capacity
141    /// (the proactive reclaim) versus quality (low-confidence/stale/unretrieved).
142    /// Lets callers report per-store capacity reclaim distinctly (#995).
143    pub capacity_archived: usize,
144    pub remaining_facts: usize,
145}
146
147pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
148    let now = Utc::now();
149    let mut count = 0;
150
151    for fact in facts.iter_mut() {
152        if !fact.is_current() {
153            continue;
154        }
155
156        if let Some(valid_until) = fact.valid_until
157            && valid_until < now
158            && fact.confidence > 0.1
159        {
160            fact.confidence = 0.1;
161            count += 1;
162            continue;
163        }
164
165        let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
166        if days_since_confirmed <= 0.0 {
167            continue;
168        }
169        let days_since_retrieved = fact
170            .last_retrieved
171            .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
172        let retrieval_count = fact.retrieval_count as f32;
173        let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
174
175        // Archetype-aware stability (opt-in): structural evidence is more durable
176        // than inference. Off by default → identical to the prior baseline.
177        let base_stability = if config.archetype_aware_decay {
178            config.base_stability_days * fact.archetype.stability_multiplier()
179        } else {
180            config.base_stability_days
181        };
182
183        let new_confidence = match config.forgetting_model {
184            ForgettingModel::Ebbinghaus => ebbinghaus_confidence(
185                fact.confidence,
186                days_since_confirmed,
187                days_since_retrieved,
188                retrieval_count,
189                net_feedback,
190                base_stability,
191            ),
192            ForgettingModel::Linear => linear_confidence(
193                fact.confidence,
194                days_since_confirmed,
195                days_since_retrieved,
196                retrieval_count,
197                net_feedback,
198                config.decay_rate_per_day,
199            ),
200        };
201        if (new_confidence - fact.confidence).abs() > 0.001 {
202            fact.confidence = new_confidence;
203            count += 1;
204        }
205    }
206
207    if count > 0 && config.forgetting_model == ForgettingModel::Ebbinghaus {
208        crate::core::introspect::tick("power_law_decay");
209    }
210    count
211}
212
213/// Ebbinghaus retention `R = exp(-Δt / S)` (#1). Stability `S` grows with the
214/// spacing effect (each prior retrieval) and net feedback; `Δt` is time since
215/// the memory was last reinforced (confirmed *or* retrieved). Multiplicative so
216/// confidence approaches the floor smoothly and never overshoots. Deterministic.
217fn ebbinghaus_confidence(
218    confidence: f32,
219    days_since_confirmed: f32,
220    days_since_retrieved: f32,
221    retrieval_count: f32,
222    net_feedback: i64,
223    base_stability_days: f32,
224) -> f32 {
225    let elapsed = days_since_confirmed.min(days_since_retrieved).max(0.0);
226    let spacing = 1.0 + SPACING_GAIN * retrieval_count;
227    let feedback_mult = match net_feedback.cmp(&0) {
228        std::cmp::Ordering::Greater => 1.0 + (net_feedback as f32).ln_1p(),
229        std::cmp::Ordering::Less => 1.0 / (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()),
230        std::cmp::Ordering::Equal => 1.0,
231    };
232    let stability = (base_stability_days * spacing * feedback_mult).max(MIN_STABILITY_DAYS);
233    let retention = (-(f64::from(elapsed)) / f64::from(stability)).exp() as f32;
234    (confidence * retention).max(CONFIDENCE_FLOOR)
235}
236
237/// Legacy linear subtraction, preserved verbatim for `forgetting_model = linear`.
238/// FadeMem-inspired: protect frequently/recently retrieved facts; feedback
239/// steers retention. Deterministic, local-only.
240fn linear_confidence(
241    confidence: f32,
242    days_since_confirmed: f32,
243    days_since_retrieved: f32,
244    retrieval_count: f32,
245    net_feedback: i64,
246    decay_rate_per_day: f32,
247) -> f32 {
248    let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p());
249    let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0);
250    let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
251    let feedback_factor = match net_feedback.cmp(&0) {
252        std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
253        std::cmp::Ordering::Less => (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0),
254        std::cmp::Ordering::Equal => 1.0,
255    };
256    let decay = decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
257    (confidence - decay).max(CONFIDENCE_FLOOR)
258}
259
260pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
261    let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
262
263    let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
264        std::collections::HashMap::new();
265    for (i, f) in facts.iter().enumerate() {
266        if f.is_current() {
267            category_groups
268                .entry(f.category.clone())
269                .or_default()
270                .push(i);
271        }
272    }
273
274    for indices in category_groups.values() {
275        for (pos_a, &i) in indices.iter().enumerate() {
276            if to_remove.contains(&i) {
277                continue;
278            }
279            for &j in &indices[pos_a + 1..] {
280                if to_remove.contains(&j) {
281                    continue;
282                }
283                let sim = word_similarity(&facts[i].value, &facts[j].value);
284                if sim >= similarity_threshold {
285                    if facts[i].confidence >= facts[j].confidence {
286                        facts[i].confirmation_count += facts[j].confirmation_count;
287                        if facts[j].last_confirmed > facts[i].last_confirmed {
288                            facts[i].last_confirmed = facts[j].last_confirmed;
289                        }
290                        to_remove.insert(j);
291                    } else {
292                        facts[j].confirmation_count += facts[i].confirmation_count;
293                        if facts[i].last_confirmed > facts[j].last_confirmed {
294                            facts[j].last_confirmed = facts[i].last_confirmed;
295                        }
296                        to_remove.insert(i);
297                        break;
298                    }
299                }
300            }
301        }
302    }
303
304    let count = to_remove.len();
305    let mut sorted: Vec<usize> = to_remove.into_iter().collect();
306    sorted.sort_unstable();
307    for idx in sorted.into_iter().rev() {
308        facts.remove(idx);
309    }
310
311    count
312}
313
314pub fn compact(
315    facts: &mut Vec<KnowledgeFact>,
316    config: &LifecycleConfig,
317) -> (usize, Vec<KnowledgeFact>) {
318    let mut archived: Vec<KnowledgeFact> = Vec::new();
319    let now = Utc::now();
320    let stale_threshold = now - Duration::days(config.stale_days);
321
322    let mut to_archive: Vec<usize> = Vec::new();
323
324    for (i, fact) in facts.iter().enumerate() {
325        let recently_retrieved = fact
326            .last_retrieved
327            .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
328        let frequently_retrieved = fact.retrieval_count >= 5;
329
330        if fact.confidence < config.low_confidence_threshold {
331            to_archive.push(i);
332            continue;
333        }
334
335        // Real pruning (#962): a single-confirmation fact untouched for the
336        // configured horizon that was *never* retrieved is dead weight even at
337        // high confidence — archive it. Gated on `confirmation_count <= 1` so
338        // repeatedly-confirmed (structurally important) facts are always kept.
339        if let Some(days) = config.prune_unretrieved_after_days {
340            let cutoff = now - Duration::days(days);
341            if fact.last_confirmed < cutoff
342                && fact.retrieval_count == 0
343                && fact.last_retrieved.is_none()
344                && fact.confirmation_count <= 1
345            {
346                to_archive.push(i);
347                continue;
348            }
349        }
350
351        if fact.last_confirmed < stale_threshold
352            && fact.confirmation_count <= 1
353            && fact.confidence < 0.5
354            && !recently_retrieved
355            && !frequently_retrieved
356        {
357            to_archive.push(i);
358        }
359    }
360
361    to_archive.sort_unstable();
362    to_archive.dedup();
363
364    for idx in to_archive.into_iter().rev() {
365        archived.push(facts.remove(idx));
366    }
367
368    // Quality-only archival here. Capacity reclaim moved to `run_lifecycle` so it
369    // flows through the single capacity manager ([`crate::core::memory_capacity`])
370    // like every other store, keeping `compact` a pure quality pass.
371    (archived.len(), archived)
372}
373
374/// Guardrails for cluster compaction (#971). See
375/// [`crate::core::memory_policy::CompactionPolicy`] for field meanings.
376#[derive(Debug, Clone)]
377pub struct ClusterCompactionConfig {
378    pub min_cluster: usize,
379    pub similarity: f32,
380    pub max_confidence: f32,
381    pub max_confirmations: u32,
382}
383
384/// Maximum digest value length (chars). Bounded so a digest never re-bloats the
385/// store it was meant to shrink.
386const COMPACTION_VALUE_MAX: usize = 400;
387
388/// Collapse clusters of low-value, mutually-similar, same-category facts into one
389/// recoverable digest each. Returns `(clusters_collapsed, archived_originals)`;
390/// the caller archives the originals so the operation is lossless. Deterministic:
391/// candidates are scanned in the store's existing order and similarity ties
392/// resolve to the earliest-founded cluster.
393pub fn compact_clusters(
394    facts: &mut Vec<KnowledgeFact>,
395    cfg: &ClusterCompactionConfig,
396) -> (usize, Vec<KnowledgeFact>) {
397    if cfg.min_cluster < 2 {
398        return (0, Vec::new());
399    }
400    let now = Utc::now();
401
402    // Eligible = current, faded, barely-confirmed, cold, and not itself a digest
403    // or a synthesized summary (summaries are never compacted).
404    let eligible = |f: &KnowledgeFact| -> bool {
405        if !f.is_current() {
406            return false;
407        }
408        if f.source_session == crate::core::knowledge::COMPACTION_DIGEST_SOURCE
409            || f.source_session == crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE
410        {
411            return false;
412        }
413        let recently_retrieved = f
414            .last_retrieved
415            .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
416        let frequently_retrieved = f.retrieval_count >= 5;
417        f.confidence < cfg.max_confidence
418            && f.confirmation_count <= cfg.max_confirmations
419            && !recently_retrieved
420            && !frequently_retrieved
421    };
422
423    // Group eligible indices by category, preserving first-seen order.
424    let mut by_category: Vec<(String, Vec<usize>)> = Vec::new();
425    for (i, f) in facts.iter().enumerate() {
426        if !eligible(f) {
427            continue;
428        }
429        match by_category.iter_mut().find(|(c, _)| *c == f.category) {
430            Some((_, v)) => v.push(i),
431            None => by_category.push((f.category.clone(), vec![i])),
432        }
433    }
434
435    // Greedy agglomerate within each category by average word similarity, then
436    // keep only clusters that reach the minimum size.
437    let mut clusters: Vec<Vec<usize>> = Vec::new();
438    for (_, indices) in &by_category {
439        let mut cat_clusters: Vec<Vec<usize>> = Vec::new();
440        for &i in indices {
441            let mut best: Option<(usize, f32)> = None;
442            for (ci, cl) in cat_clusters.iter().enumerate() {
443                let avg = cl
444                    .iter()
445                    .map(|&j| word_similarity(&facts[i].value, &facts[j].value))
446                    .sum::<f32>()
447                    / cl.len() as f32;
448                if avg >= cfg.similarity && best.is_none_or(|(_, b)| avg > b) {
449                    best = Some((ci, avg));
450                }
451            }
452            if let Some((ci, _)) = best {
453                cat_clusters[ci].push(i);
454            } else {
455                cat_clusters.push(vec![i]);
456            }
457        }
458        clusters.extend(
459            cat_clusters
460                .into_iter()
461                .filter(|c| c.len() >= cfg.min_cluster),
462        );
463    }
464
465    if clusters.is_empty() {
466        return (0, Vec::new());
467    }
468
469    // Build a digest per cluster, then remove the originals (high→low so indices
470    // stay valid) and append the digests.
471    let mut remove: Vec<usize> = Vec::new();
472    let mut digests: Vec<KnowledgeFact> = Vec::with_capacity(clusters.len());
473    for cluster in &clusters {
474        let members: Vec<&KnowledgeFact> = cluster.iter().map(|&i| &facts[i]).collect();
475        digests.push(build_digest(&members, now));
476        remove.extend(cluster.iter().copied());
477    }
478
479    remove.sort_unstable();
480    remove.dedup();
481    let mut archived: Vec<KnowledgeFact> = Vec::with_capacity(remove.len());
482    for idx in remove.into_iter().rev() {
483        archived.push(facts.remove(idx));
484    }
485    facts.extend(digests);
486
487    (clusters.len(), archived)
488}
489
490/// Synthesize one digest fact from a cluster's members. Byte-stable for a given
491/// set of members: members are sorted, the value is built deterministically, and
492/// the key is content-addressed (md5 of category + sorted member keys) so a
493/// re-run over the same inputs is idempotent.
494fn build_digest(members: &[&KnowledgeFact], now: DateTime<Utc>) -> KnowledgeFact {
495    use md5::{Digest, Md5};
496
497    let mut sorted: Vec<&KnowledgeFact> = members.to_vec();
498    sorted.sort_by(|a, b| a.key.cmp(&b.key).then_with(|| a.value.cmp(&b.value)));
499
500    let category = sorted[0].category.clone();
501    let max_conf = sorted.iter().map(|f| f.confidence).fold(0.0_f32, f32::max);
502    let confirmations: u32 = sorted.iter().map(|f| f.confirmation_count).sum();
503
504    let body: Vec<String> = sorted
505        .iter()
506        .map(|f| format!("{}: {}", f.key, f.value))
507        .collect();
508    let value_full = format!(
509        "Compacted {} low-signal {category} facts — {}",
510        sorted.len(),
511        body.join("; ")
512    );
513    let value = truncate_chars(&value_full, COMPACTION_VALUE_MAX);
514
515    let mut hasher = Md5::new();
516    hasher.update(category.as_bytes());
517    for f in &sorted {
518        hasher.update(b"\n");
519        hasher.update(f.key.as_bytes());
520    }
521    let hash = crate::core::agent_identity::hex_encode(&hasher.finalize());
522    let key = format!("digest-{}", &hash[..8]);
523
524    let sensitivity = crate::core::sensitivity::classify_content(&value);
525    KnowledgeFact {
526        category,
527        key,
528        value,
529        source_session: crate::core::knowledge::COMPACTION_DIGEST_SOURCE.to_string(),
530        confidence: max_conf,
531        created_at: now,
532        last_confirmed: now,
533        retrieval_count: 0,
534        last_retrieved: None,
535        valid_from: Some(now),
536        valid_until: None,
537        supersedes: None,
538        confirmation_count: confirmations.max(1),
539        feedback_up: 0,
540        feedback_down: 0,
541        last_feedback: None,
542        privacy: crate::core::memory_boundary::FactPrivacy::default(),
543        sensitivity,
544        imported_from: None,
545        archetype: crate::core::knowledge::KnowledgeArchetype::Observation,
546        fidelity: None,
547        revision_count: 0,
548    }
549}
550
551/// Truncate to at most `max` characters on a char boundary, appending an ellipsis
552/// when content was dropped.
553fn truncate_chars(s: &str, max: usize) -> String {
554    if s.chars().count() <= max {
555        return s.to_string();
556    }
557    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
558    out.push('…');
559    out
560}
561
562pub fn run_lifecycle(
563    facts: &mut Vec<KnowledgeFact>,
564    config: &LifecycleConfig,
565) -> Result<LifecycleReport, String> {
566    let decayed = apply_confidence_decay(facts, config);
567    let consolidated = consolidate_similar(facts, config.consolidation_similarity);
568    let (compacted, archived) = compact(facts, config);
569
570    if !archived.is_empty() {
571        let _ = archive_facts(&archived);
572    }
573
574    // Capacity reclaim (#995): facts settle at headroom via the single capacity
575    // manager, archiving the evicted tail losslessly under the legacy facts root
576    // — the same path quality archival uses, so recall rehydration is uniform.
577    let capacity_archived = crate::core::memory_capacity::reclaim_store(
578        MemoryStore::Facts,
579        None,
580        facts,
581        config.max_facts,
582        config.reclaim_headroom_pct,
583        config.reclaim_enabled,
584        |a, b| {
585            b.is_current()
586                .cmp(&a.is_current())
587                .then_with(|| sort_fact_for_output(a, b))
588        },
589    )?
590    .len();
591
592    Ok(LifecycleReport {
593        decayed_count: decayed,
594        consolidated_count: consolidated,
595        archived_count: archived.len() + capacity_archived,
596        compacted_count: compacted + capacity_archived,
597        capacity_archived,
598        remaining_facts: facts.len(),
599    })
600}
601
602/// Archive evicted facts (lossless). Facts keep the legacy global archive root
603/// for backward compatibility; the generic multi-store archive lives in
604/// [`crate::core::memory_archive`].
605pub fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
606    crate::core::memory_archive::archive_items(
607        MemoryStore::Facts,
608        None,
609        facts,
610        &ArchiveConfig::from_env(),
611    )
612    .map(|_| ())
613}
614
615/// Restore the facts from a single archive file (legacy `facts` key supported).
616pub fn restore_archive(archive_path: &str) -> Result<Vec<KnowledgeFact>, String> {
617    crate::core::memory_archive::restore_items(std::path::Path::new(archive_path))
618}
619
620/// All facts archive files, sorted ascending (chronological).
621pub fn list_archives() -> Vec<PathBuf> {
622    crate::core::memory_archive::list_archives(MemoryStore::Facts, None)
623}
624
625/// The newest reachable facts archives for the recall-miss rehydrate path —
626/// bounded by [`ArchiveConfig::rehydrate_reach`] so every retained archive is
627/// reachable (closes the pre-#995 retained-vs-reachable gap).
628pub fn reachable_archives(cfg: &ArchiveConfig) -> Vec<PathBuf> {
629    crate::core::memory_archive::reachable_archives(MemoryStore::Facts, None, cfg)
630}
631
632fn word_similarity(a: &str, b: &str) -> f32 {
633    let a_lower = a.to_lowercase();
634    let b_lower = b.to_lowercase();
635    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
636    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
637
638    if a_words.is_empty() && b_words.is_empty() {
639        return 1.0;
640    }
641
642    let intersection = a_words.intersection(&b_words).count();
643    let union = a_words.union(&b_words).count();
644
645    if union == 0 {
646        return 0.0;
647    }
648
649    intersection as f32 / union as f32
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::core::knowledge::KnowledgeArchetype;
656
657    /// Capacity reclaim archives the evicted tail to disk, so any test that drives
658    /// [`run_lifecycle`] over capacity must sandbox the data dir.
659    fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
660        let _lock = crate::core::data_dir::test_env_lock();
661        let dir = std::env::temp_dir().join(format!(
662            "lctx-lifecycle-{}-{}",
663            std::process::id(),
664            Utc::now().timestamp_nanos_opt().unwrap_or(0)
665        ));
666        let _ = std::fs::create_dir_all(&dir);
667        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
668        let out = f();
669        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
670        let _ = std::fs::remove_dir_all(&dir);
671        out
672    }
673
674    fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
675        KnowledgeFact {
676            category: category.to_string(),
677            key: key.to_string(),
678            value: value.to_string(),
679            source_session: "s1".to_string(),
680            confidence,
681            created_at: Utc::now(),
682            last_confirmed: Utc::now(),
683            retrieval_count: 0,
684            last_retrieved: None,
685            valid_from: Some(Utc::now()),
686            valid_until: None,
687            supersedes: None,
688            confirmation_count: 1,
689            feedback_up: 0,
690            feedback_down: 0,
691            last_feedback: None,
692            privacy: crate::core::memory_boundary::FactPrivacy::default(),
693            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
694            imported_from: None,
695            archetype: KnowledgeArchetype::default(),
696            fidelity: None,
697            revision_count: 0,
698        }
699    }
700
701    fn make_old_fact(
702        category: &str,
703        key: &str,
704        value: &str,
705        confidence: f32,
706        days_old: i64,
707    ) -> KnowledgeFact {
708        let past = Utc::now() - Duration::days(days_old);
709        KnowledgeFact {
710            category: category.to_string(),
711            key: key.to_string(),
712            value: value.to_string(),
713            source_session: "s1".to_string(),
714            confidence,
715            created_at: past,
716            last_confirmed: past,
717            retrieval_count: 0,
718            last_retrieved: None,
719            valid_from: Some(past),
720            valid_until: None,
721            supersedes: None,
722            confirmation_count: 1,
723            feedback_up: 0,
724            feedback_down: 0,
725            last_feedback: None,
726            privacy: crate::core::memory_boundary::FactPrivacy::default(),
727            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
728            imported_from: None,
729            archetype: KnowledgeArchetype::default(),
730            fidelity: None,
731            revision_count: 0,
732        }
733    }
734
735    #[test]
736    fn decay_reduces_confidence() {
737        let config = LifecycleConfig::default();
738        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
739
740        let count = apply_confidence_decay(&mut facts, &config);
741        assert_eq!(count, 1);
742        assert!(facts[0].confidence < 0.9);
743        assert!(facts[0].confidence > 0.7);
744    }
745
746    #[test]
747    fn archetype_aware_decay_protects_evidence() {
748        // Opt-in: structural evidence (Architecture) decays slower than inference
749        // (Preference). Off (default), archetype is ignored and both decay alike.
750        let mut evidence = make_old_fact("arch", "db", "PostgreSQL", 0.9, 30);
751        evidence.archetype = KnowledgeArchetype::Architecture;
752        let mut inference = make_old_fact("pref", "style", "tabs", 0.9, 30);
753        inference.archetype = KnowledgeArchetype::Preference;
754
755        let off = LifecycleConfig::default();
756        let mut a = vec![evidence.clone(), inference.clone()];
757        apply_confidence_decay(&mut a, &off);
758        assert!(
759            (a[0].confidence - a[1].confidence).abs() < 1e-6,
760            "flag off → archetype ignored, equal decay"
761        );
762
763        let on = LifecycleConfig {
764            archetype_aware_decay: true,
765            ..Default::default()
766        };
767        let mut b = vec![evidence, inference];
768        apply_confidence_decay(&mut b, &on);
769        assert!(
770            b[0].confidence > b[1].confidence,
771            "evidence {} should outlast inference {}",
772            b[0].confidence,
773            b[1].confidence
774        );
775    }
776
777    #[test]
778    fn decay_skips_recent_facts() {
779        let config = LifecycleConfig::default();
780        let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
781
782        let count = apply_confidence_decay(&mut facts, &config);
783        assert_eq!(count, 0);
784    }
785
786    #[test]
787    fn feedback_steers_decay_keep_vs_forget() {
788        let config = LifecycleConfig::default();
789        let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
790        praised.feedback_up = 5;
791        let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
792        panned.feedback_down = 5;
793        let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
794
795        let mut facts = vec![praised, panned, neutral];
796        apply_confidence_decay(&mut facts, &config);
797
798        let (praised_c, panned_c, neutral_c) = (
799            facts[0].confidence,
800            facts[1].confidence,
801            facts[2].confidence,
802        );
803
804        // Reward bridge: up-voted retains more than neutral, neutral more than down-voted.
805        assert!(
806            praised_c > neutral_c,
807            "praised {praised_c} should outlast neutral {neutral_c}"
808        );
809        assert!(
810            neutral_c > panned_c,
811            "neutral {neutral_c} should outlast panned {panned_c}"
812        );
813        // Even a heavily down-voted fact only fades toward the floor — never hard-deleted.
814        assert!(panned_c >= 0.05);
815    }
816
817    #[test]
818    fn spacing_effect_protects_frequently_retrieved() {
819        // #1: under the Ebbinghaus curve, a fact retrieved many times must decay
820        // slower than an identical never-retrieved fact of the same age.
821        let config = LifecycleConfig::default();
822        let rarely = make_old_fact("arch", "rare", "x", 0.9, 20);
823        let mut often = make_old_fact("arch", "often", "y", 0.9, 20);
824        often.retrieval_count = 20;
825        let mut facts = vec![rarely, often];
826        apply_confidence_decay(&mut facts, &config);
827        assert!(
828            facts[1].confidence > facts[0].confidence,
829            "spacing effect: rehearsed {} should outlast un-rehearsed {}",
830            facts[1].confidence,
831            facts[0].confidence
832        );
833    }
834
835    #[test]
836    fn ebbinghaus_decay_is_deterministic() {
837        // Determinism contract (#498): same input → same output, no RNG.
838        let config = LifecycleConfig::default();
839        let mut a = vec![make_old_fact("arch", "k", "v", 0.8, 15)];
840        let mut b = a.clone();
841        apply_confidence_decay(&mut a, &config);
842        apply_confidence_decay(&mut b, &config);
843        assert_eq!(a[0].confidence, b[0].confidence);
844    }
845
846    #[test]
847    fn linear_model_still_available() {
848        // Opt-out path keeps the legacy subtractive behavior.
849        let config = LifecycleConfig {
850            forgetting_model: ForgettingModel::Linear,
851            ..Default::default()
852        };
853        let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
854        let count = apply_confidence_decay(&mut facts, &config);
855        assert_eq!(count, 1);
856        assert!(facts[0].confidence < 0.9 && facts[0].confidence > 0.7);
857    }
858
859    #[test]
860    fn forgetting_model_parses() {
861        assert_eq!(ForgettingModel::parse("linear"), ForgettingModel::Linear);
862        assert_eq!(
863            ForgettingModel::parse("ebbinghaus"),
864            ForgettingModel::Ebbinghaus
865        );
866        assert_eq!(
867            ForgettingModel::parse("garbage"),
868            ForgettingModel::Ebbinghaus
869        );
870    }
871
872    #[test]
873    fn consolidate_similar_facts() {
874        let mut facts = vec![
875            make_fact("arch", "db", "uses PostgreSQL database", 0.8),
876            make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
877            make_fact("ops", "deploy", "docker compose up", 0.9),
878        ];
879
880        let count = consolidate_similar(&mut facts, 0.7);
881        assert!(count > 0, "Should consolidate similar facts");
882        assert!(facts.len() < 3);
883    }
884
885    #[test]
886    fn consolidate_keeps_different_categories() {
887        let mut facts = vec![
888            make_fact("arch", "db", "PostgreSQL", 0.8),
889            make_fact("ops", "db", "PostgreSQL", 0.8),
890        ];
891
892        let count = consolidate_similar(&mut facts, 0.9);
893        assert_eq!(count, 0, "Different categories should not consolidate");
894    }
895
896    #[test]
897    fn compact_removes_low_confidence() {
898        let config = LifecycleConfig::default();
899        let mut facts = vec![
900            make_fact("arch", "db", "PostgreSQL", 0.9),
901            make_fact("arch", "cache", "Redis", 0.1),
902        ];
903
904        let (count, archived) = compact(&mut facts, &config);
905        assert_eq!(count, 1);
906        assert_eq!(facts.len(), 1);
907        assert_eq!(archived.len(), 1);
908        assert_eq!(archived[0].key, "cache");
909    }
910
911    #[test]
912    fn compact_is_quality_only_and_ignores_capacity() {
913        // Post-#995: capacity reclaim moved out of `compact` into the single
914        // capacity manager (driven by `run_lifecycle`). A store full of healthy,
915        // current, high-confidence facts is a *capacity* concern, so `compact`
916        // (quality only) must leave it untouched.
917        let config = LifecycleConfig {
918            max_facts: 8,
919            ..Default::default()
920        };
921        let mut facts: Vec<KnowledgeFact> = (0..8)
922            .map(|i| make_fact("finding", &format!("k{i}"), &format!("value {i}"), 0.8))
923            .collect();
924
925        let (count, archived) = compact(&mut facts, &config);
926
927        assert_eq!(count, 0, "quality compact must not evict for capacity");
928        assert!(archived.is_empty());
929        assert_eq!(facts.len(), 8);
930    }
931
932    #[test]
933    fn run_lifecycle_reclaims_capacity_to_headroom() {
934        with_temp_data_dir(|| {
935            let config = LifecycleConfig {
936                max_facts: 8,
937                ..Default::default()
938            };
939            let mut facts: Vec<KnowledgeFact> = (0..8)
940                .map(|i| make_fact("finding", &format!("k{i}"), &format!("value {i}"), 0.8))
941                .collect();
942
943            let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
944
945            // Hysteresis: at cap (8) → settle to headroom target (6), archive 2.
946            assert_eq!(report.capacity_archived, 2);
947            assert_eq!(facts.len(), 6);
948            assert_eq!(report.remaining_facts, 6);
949        });
950    }
951
952    #[test]
953    fn run_lifecycle_evicts_expired_before_current() {
954        with_temp_data_dir(|| {
955            let config = LifecycleConfig {
956                max_facts: 4,
957                ..Default::default()
958            };
959            let decision = make_fact("decision", "keep-decision", "important decision", 0.7);
960            let finding = make_fact("finding", "keep-finding", "fresh finding", 0.9);
961            let mut old = make_fact("decision", "drop-archived", "old decision", 0.95);
962            // Definitively expired (not just "now") so retention ordering is stable.
963            old.valid_until = Some(Utc::now() - Duration::seconds(1));
964            let low = make_fact("misc", "drop-low", "low salience", 0.6);
965            let mut facts = vec![old, low, finding, decision];
966
967            let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
968            let keys: Vec<&str> = facts.iter().map(|f| f.key.as_str()).collect();
969
970            // 4 at cap → settle to 3; the expired fact sorts last (not current) and
971            // is the single eviction, so every current fact survives.
972            assert_eq!(report.capacity_archived, 1);
973            assert!(keys.contains(&"keep-decision"));
974            assert!(keys.contains(&"keep-finding"));
975            assert!(!keys.contains(&"drop-archived"));
976        });
977    }
978
979    #[test]
980    fn prune_unretrieved_archives_old_never_retrieved_facts() {
981        // Opt-in (#962): a 60-day-old, high-confidence, never-retrieved,
982        // single-confirmation fact is dead weight and must be archived even
983        // though its confidence is well above the low-confidence floor.
984        let config = LifecycleConfig {
985            prune_unretrieved_after_days: Some(30),
986            ..Default::default()
987        };
988        let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
989        let (count, archived) = compact(&mut facts, &config);
990        assert_eq!(count, 1);
991        assert_eq!(archived.len(), 1);
992        assert!(facts.is_empty());
993    }
994
995    #[test]
996    fn prune_unretrieved_is_off_by_default() {
997        // Default config (None) must not touch a high-confidence stale fact —
998        // existing tuning stays byte-for-byte.
999        let config = LifecycleConfig::default();
1000        let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
1001        let (count, _) = compact(&mut facts, &config);
1002        assert_eq!(count, 0);
1003        assert_eq!(facts.len(), 1);
1004    }
1005
1006    #[test]
1007    fn prune_unretrieved_keeps_retrieved_and_confirmed_facts() {
1008        let config = LifecycleConfig {
1009            prune_unretrieved_after_days: Some(30),
1010            ..Default::default()
1011        };
1012        let mut retrieved = make_old_fact("arch", "used", "v", 0.9, 60);
1013        retrieved.retrieval_count = 3;
1014        let mut confirmed = make_old_fact("arch", "confirmed", "v", 0.9, 60);
1015        confirmed.confirmation_count = 4;
1016        let mut facts = vec![retrieved, confirmed];
1017        let (count, _) = compact(&mut facts, &config);
1018        assert_eq!(count, 0, "retrieved or repeatedly-confirmed facts are kept");
1019        assert_eq!(facts.len(), 2);
1020    }
1021
1022    #[test]
1023    fn compact_archives_stale_facts() {
1024        let config = LifecycleConfig::default();
1025        let mut facts = vec![
1026            make_fact("arch", "db", "PostgreSQL", 0.9),
1027            make_old_fact("arch", "old", "ancient thing", 0.4, 60),
1028        ];
1029
1030        let (count, archived) = compact(&mut facts, &config);
1031        assert_eq!(count, 1);
1032        assert_eq!(archived[0].key, "old");
1033    }
1034
1035    #[test]
1036    fn full_lifecycle_run() {
1037        let config = LifecycleConfig {
1038            max_facts: 5,
1039            ..Default::default()
1040        };
1041
1042        let mut facts = vec![
1043            make_fact("arch", "db", "PostgreSQL", 0.9),
1044            make_fact("arch", "cache", "Redis", 0.8),
1045            make_old_fact("arch", "old1", "thing1", 0.2, 50),
1046            make_old_fact("arch", "old2", "thing2", 0.15, 60),
1047            make_fact("ops", "deploy", "docker compose", 0.7),
1048        ];
1049
1050        let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
1051        assert!(report.remaining_facts <= config.max_facts);
1052        assert!(report.decayed_count > 0 || report.compacted_count > 0);
1053    }
1054
1055    #[test]
1056    fn word_similarity_identical() {
1057        assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
1058    }
1059
1060    #[test]
1061    fn word_similarity_partial() {
1062        let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
1063        assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
1064        assert!(sim < 1.0);
1065    }
1066
1067    #[test]
1068    fn word_similarity_different() {
1069        let sim = word_similarity("Redis cache", "Docker compose");
1070        assert!(sim < 0.1);
1071    }
1072
1073    // === Cluster compaction (#971) ===
1074
1075    fn cc_config() -> ClusterCompactionConfig {
1076        ClusterCompactionConfig {
1077            min_cluster: 4,
1078            similarity: 0.5,
1079            max_confidence: 0.5,
1080            max_confirmations: 1,
1081        }
1082    }
1083
1084    fn faded_cluster(n: usize) -> Vec<KnowledgeFact> {
1085        (0..n)
1086            .map(|i| {
1087                make_old_fact(
1088                    "logs",
1089                    &format!("entry{i}"),
1090                    &format!("request handler returned a transient retry case {i}"),
1091                    0.2,
1092                    40,
1093                )
1094            })
1095            .collect()
1096    }
1097
1098    #[test]
1099    fn compact_clusters_collapses_low_value_cluster_into_digest() {
1100        let mut facts = faded_cluster(5);
1101        let (collapsed, archived) = compact_clusters(&mut facts, &cc_config());
1102
1103        assert_eq!(collapsed, 1);
1104        assert_eq!(archived.len(), 5, "all originals archived (recoverable)");
1105        assert_eq!(facts.len(), 1, "five facts became one digest");
1106
1107        let digest = &facts[0];
1108        assert_eq!(
1109            digest.source_session,
1110            crate::core::knowledge::COMPACTION_DIGEST_SOURCE
1111        );
1112        assert!(digest.key.starts_with("digest-"));
1113        assert!(digest.value.contains("Compacted 5 low-signal logs facts"));
1114    }
1115
1116    #[test]
1117    fn compact_clusters_leaves_high_value_facts() {
1118        let cfg = cc_config();
1119
1120        // High confidence → above the importance ceiling.
1121        let mut high_conf: Vec<KnowledgeFact> = (0..5)
1122            .map(|i| {
1123                make_old_fact(
1124                    "logs",
1125                    &format!("k{i}"),
1126                    "request handler returned a transient retry",
1127                    0.9,
1128                    40,
1129                )
1130            })
1131            .collect();
1132        let (c1, _) = compact_clusters(&mut high_conf, &cfg);
1133        assert_eq!(c1, 0);
1134        assert_eq!(high_conf.len(), 5);
1135
1136        // Frequently retrieved → valuable even when faded.
1137        let mut retrieved: Vec<KnowledgeFact> = (0..5)
1138            .map(|i| {
1139                let mut f = make_old_fact(
1140                    "logs",
1141                    &format!("k{i}"),
1142                    "request handler returned a transient retry",
1143                    0.2,
1144                    40,
1145                );
1146                f.retrieval_count = 9;
1147                f
1148            })
1149            .collect();
1150        let (c2, _) = compact_clusters(&mut retrieved, &cfg);
1151        assert_eq!(c2, 0);
1152        assert_eq!(retrieved.len(), 5);
1153    }
1154
1155    #[test]
1156    fn compact_clusters_respects_min_cluster() {
1157        let mut facts = faded_cluster(3); // below min_cluster (4)
1158        let (collapsed, archived) = compact_clusters(&mut facts, &cc_config());
1159        assert_eq!(collapsed, 0);
1160        assert!(archived.is_empty());
1161        assert_eq!(facts.len(), 3);
1162    }
1163
1164    #[test]
1165    fn compact_clusters_is_deterministic() {
1166        let cfg = cc_config();
1167        let mut a = faded_cluster(5);
1168        let mut b = faded_cluster(5);
1169        compact_clusters(&mut a, &cfg);
1170        compact_clusters(&mut b, &cfg);
1171        assert_eq!(a.len(), 1);
1172        assert_eq!(b.len(), 1);
1173        assert_eq!(a[0].key, b[0].key, "content-addressed digest key is stable");
1174        assert_eq!(a[0].value, b[0].value, "digest value is byte-stable");
1175    }
1176
1177    #[test]
1178    fn compact_clusters_skips_digests_and_summaries() {
1179        let mut facts = faded_cluster(5);
1180        for f in &mut facts {
1181            f.source_session = crate::core::knowledge::COMPACTION_DIGEST_SOURCE.to_string();
1182        }
1183        let (collapsed, _) = compact_clusters(&mut facts, &cc_config());
1184        assert_eq!(collapsed, 0, "existing digests are never re-compacted");
1185        assert_eq!(facts.len(), 5);
1186    }
1187
1188    #[test]
1189    fn truncate_chars_is_char_boundary_safe() {
1190        let s = "äöü".repeat(300); // 900 multibyte chars, well over the cap
1191        let t = truncate_chars(&s, 400);
1192        assert!(t.chars().count() <= 400);
1193        assert!(t.ends_with('…'));
1194        // No panic on a non-ASCII boundary is the real assertion here.
1195    }
1196}