1use chrono::{Duration, Utc};
10use std::path::PathBuf;
11
12use super::knowledge::KnowledgeFact;
13use super::memory_archive::{ArchiveConfig, MemoryStore};
14
15const DEFAULT_DECAY_RATE: f32 = 0.01;
16const LOW_CONFIDENCE_THRESHOLD: f32 = 0.3;
17const STALE_DAYS: i64 = 30;
18pub const DEFAULT_RECLAIM_HEADROOM_PCT: f32 = 0.25;
21
22const SPACING_GAIN: f32 = 0.5;
25const MIN_STABILITY_DAYS: f32 = 1.0;
28const CONFIDENCE_FLOOR: f32 = 0.05;
31pub const DEFAULT_BASE_STABILITY_DAYS: f32 = 90.0;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum ForgettingModel {
37 #[default]
41 Ebbinghaus,
42 Linear,
44}
45
46impl ForgettingModel {
47 pub fn parse(s: &str) -> Self {
48 match s.trim().to_lowercase().as_str() {
49 "linear" => Self::Linear,
50 _ => Self::Ebbinghaus,
51 }
52 }
53
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::Ebbinghaus => "ebbinghaus",
57 Self::Linear => "linear",
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
63pub struct LifecycleConfig {
64 pub decay_rate_per_day: f32,
65 pub low_confidence_threshold: f32,
66 pub stale_days: i64,
67 pub consolidation_similarity: f32,
68 pub forgetting_model: ForgettingModel,
70 pub base_stability_days: f32,
73 pub archetype_aware_decay: bool,
77 pub prune_unretrieved_after_days: Option<i64>,
82 pub reclaim_headroom_pct: f32,
87 pub reclaim_enabled: bool,
91}
92
93impl Default for LifecycleConfig {
94 fn default() -> Self {
95 Self {
96 decay_rate_per_day: DEFAULT_DECAY_RATE,
97 low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
98 stale_days: STALE_DAYS,
99 consolidation_similarity: 0.85,
100 forgetting_model: ForgettingModel::default(),
101 base_stability_days: DEFAULT_BASE_STABILITY_DAYS,
102 archetype_aware_decay: false,
103 prune_unretrieved_after_days: None,
104 reclaim_headroom_pct: DEFAULT_RECLAIM_HEADROOM_PCT,
105 reclaim_enabled: true,
106 }
107 }
108}
109
110impl LifecycleConfig {
111 pub fn from_policy(policy: &crate::core::memory_policy::MemoryPolicy) -> Self {
116 Self {
117 decay_rate_per_day: policy.lifecycle.decay_rate,
118 low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
119 stale_days: policy.lifecycle.stale_days,
120 consolidation_similarity: policy.lifecycle.similarity_threshold,
121 forgetting_model: ForgettingModel::parse(&policy.lifecycle.forgetting_model),
122 base_stability_days: policy.lifecycle.base_stability_days,
123 archetype_aware_decay: policy.lifecycle.archetype_aware_decay,
124 prune_unretrieved_after_days: policy.lifecycle.prune_unretrieved_after_days,
125 reclaim_headroom_pct: policy.lifecycle.reclaim_headroom_pct,
126 reclaim_enabled: policy.lifecycle.reclaim_enabled,
127 }
128 }
129}
130
131#[derive(Debug, Default)]
132pub struct LifecycleReport {
133 pub decayed_count: usize,
134 pub consolidated_count: usize,
135 pub archived_count: usize,
136 pub compacted_count: usize,
137 pub remaining_facts: usize,
138}
139
140pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
141 let now = Utc::now();
142 let mut count = 0;
143
144 for fact in facts.iter_mut() {
145 if !fact.is_current() {
146 continue;
147 }
148
149 if let Some(valid_until) = fact.valid_until
150 && valid_until < now
151 && fact.confidence > 0.1
152 {
153 fact.confidence = 0.1;
154 count += 1;
155 continue;
156 }
157
158 let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
159 if days_since_confirmed <= 0.0 {
160 continue;
161 }
162 let days_since_retrieved = fact
163 .last_retrieved
164 .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
165 let retrieval_count = fact.retrieval_count as f32;
166 let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
167
168 let base_stability = if config.archetype_aware_decay {
171 config.base_stability_days * fact.archetype.stability_multiplier()
172 } else {
173 config.base_stability_days
174 };
175
176 let new_confidence = match config.forgetting_model {
177 ForgettingModel::Ebbinghaus => ebbinghaus_confidence(
178 fact.confidence,
179 days_since_confirmed,
180 days_since_retrieved,
181 retrieval_count,
182 net_feedback,
183 base_stability,
184 ),
185 ForgettingModel::Linear => linear_confidence(
186 fact.confidence,
187 days_since_confirmed,
188 days_since_retrieved,
189 retrieval_count,
190 net_feedback,
191 config.decay_rate_per_day,
192 ),
193 };
194 if (new_confidence - fact.confidence).abs() > 0.001 {
195 fact.confidence = new_confidence;
196 count += 1;
197 }
198 }
199
200 if count > 0 && config.forgetting_model == ForgettingModel::Ebbinghaus {
201 crate::core::introspect::tick("power_law_decay");
202 }
203 count
204}
205
206fn ebbinghaus_confidence(
211 confidence: f32,
212 days_since_confirmed: f32,
213 days_since_retrieved: f32,
214 retrieval_count: f32,
215 net_feedback: i64,
216 base_stability_days: f32,
217) -> f32 {
218 let elapsed = days_since_confirmed.min(days_since_retrieved).max(0.0);
219 let spacing = 1.0 + SPACING_GAIN * retrieval_count;
220 let feedback_mult = match net_feedback.cmp(&0) {
221 std::cmp::Ordering::Greater => 1.0 + (net_feedback as f32).ln_1p(),
222 std::cmp::Ordering::Less => 1.0 / (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()),
223 std::cmp::Ordering::Equal => 1.0,
224 };
225 let stability = (base_stability_days * spacing * feedback_mult).max(MIN_STABILITY_DAYS);
226 let retention = (-(f64::from(elapsed)) / f64::from(stability)).exp() as f32;
227 (confidence * retention).max(CONFIDENCE_FLOOR)
228}
229
230fn linear_confidence(
234 confidence: f32,
235 days_since_confirmed: f32,
236 days_since_retrieved: f32,
237 retrieval_count: f32,
238 net_feedback: i64,
239 decay_rate_per_day: f32,
240) -> f32 {
241 let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p());
242 let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0);
243 let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
244 let feedback_factor = match net_feedback.cmp(&0) {
245 std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
246 std::cmp::Ordering::Less => (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0),
247 std::cmp::Ordering::Equal => 1.0,
248 };
249 let decay = decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
250 (confidence - decay).max(CONFIDENCE_FLOOR)
251}
252
253pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
254 let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
255
256 let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
257 std::collections::HashMap::new();
258 for (i, f) in facts.iter().enumerate() {
259 if f.is_current() {
260 category_groups
261 .entry(f.category.clone())
262 .or_default()
263 .push(i);
264 }
265 }
266
267 for indices in category_groups.values() {
268 for (pos_a, &i) in indices.iter().enumerate() {
269 if to_remove.contains(&i) {
270 continue;
271 }
272 for &j in &indices[pos_a + 1..] {
273 if to_remove.contains(&j) {
274 continue;
275 }
276 let sim = word_similarity(&facts[i].value, &facts[j].value);
277 if sim >= similarity_threshold {
278 if facts[i].confidence >= facts[j].confidence {
279 facts[i].confirmation_count += facts[j].confirmation_count;
280 if facts[j].last_confirmed > facts[i].last_confirmed {
281 facts[i].last_confirmed = facts[j].last_confirmed;
282 }
283 to_remove.insert(j);
284 } else {
285 facts[j].confirmation_count += facts[i].confirmation_count;
286 if facts[i].last_confirmed > facts[j].last_confirmed {
287 facts[j].last_confirmed = facts[i].last_confirmed;
288 }
289 to_remove.insert(i);
290 break;
291 }
292 }
293 }
294 }
295 }
296
297 let count = to_remove.len();
298 let mut sorted: Vec<usize> = to_remove.into_iter().collect();
299 sorted.sort_unstable();
300 for idx in sorted.into_iter().rev() {
301 facts.remove(idx);
302 }
303
304 count
305}
306
307pub fn compact(
308 facts: &mut Vec<KnowledgeFact>,
309 config: &LifecycleConfig,
310) -> (usize, Vec<KnowledgeFact>) {
311 let mut archived: Vec<KnowledgeFact> = Vec::new();
312 let now = Utc::now();
313 let stale_threshold = now - Duration::days(config.stale_days);
314
315 let mut to_archive: Vec<usize> = Vec::new();
316
317 for (i, fact) in facts.iter().enumerate() {
318 let recently_retrieved = fact
319 .last_retrieved
320 .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
321 let frequently_retrieved = fact.retrieval_count >= 5;
322
323 if fact.confidence < config.low_confidence_threshold {
324 to_archive.push(i);
325 continue;
326 }
327
328 if let Some(days) = config.prune_unretrieved_after_days {
333 let cutoff = now - Duration::days(days);
334 if fact.last_confirmed < cutoff
335 && fact.retrieval_count == 0
336 && fact.last_retrieved.is_none()
337 && fact.confirmation_count <= 1
338 {
339 to_archive.push(i);
340 continue;
341 }
342 }
343
344 if fact.last_confirmed < stale_threshold
345 && fact.confirmation_count <= 1
346 && fact.confidence < 0.5
347 && !recently_retrieved
348 && !frequently_retrieved
349 {
350 to_archive.push(i);
351 }
352 }
353
354 to_archive.sort_unstable();
355 to_archive.dedup();
356
357 for idx in to_archive.into_iter().rev() {
358 archived.push(facts.remove(idx));
359 }
360
361 (archived.len(), archived)
365}
366
367pub fn run_lifecycle(
368 facts: &mut Vec<KnowledgeFact>,
369 config: &LifecycleConfig,
370) -> Result<LifecycleReport, String> {
371 let decayed = apply_confidence_decay(facts, config);
372 let consolidated = consolidate_similar(facts, config.consolidation_similarity);
373 let (compacted, archived) = compact(facts, config);
374
375 if !archived.is_empty() {
376 archive_facts(&archived)?;
377 }
378
379 Ok(LifecycleReport {
382 decayed_count: decayed,
383 consolidated_count: consolidated,
384 archived_count: archived.len(),
385 compacted_count: compacted,
386 remaining_facts: facts.len(),
387 })
388}
389
390pub fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
394 crate::core::memory_archive::archive_items(
395 MemoryStore::Facts,
396 None,
397 facts,
398 &ArchiveConfig::from_env(),
399 )
400 .map(|_| ())
401}
402
403fn in_project<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
406 crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
407 std::cell::RefCell::new(Some(PathBuf::from(project_root))),
408 f,
409 )
410}
411
412pub fn restore_archive(
415 project_root: &str,
416 archive_path: &str,
417) -> Result<Vec<KnowledgeFact>, String> {
418 in_project(project_root, || {
419 crate::core::memory_archive::restore_items(std::path::Path::new(archive_path))
420 })
421}
422
423pub fn list_archives(project_root: &str) -> Vec<PathBuf> {
425 in_project(project_root, || {
426 crate::core::memory_archive::list_archives(MemoryStore::Facts, None)
427 })
428}
429
430pub fn reachable_archives(project_root: &str, cfg: &ArchiveConfig) -> Vec<PathBuf> {
434 in_project(project_root, || {
435 crate::core::memory_archive::reachable_archives(MemoryStore::Facts, None, cfg)
436 })
437}
438
439fn word_similarity(a: &str, b: &str) -> f32 {
440 let a_lower = a.to_lowercase();
441 let b_lower = b.to_lowercase();
442 let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
443 let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
444
445 if a_words.is_empty() && b_words.is_empty() {
446 return 1.0;
447 }
448
449 let intersection = a_words.intersection(&b_words).count();
450 let union = a_words.union(&b_words).count();
451
452 if union == 0 {
453 return 0.0;
454 }
455
456 intersection as f32 / union as f32
457}
458
459#[cfg(test)]
460pub mod tests {
461 use super::*;
462 use crate::core::knowledge::KnowledgeArchetype;
463
464 fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
467 let _lock = crate::core::data_dir::test_env_lock();
468 let dir = std::env::temp_dir().join(format!(
469 "lctx-lifecycle-{}-{}",
470 std::process::id(),
471 Utc::now().timestamp_nanos_opt().unwrap_or(0)
472 ));
473 let _ = std::fs::create_dir_all(&dir);
474 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
475 let out = f();
476 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
477 let _ = std::fs::remove_dir_all(&dir);
478 out
479 }
480
481 fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
482 KnowledgeFact {
483 anchors: Vec::new(),
484 review: None,
485 capture: Default::default(),
486 origin: crate::core::knowledge::FactOrigin::Local,
487 category: category.to_string(),
488 key: key.to_string(),
489 value: value.to_string(),
490 source_session: "s1".to_string(),
491 confidence,
492 created_at: Utc::now(),
493 last_confirmed: Utc::now(),
494 retrieval_count: 0,
495 last_retrieved: None,
496 valid_from: Some(Utc::now()),
497 valid_until: None,
498 supersedes: None,
499 confirmation_count: 1,
500 feedback_up: 0,
501 feedback_down: 0,
502 last_feedback: None,
503 privacy: crate::core::memory_boundary::FactPrivacy::default(),
504 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
505 imported_from: None,
506 archetype: KnowledgeArchetype::default(),
507 fidelity: None,
508 revision_count: 0,
509 status: None,
510 validity: None,
511 }
512 }
513
514 fn make_old_fact(
515 category: &str,
516 key: &str,
517 value: &str,
518 confidence: f32,
519 days_old: i64,
520 ) -> KnowledgeFact {
521 let past = Utc::now() - Duration::days(days_old);
522 KnowledgeFact {
523 anchors: Vec::new(),
524 review: None,
525 capture: Default::default(),
526 origin: crate::core::knowledge::FactOrigin::Local,
527 category: category.to_string(),
528 key: key.to_string(),
529 value: value.to_string(),
530 source_session: "s1".to_string(),
531 confidence,
532 created_at: past,
533 last_confirmed: past,
534 retrieval_count: 0,
535 last_retrieved: None,
536 valid_from: Some(past),
537 valid_until: None,
538 supersedes: None,
539 confirmation_count: 1,
540 feedback_up: 0,
541 feedback_down: 0,
542 last_feedback: None,
543 privacy: crate::core::memory_boundary::FactPrivacy::default(),
544 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
545 imported_from: None,
546 archetype: KnowledgeArchetype::default(),
547 fidelity: None,
548 revision_count: 0,
549 status: None,
550 validity: None,
551 }
552 }
553
554 #[test]
555 fn decay_reduces_confidence() {
556 let config = LifecycleConfig::default();
557 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
558
559 let count = apply_confidence_decay(&mut facts, &config);
560 assert_eq!(count, 1);
561 assert!(facts[0].confidence < 0.9);
562 assert!(facts[0].confidence > 0.7);
563 }
564
565 #[test]
566 fn archetype_aware_decay_protects_evidence() {
567 let mut evidence = make_old_fact("arch", "db", "PostgreSQL", 0.9, 30);
570 evidence.archetype = KnowledgeArchetype::Architecture;
571 let mut inference = make_old_fact("pref", "style", "tabs", 0.9, 30);
572 inference.archetype = KnowledgeArchetype::Preference;
573
574 let off = LifecycleConfig::default();
575 let mut a = vec![evidence.clone(), inference.clone()];
576 apply_confidence_decay(&mut a, &off);
577 assert!(
578 (a[0].confidence - a[1].confidence).abs() < 1e-6,
579 "flag off → archetype ignored, equal decay"
580 );
581
582 let on = LifecycleConfig {
583 archetype_aware_decay: true,
584 ..Default::default()
585 };
586 let mut b = vec![evidence, inference];
587 apply_confidence_decay(&mut b, &on);
588 assert!(
589 b[0].confidence > b[1].confidence,
590 "evidence {} should outlast inference {}",
591 b[0].confidence,
592 b[1].confidence
593 );
594 }
595
596 #[test]
597 fn decay_skips_recent_facts() {
598 let config = LifecycleConfig::default();
599 let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
600
601 let count = apply_confidence_decay(&mut facts, &config);
602 assert_eq!(count, 0);
603 }
604
605 #[test]
606 fn feedback_steers_decay_keep_vs_forget() {
607 let config = LifecycleConfig::default();
608 let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
609 praised.feedback_up = 5;
610 let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
611 panned.feedback_down = 5;
612 let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
613
614 let mut facts = vec![praised, panned, neutral];
615 apply_confidence_decay(&mut facts, &config);
616
617 let (praised_c, panned_c, neutral_c) = (
618 facts[0].confidence,
619 facts[1].confidence,
620 facts[2].confidence,
621 );
622
623 assert!(
625 praised_c > neutral_c,
626 "praised {praised_c} should outlast neutral {neutral_c}"
627 );
628 assert!(
629 neutral_c > panned_c,
630 "neutral {neutral_c} should outlast panned {panned_c}"
631 );
632 assert!(panned_c >= 0.05);
634 }
635
636 #[test]
637 fn spacing_effect_protects_frequently_retrieved() {
638 let config = LifecycleConfig::default();
641 let rarely = make_old_fact("arch", "rare", "x", 0.9, 20);
642 let mut often = make_old_fact("arch", "often", "y", 0.9, 20);
643 often.retrieval_count = 20;
644 let mut facts = vec![rarely, often];
645 apply_confidence_decay(&mut facts, &config);
646 assert!(
647 facts[1].confidence > facts[0].confidence,
648 "spacing effect: rehearsed {} should outlast un-rehearsed {}",
649 facts[1].confidence,
650 facts[0].confidence
651 );
652 }
653
654 #[test]
655 fn ebbinghaus_decay_is_deterministic() {
656 let config = LifecycleConfig::default();
658 let mut a = vec![make_old_fact("arch", "k", "v", 0.8, 15)];
659 let mut b = a.clone();
660 apply_confidence_decay(&mut a, &config);
661 apply_confidence_decay(&mut b, &config);
662 assert_eq!(a[0].confidence, b[0].confidence);
663 }
664
665 #[test]
666 fn linear_model_still_available() {
667 let config = LifecycleConfig {
669 forgetting_model: ForgettingModel::Linear,
670 ..Default::default()
671 };
672 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
673 let count = apply_confidence_decay(&mut facts, &config);
674 assert_eq!(count, 1);
675 assert!(facts[0].confidence < 0.9 && facts[0].confidence > 0.7);
676 }
677
678 #[test]
679 fn forgetting_model_parses() {
680 assert_eq!(ForgettingModel::parse("linear"), ForgettingModel::Linear);
681 assert_eq!(
682 ForgettingModel::parse("ebbinghaus"),
683 ForgettingModel::Ebbinghaus
684 );
685 assert_eq!(
686 ForgettingModel::parse("garbage"),
687 ForgettingModel::Ebbinghaus
688 );
689 }
690
691 #[test]
692 fn consolidate_similar_facts() {
693 let mut facts = vec![
694 make_fact("arch", "db", "uses PostgreSQL database", 0.8),
695 make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
696 make_fact("ops", "deploy", "docker compose up", 0.9),
697 ];
698
699 let count = consolidate_similar(&mut facts, 0.7);
700 assert!(count > 0, "Should consolidate similar facts");
701 assert!(facts.len() < 3);
702 }
703
704 #[test]
705 fn consolidate_keeps_different_categories() {
706 let mut facts = vec![
707 make_fact("arch", "db", "PostgreSQL", 0.8),
708 make_fact("ops", "db", "PostgreSQL", 0.8),
709 ];
710
711 let count = consolidate_similar(&mut facts, 0.9);
712 assert_eq!(count, 0, "Different categories should not consolidate");
713 }
714
715 #[test]
716 fn compact_removes_low_confidence() {
717 let config = LifecycleConfig::default();
718 let mut facts = vec![
719 make_fact("arch", "db", "PostgreSQL", 0.9),
720 make_fact("arch", "cache", "Redis", 0.1),
721 ];
722
723 let (count, archived) = compact(&mut facts, &config);
724 assert_eq!(count, 1);
725 assert_eq!(facts.len(), 1);
726 assert_eq!(archived.len(), 1);
727 assert_eq!(archived[0].key, "cache");
728 }
729
730 #[test]
731 fn compact_is_quality_only_and_ignores_capacity() {
732 let config = LifecycleConfig::default();
735 let mut facts: Vec<KnowledgeFact> = (0..8)
736 .map(|i| make_fact("finding", &format!("k{i}"), &format!("value {i}"), 0.8))
737 .collect();
738
739 let (count, archived) = compact(&mut facts, &config);
740
741 assert_eq!(count, 0, "quality compact must not evict for capacity");
742 assert!(archived.is_empty());
743 assert_eq!(facts.len(), 8);
744 }
745
746 #[test]
747 fn run_lifecycle_never_reclaims_capacity() {
748 with_temp_data_dir(|| {
749 let config = LifecycleConfig::default();
753 let mut facts: Vec<KnowledgeFact> = (0..450)
754 .map(|i| {
755 make_fact(
756 "finding",
757 &format!("k{i}"),
758 &format!("distinct finding number {i} about module m{i}"),
759 0.8,
760 )
761 })
762 .collect();
763 facts[0].valid_until = Some(Utc::now() - Duration::seconds(1));
764
765 let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
766
767 assert_eq!(report.archived_count, 0);
768 assert_eq!(facts.len(), 450);
769 assert_eq!(report.remaining_facts, 450);
770 });
771 }
772
773 #[test]
774 fn prune_unretrieved_archives_old_never_retrieved_facts() {
775 let config = LifecycleConfig {
779 prune_unretrieved_after_days: Some(30),
780 ..Default::default()
781 };
782 let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
783 let (count, archived) = compact(&mut facts, &config);
784 assert_eq!(count, 1);
785 assert_eq!(archived.len(), 1);
786 assert!(facts.is_empty());
787 }
788
789 #[test]
790 fn prune_unretrieved_is_off_by_default() {
791 let config = LifecycleConfig::default();
794 let mut facts = vec![make_old_fact("arch", "x", "still confident", 0.9, 60)];
795 let (count, _) = compact(&mut facts, &config);
796 assert_eq!(count, 0);
797 assert_eq!(facts.len(), 1);
798 }
799
800 #[test]
801 fn prune_unretrieved_keeps_retrieved_and_confirmed_facts() {
802 let config = LifecycleConfig {
803 prune_unretrieved_after_days: Some(30),
804 ..Default::default()
805 };
806 let mut retrieved = make_old_fact("arch", "used", "v", 0.9, 60);
807 retrieved.retrieval_count = 3;
808 let mut confirmed = make_old_fact("arch", "confirmed", "v", 0.9, 60);
809 confirmed.confirmation_count = 4;
810 let mut facts = vec![retrieved, confirmed];
811 let (count, _) = compact(&mut facts, &config);
812 assert_eq!(count, 0, "retrieved or repeatedly-confirmed facts are kept");
813 assert_eq!(facts.len(), 2);
814 }
815
816 #[test]
817 fn compact_archives_stale_facts() {
818 let config = LifecycleConfig::default();
819 let mut facts = vec![
820 make_fact("arch", "db", "PostgreSQL", 0.9),
821 make_old_fact("arch", "old", "ancient thing", 0.4, 60),
822 ];
823
824 let (count, archived) = compact(&mut facts, &config);
825 assert_eq!(count, 1);
826 assert_eq!(archived[0].key, "old");
827 }
828
829 #[test]
830 fn full_lifecycle_run() {
831 with_temp_data_dir(|| {
832 let config = LifecycleConfig::default();
833
834 let mut facts = vec![
835 make_fact("arch", "db", "PostgreSQL", 0.9),
836 make_fact("arch", "cache", "Redis", 0.8),
837 make_old_fact("arch", "old1", "thing1", 0.2, 50),
838 make_old_fact("arch", "old2", "thing2", 0.15, 60),
839 make_fact("ops", "deploy", "docker compose", 0.7),
840 ];
841
842 let report = run_lifecycle(&mut facts, &config).expect("lifecycle succeeds");
843 assert!(report.remaining_facts < 5, "the stale facts are archived");
844 assert!(report.decayed_count > 0 || report.compacted_count > 0);
845 });
846 }
847
848 #[test]
849 fn word_similarity_identical() {
850 assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
851 }
852
853 #[test]
854 fn word_similarity_partial() {
855 let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
856 assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
857 assert!(sim < 1.0);
858 }
859
860 #[test]
861 fn word_similarity_different() {
862 let sim = word_similarity("Redis cache", "Docker compose");
863 assert!(sim < 0.1);
864 }
865}