1use 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;
19const MAX_ARCHIVE_FILES: usize = 16;
23
24const SPACING_GAIN: f32 = 0.5;
27const MIN_STABILITY_DAYS: f32 = 1.0;
30const CONFIDENCE_FLOOR: f32 = 0.05;
33pub const DEFAULT_BASE_STABILITY_DAYS: f32 = 90.0;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum ForgettingModel {
39 #[default]
43 Ebbinghaus,
44 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 pub forgetting_model: ForgettingModel,
73 pub base_stability_days: f32,
76 pub archetype_aware_decay: bool,
80 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 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
178fn 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
202fn 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 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#[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
357const COMPACTION_VALUE_MAX: usize = 400;
360
361pub 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 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 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 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 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
463fn 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
524fn 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 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 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 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 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 assert!(panned_c >= 0.05);
783 }
784
785 #[test]
786 fn spacing_effect_protects_frequently_retrieved() {
787 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 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 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 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 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 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 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 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); 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); let t = truncate_chars(&s, 400);
1092 assert!(t.chars().count() <= 400);
1093 assert!(t.ends_with('…'));
1094 }
1096}