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