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