1use 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;
19pub const DEFAULT_RECLAIM_HEADROOM_PCT: f32 = 0.25;
22
23const SPACING_GAIN: f32 = 0.5;
26const MIN_STABILITY_DAYS: f32 = 1.0;
29const CONFIDENCE_FLOOR: f32 = 0.05;
32pub const DEFAULT_BASE_STABILITY_DAYS: f32 = 90.0;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum ForgettingModel {
38 #[default]
42 Ebbinghaus,
43 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 pub forgetting_model: ForgettingModel,
72 pub base_stability_days: f32,
75 pub archetype_aware_decay: bool,
79 pub prune_unretrieved_after_days: Option<i64>,
84 pub reclaim_headroom_pct: f32,
88 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 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 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 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
213fn 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
237fn 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 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 (archived.len(), archived)
372}
373
374#[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
384const COMPACTION_VALUE_MAX: usize = 400;
387
388pub 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 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 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 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 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
490fn 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
551fn 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(facts: &mut Vec<KnowledgeFact>, config: &LifecycleConfig) -> LifecycleReport {
563 let decayed = apply_confidence_decay(facts, config);
564 let consolidated = consolidate_similar(facts, config.consolidation_similarity);
565 let (compacted, archived) = compact(facts, config);
566
567 if !archived.is_empty() {
568 let _ = archive_facts(&archived);
569 }
570
571 let capacity_archived = crate::core::memory_capacity::reclaim_store(
575 MemoryStore::Facts,
576 None,
577 facts,
578 config.max_facts,
579 config.reclaim_headroom_pct,
580 config.reclaim_enabled,
581 |a, b| {
582 b.is_current()
583 .cmp(&a.is_current())
584 .then_with(|| sort_fact_for_output(a, b))
585 },
586 )
587 .len();
588
589 LifecycleReport {
590 decayed_count: decayed,
591 consolidated_count: consolidated,
592 archived_count: archived.len() + capacity_archived,
593 compacted_count: compacted + capacity_archived,
594 capacity_archived,
595 remaining_facts: facts.len(),
596 }
597}
598
599pub fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
603 crate::core::memory_archive::archive_items(
604 MemoryStore::Facts,
605 None,
606 facts,
607 &ArchiveConfig::from_env(),
608 )
609 .map(|_| ())
610}
611
612pub fn restore_archive(archive_path: &str) -> Result<Vec<KnowledgeFact>, String> {
614 crate::core::memory_archive::restore_items(std::path::Path::new(archive_path))
615}
616
617pub fn list_archives() -> Vec<PathBuf> {
619 crate::core::memory_archive::list_archives(MemoryStore::Facts, None)
620}
621
622pub fn reachable_archives(cfg: &ArchiveConfig) -> Vec<PathBuf> {
626 crate::core::memory_archive::reachable_archives(MemoryStore::Facts, None, cfg)
627}
628
629fn word_similarity(a: &str, b: &str) -> f32 {
630 let a_lower = a.to_lowercase();
631 let b_lower = b.to_lowercase();
632 let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
633 let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
634
635 if a_words.is_empty() && b_words.is_empty() {
636 return 1.0;
637 }
638
639 let intersection = a_words.intersection(&b_words).count();
640 let union = a_words.union(&b_words).count();
641
642 if union == 0 {
643 return 0.0;
644 }
645
646 intersection as f32 / union as f32
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::core::knowledge::KnowledgeArchetype;
653
654 fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
657 let _lock = crate::core::data_dir::test_env_lock();
658 let dir = std::env::temp_dir().join(format!(
659 "lctx-lifecycle-{}-{}",
660 std::process::id(),
661 Utc::now().timestamp_nanos_opt().unwrap_or(0)
662 ));
663 let _ = std::fs::create_dir_all(&dir);
664 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
665 let out = f();
666 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
667 let _ = std::fs::remove_dir_all(&dir);
668 out
669 }
670
671 fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
672 KnowledgeFact {
673 category: category.to_string(),
674 key: key.to_string(),
675 value: value.to_string(),
676 source_session: "s1".to_string(),
677 confidence,
678 created_at: Utc::now(),
679 last_confirmed: Utc::now(),
680 retrieval_count: 0,
681 last_retrieved: None,
682 valid_from: Some(Utc::now()),
683 valid_until: None,
684 supersedes: None,
685 confirmation_count: 1,
686 feedback_up: 0,
687 feedback_down: 0,
688 last_feedback: None,
689 privacy: crate::core::memory_boundary::FactPrivacy::default(),
690 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
691 imported_from: None,
692 archetype: KnowledgeArchetype::default(),
693 fidelity: None,
694 revision_count: 0,
695 }
696 }
697
698 fn make_old_fact(
699 category: &str,
700 key: &str,
701 value: &str,
702 confidence: f32,
703 days_old: i64,
704 ) -> KnowledgeFact {
705 let past = Utc::now() - Duration::days(days_old);
706 KnowledgeFact {
707 category: category.to_string(),
708 key: key.to_string(),
709 value: value.to_string(),
710 source_session: "s1".to_string(),
711 confidence,
712 created_at: past,
713 last_confirmed: past,
714 retrieval_count: 0,
715 last_retrieved: None,
716 valid_from: Some(past),
717 valid_until: None,
718 supersedes: None,
719 confirmation_count: 1,
720 feedback_up: 0,
721 feedback_down: 0,
722 last_feedback: None,
723 privacy: crate::core::memory_boundary::FactPrivacy::default(),
724 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
725 imported_from: None,
726 archetype: KnowledgeArchetype::default(),
727 fidelity: None,
728 revision_count: 0,
729 }
730 }
731
732 #[test]
733 fn decay_reduces_confidence() {
734 let config = LifecycleConfig::default();
735 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
736
737 let count = apply_confidence_decay(&mut facts, &config);
738 assert_eq!(count, 1);
739 assert!(facts[0].confidence < 0.9);
740 assert!(facts[0].confidence > 0.7);
741 }
742
743 #[test]
744 fn archetype_aware_decay_protects_evidence() {
745 let mut evidence = make_old_fact("arch", "db", "PostgreSQL", 0.9, 30);
748 evidence.archetype = KnowledgeArchetype::Architecture;
749 let mut inference = make_old_fact("pref", "style", "tabs", 0.9, 30);
750 inference.archetype = KnowledgeArchetype::Preference;
751
752 let off = LifecycleConfig::default();
753 let mut a = vec![evidence.clone(), inference.clone()];
754 apply_confidence_decay(&mut a, &off);
755 assert!(
756 (a[0].confidence - a[1].confidence).abs() < 1e-6,
757 "flag off → archetype ignored, equal decay"
758 );
759
760 let on = LifecycleConfig {
761 archetype_aware_decay: true,
762 ..Default::default()
763 };
764 let mut b = vec![evidence, inference];
765 apply_confidence_decay(&mut b, &on);
766 assert!(
767 b[0].confidence > b[1].confidence,
768 "evidence {} should outlast inference {}",
769 b[0].confidence,
770 b[1].confidence
771 );
772 }
773
774 #[test]
775 fn decay_skips_recent_facts() {
776 let config = LifecycleConfig::default();
777 let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
778
779 let count = apply_confidence_decay(&mut facts, &config);
780 assert_eq!(count, 0);
781 }
782
783 #[test]
784 fn feedback_steers_decay_keep_vs_forget() {
785 let config = LifecycleConfig::default();
786 let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
787 praised.feedback_up = 5;
788 let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
789 panned.feedback_down = 5;
790 let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
791
792 let mut facts = vec![praised, panned, neutral];
793 apply_confidence_decay(&mut facts, &config);
794
795 let (praised_c, panned_c, neutral_c) = (
796 facts[0].confidence,
797 facts[1].confidence,
798 facts[2].confidence,
799 );
800
801 assert!(
803 praised_c > neutral_c,
804 "praised {praised_c} should outlast neutral {neutral_c}"
805 );
806 assert!(
807 neutral_c > panned_c,
808 "neutral {neutral_c} should outlast panned {panned_c}"
809 );
810 assert!(panned_c >= 0.05);
812 }
813
814 #[test]
815 fn spacing_effect_protects_frequently_retrieved() {
816 let config = LifecycleConfig::default();
819 let rarely = make_old_fact("arch", "rare", "x", 0.9, 20);
820 let mut often = make_old_fact("arch", "often", "y", 0.9, 20);
821 often.retrieval_count = 20;
822 let mut facts = vec![rarely, often];
823 apply_confidence_decay(&mut facts, &config);
824 assert!(
825 facts[1].confidence > facts[0].confidence,
826 "spacing effect: rehearsed {} should outlast un-rehearsed {}",
827 facts[1].confidence,
828 facts[0].confidence
829 );
830 }
831
832 #[test]
833 fn ebbinghaus_decay_is_deterministic() {
834 let config = LifecycleConfig::default();
836 let mut a = vec![make_old_fact("arch", "k", "v", 0.8, 15)];
837 let mut b = a.clone();
838 apply_confidence_decay(&mut a, &config);
839 apply_confidence_decay(&mut b, &config);
840 assert_eq!(a[0].confidence, b[0].confidence);
841 }
842
843 #[test]
844 fn linear_model_still_available() {
845 let config = LifecycleConfig {
847 forgetting_model: ForgettingModel::Linear,
848 ..Default::default()
849 };
850 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
851 let count = apply_confidence_decay(&mut facts, &config);
852 assert_eq!(count, 1);
853 assert!(facts[0].confidence < 0.9 && facts[0].confidence > 0.7);
854 }
855
856 #[test]
857 fn forgetting_model_parses() {
858 assert_eq!(ForgettingModel::parse("linear"), ForgettingModel::Linear);
859 assert_eq!(
860 ForgettingModel::parse("ebbinghaus"),
861 ForgettingModel::Ebbinghaus
862 );
863 assert_eq!(
864 ForgettingModel::parse("garbage"),
865 ForgettingModel::Ebbinghaus
866 );
867 }
868
869 #[test]
870 fn consolidate_similar_facts() {
871 let mut facts = vec![
872 make_fact("arch", "db", "uses PostgreSQL database", 0.8),
873 make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
874 make_fact("ops", "deploy", "docker compose up", 0.9),
875 ];
876
877 let count = consolidate_similar(&mut facts, 0.7);
878 assert!(count > 0, "Should consolidate similar facts");
879 assert!(facts.len() < 3);
880 }
881
882 #[test]
883 fn consolidate_keeps_different_categories() {
884 let mut facts = vec![
885 make_fact("arch", "db", "PostgreSQL", 0.8),
886 make_fact("ops", "db", "PostgreSQL", 0.8),
887 ];
888
889 let count = consolidate_similar(&mut facts, 0.9);
890 assert_eq!(count, 0, "Different categories should not consolidate");
891 }
892
893 #[test]
894 fn compact_removes_low_confidence() {
895 let config = LifecycleConfig::default();
896 let mut facts = vec![
897 make_fact("arch", "db", "PostgreSQL", 0.9),
898 make_fact("arch", "cache", "Redis", 0.1),
899 ];
900
901 let (count, archived) = compact(&mut facts, &config);
902 assert_eq!(count, 1);
903 assert_eq!(facts.len(), 1);
904 assert_eq!(archived.len(), 1);
905 assert_eq!(archived[0].key, "cache");
906 }
907
908 #[test]
909 fn compact_is_quality_only_and_ignores_capacity() {
910 let config = LifecycleConfig {
915 max_facts: 8,
916 ..Default::default()
917 };
918 let mut facts: Vec<KnowledgeFact> = (0..8)
919 .map(|i| make_fact("finding", &format!("k{i}"), &format!("value {i}"), 0.8))
920 .collect();
921
922 let (count, archived) = compact(&mut facts, &config);
923
924 assert_eq!(count, 0, "quality compact must not evict for capacity");
925 assert!(archived.is_empty());
926 assert_eq!(facts.len(), 8);
927 }
928
929 #[test]
930 fn run_lifecycle_reclaims_capacity_to_headroom() {
931 with_temp_data_dir(|| {
932 let config = LifecycleConfig {
933 max_facts: 8,
934 ..Default::default()
935 };
936 let mut facts: Vec<KnowledgeFact> = (0..8)
937 .map(|i| make_fact("finding", &format!("k{i}"), &format!("value {i}"), 0.8))
938 .collect();
939
940 let report = run_lifecycle(&mut facts, &config);
941
942 assert_eq!(report.capacity_archived, 2);
944 assert_eq!(facts.len(), 6);
945 assert_eq!(report.remaining_facts, 6);
946 });
947 }
948
949 #[test]
950 fn run_lifecycle_evicts_expired_before_current() {
951 with_temp_data_dir(|| {
952 let config = LifecycleConfig {
953 max_facts: 4,
954 ..Default::default()
955 };
956 let decision = make_fact("decision", "keep-decision", "important decision", 0.7);
957 let finding = make_fact("finding", "keep-finding", "fresh finding", 0.9);
958 let mut old = make_fact("decision", "drop-archived", "old decision", 0.95);
959 old.valid_until = Some(Utc::now() - Duration::seconds(1));
961 let low = make_fact("misc", "drop-low", "low salience", 0.6);
962 let mut facts = vec![old, low, finding, decision];
963
964 let report = run_lifecycle(&mut facts, &config);
965 let keys: Vec<&str> = facts.iter().map(|f| f.key.as_str()).collect();
966
967 assert_eq!(report.capacity_archived, 1);
970 assert!(keys.contains(&"keep-decision"));
971 assert!(keys.contains(&"keep-finding"));
972 assert!(!keys.contains(&"drop-archived"));
973 });
974 }
975
976 #[test]
977 fn prune_unretrieved_archives_old_never_retrieved_facts() {
978 let config = LifecycleConfig {
982 prune_unretrieved_after_days: Some(30),
983 ..Default::default()
984 };
985 let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
986 let (count, archived) = compact(&mut facts, &config);
987 assert_eq!(count, 1);
988 assert_eq!(archived.len(), 1);
989 assert!(facts.is_empty());
990 }
991
992 #[test]
993 fn prune_unretrieved_is_off_by_default() {
994 let config = LifecycleConfig::default();
997 let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
998 let (count, _) = compact(&mut facts, &config);
999 assert_eq!(count, 0);
1000 assert_eq!(facts.len(), 1);
1001 }
1002
1003 #[test]
1004 fn prune_unretrieved_keeps_retrieved_and_confirmed_facts() {
1005 let config = LifecycleConfig {
1006 prune_unretrieved_after_days: Some(30),
1007 ..Default::default()
1008 };
1009 let mut retrieved = make_old_fact("arch", "used", "v", 0.9, 60);
1010 retrieved.retrieval_count = 3;
1011 let mut confirmed = make_old_fact("arch", "confirmed", "v", 0.9, 60);
1012 confirmed.confirmation_count = 4;
1013 let mut facts = vec![retrieved, confirmed];
1014 let (count, _) = compact(&mut facts, &config);
1015 assert_eq!(count, 0, "retrieved or repeatedly-confirmed facts are kept");
1016 assert_eq!(facts.len(), 2);
1017 }
1018
1019 #[test]
1020 fn compact_archives_stale_facts() {
1021 let config = LifecycleConfig::default();
1022 let mut facts = vec![
1023 make_fact("arch", "db", "PostgreSQL", 0.9),
1024 make_old_fact("arch", "old", "ancient thing", 0.4, 60),
1025 ];
1026
1027 let (count, archived) = compact(&mut facts, &config);
1028 assert_eq!(count, 1);
1029 assert_eq!(archived[0].key, "old");
1030 }
1031
1032 #[test]
1033 fn full_lifecycle_run() {
1034 let config = LifecycleConfig {
1035 max_facts: 5,
1036 ..Default::default()
1037 };
1038
1039 let mut facts = vec![
1040 make_fact("arch", "db", "PostgreSQL", 0.9),
1041 make_fact("arch", "cache", "Redis", 0.8),
1042 make_old_fact("arch", "old1", "thing1", 0.2, 50),
1043 make_old_fact("arch", "old2", "thing2", 0.15, 60),
1044 make_fact("ops", "deploy", "docker compose", 0.7),
1045 ];
1046
1047 let report = run_lifecycle(&mut facts, &config);
1048 assert!(report.remaining_facts <= config.max_facts);
1049 assert!(report.decayed_count > 0 || report.compacted_count > 0);
1050 }
1051
1052 #[test]
1053 fn word_similarity_identical() {
1054 assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
1055 }
1056
1057 #[test]
1058 fn word_similarity_partial() {
1059 let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
1060 assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
1061 assert!(sim < 1.0);
1062 }
1063
1064 #[test]
1065 fn word_similarity_different() {
1066 let sim = word_similarity("Redis cache", "Docker compose");
1067 assert!(sim < 0.1);
1068 }
1069
1070 fn cc_config() -> ClusterCompactionConfig {
1073 ClusterCompactionConfig {
1074 min_cluster: 4,
1075 similarity: 0.5,
1076 max_confidence: 0.5,
1077 max_confirmations: 1,
1078 }
1079 }
1080
1081 fn faded_cluster(n: usize) -> Vec<KnowledgeFact> {
1082 (0..n)
1083 .map(|i| {
1084 make_old_fact(
1085 "logs",
1086 &format!("entry{i}"),
1087 &format!("request handler returned a transient retry case {i}"),
1088 0.2,
1089 40,
1090 )
1091 })
1092 .collect()
1093 }
1094
1095 #[test]
1096 fn compact_clusters_collapses_low_value_cluster_into_digest() {
1097 let mut facts = faded_cluster(5);
1098 let (collapsed, archived) = compact_clusters(&mut facts, &cc_config());
1099
1100 assert_eq!(collapsed, 1);
1101 assert_eq!(archived.len(), 5, "all originals archived (recoverable)");
1102 assert_eq!(facts.len(), 1, "five facts became one digest");
1103
1104 let digest = &facts[0];
1105 assert_eq!(
1106 digest.source_session,
1107 crate::core::knowledge::COMPACTION_DIGEST_SOURCE
1108 );
1109 assert!(digest.key.starts_with("digest-"));
1110 assert!(digest.value.contains("Compacted 5 low-signal logs facts"));
1111 }
1112
1113 #[test]
1114 fn compact_clusters_leaves_high_value_facts() {
1115 let cfg = cc_config();
1116
1117 let mut high_conf: Vec<KnowledgeFact> = (0..5)
1119 .map(|i| {
1120 make_old_fact(
1121 "logs",
1122 &format!("k{i}"),
1123 "request handler returned a transient retry",
1124 0.9,
1125 40,
1126 )
1127 })
1128 .collect();
1129 let (c1, _) = compact_clusters(&mut high_conf, &cfg);
1130 assert_eq!(c1, 0);
1131 assert_eq!(high_conf.len(), 5);
1132
1133 let mut retrieved: Vec<KnowledgeFact> = (0..5)
1135 .map(|i| {
1136 let mut f = make_old_fact(
1137 "logs",
1138 &format!("k{i}"),
1139 "request handler returned a transient retry",
1140 0.2,
1141 40,
1142 );
1143 f.retrieval_count = 9;
1144 f
1145 })
1146 .collect();
1147 let (c2, _) = compact_clusters(&mut retrieved, &cfg);
1148 assert_eq!(c2, 0);
1149 assert_eq!(retrieved.len(), 5);
1150 }
1151
1152 #[test]
1153 fn compact_clusters_respects_min_cluster() {
1154 let mut facts = faded_cluster(3); let (collapsed, archived) = compact_clusters(&mut facts, &cc_config());
1156 assert_eq!(collapsed, 0);
1157 assert!(archived.is_empty());
1158 assert_eq!(facts.len(), 3);
1159 }
1160
1161 #[test]
1162 fn compact_clusters_is_deterministic() {
1163 let cfg = cc_config();
1164 let mut a = faded_cluster(5);
1165 let mut b = faded_cluster(5);
1166 compact_clusters(&mut a, &cfg);
1167 compact_clusters(&mut b, &cfg);
1168 assert_eq!(a.len(), 1);
1169 assert_eq!(b.len(), 1);
1170 assert_eq!(a[0].key, b[0].key, "content-addressed digest key is stable");
1171 assert_eq!(a[0].value, b[0].value, "digest value is byte-stable");
1172 }
1173
1174 #[test]
1175 fn compact_clusters_skips_digests_and_summaries() {
1176 let mut facts = faded_cluster(5);
1177 for f in &mut facts {
1178 f.source_session = crate::core::knowledge::COMPACTION_DIGEST_SOURCE.to_string();
1179 }
1180 let (collapsed, _) = compact_clusters(&mut facts, &cc_config());
1181 assert_eq!(collapsed, 0, "existing digests are never re-compacted");
1182 assert_eq!(facts.len(), 5);
1183 }
1184
1185 #[test]
1186 fn truncate_chars_is_char_boundary_safe() {
1187 let s = "äöü".repeat(300); let t = truncate_chars(&s, 400);
1189 assert!(t.chars().count() <= 400);
1190 assert!(t.ends_with('…'));
1191 }
1193}