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}
81
82impl Default for LifecycleConfig {
83 fn default() -> Self {
84 Self {
85 decay_rate_per_day: DEFAULT_DECAY_RATE,
86 max_facts: DEFAULT_MAX_FACTS,
87 low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
88 stale_days: STALE_DAYS,
89 consolidation_similarity: 0.85,
90 forgetting_model: ForgettingModel::default(),
91 base_stability_days: DEFAULT_BASE_STABILITY_DAYS,
92 archetype_aware_decay: false,
93 }
94 }
95}
96
97#[derive(Debug, Default)]
98pub struct LifecycleReport {
99 pub decayed_count: usize,
100 pub consolidated_count: usize,
101 pub archived_count: usize,
102 pub compacted_count: usize,
103 pub remaining_facts: usize,
104}
105
106pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
107 let now = Utc::now();
108 let mut count = 0;
109
110 for fact in facts.iter_mut() {
111 if !fact.is_current() {
112 continue;
113 }
114
115 if let Some(valid_until) = fact.valid_until
116 && valid_until < now
117 && fact.confidence > 0.1
118 {
119 fact.confidence = 0.1;
120 count += 1;
121 continue;
122 }
123
124 let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
125 if days_since_confirmed <= 0.0 {
126 continue;
127 }
128 let days_since_retrieved = fact
129 .last_retrieved
130 .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
131 let retrieval_count = fact.retrieval_count as f32;
132 let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
133
134 let base_stability = if config.archetype_aware_decay {
137 config.base_stability_days * fact.archetype.stability_multiplier()
138 } else {
139 config.base_stability_days
140 };
141
142 let new_confidence = match config.forgetting_model {
143 ForgettingModel::Ebbinghaus => ebbinghaus_confidence(
144 fact.confidence,
145 days_since_confirmed,
146 days_since_retrieved,
147 retrieval_count,
148 net_feedback,
149 base_stability,
150 ),
151 ForgettingModel::Linear => linear_confidence(
152 fact.confidence,
153 days_since_confirmed,
154 days_since_retrieved,
155 retrieval_count,
156 net_feedback,
157 config.decay_rate_per_day,
158 ),
159 };
160 if (new_confidence - fact.confidence).abs() > 0.001 {
161 fact.confidence = new_confidence;
162 count += 1;
163 }
164 }
165
166 if count > 0 && config.forgetting_model == ForgettingModel::Ebbinghaus {
167 crate::core::introspect::tick("power_law_decay");
168 }
169 count
170}
171
172fn ebbinghaus_confidence(
177 confidence: f32,
178 days_since_confirmed: f32,
179 days_since_retrieved: f32,
180 retrieval_count: f32,
181 net_feedback: i64,
182 base_stability_days: f32,
183) -> f32 {
184 let elapsed = days_since_confirmed.min(days_since_retrieved).max(0.0);
185 let spacing = 1.0 + SPACING_GAIN * retrieval_count;
186 let feedback_mult = match net_feedback.cmp(&0) {
187 std::cmp::Ordering::Greater => 1.0 + (net_feedback as f32).ln_1p(),
188 std::cmp::Ordering::Less => 1.0 / (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()),
189 std::cmp::Ordering::Equal => 1.0,
190 };
191 let stability = (base_stability_days * spacing * feedback_mult).max(MIN_STABILITY_DAYS);
192 let retention = (-(f64::from(elapsed)) / f64::from(stability)).exp() as f32;
193 (confidence * retention).max(CONFIDENCE_FLOOR)
194}
195
196fn linear_confidence(
200 confidence: f32,
201 days_since_confirmed: f32,
202 days_since_retrieved: f32,
203 retrieval_count: f32,
204 net_feedback: i64,
205 decay_rate_per_day: f32,
206) -> f32 {
207 let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p());
208 let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0);
209 let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
210 let feedback_factor = match net_feedback.cmp(&0) {
211 std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
212 std::cmp::Ordering::Less => (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0),
213 std::cmp::Ordering::Equal => 1.0,
214 };
215 let decay = decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
216 (confidence - decay).max(CONFIDENCE_FLOOR)
217}
218
219pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
220 let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
221
222 let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
223 std::collections::HashMap::new();
224 for (i, f) in facts.iter().enumerate() {
225 if f.is_current() {
226 category_groups
227 .entry(f.category.clone())
228 .or_default()
229 .push(i);
230 }
231 }
232
233 for indices in category_groups.values() {
234 for (pos_a, &i) in indices.iter().enumerate() {
235 if to_remove.contains(&i) {
236 continue;
237 }
238 for &j in &indices[pos_a + 1..] {
239 if to_remove.contains(&j) {
240 continue;
241 }
242 let sim = word_similarity(&facts[i].value, &facts[j].value);
243 if sim >= similarity_threshold {
244 if facts[i].confidence >= facts[j].confidence {
245 facts[i].confirmation_count += facts[j].confirmation_count;
246 if facts[j].last_confirmed > facts[i].last_confirmed {
247 facts[i].last_confirmed = facts[j].last_confirmed;
248 }
249 to_remove.insert(j);
250 } else {
251 facts[j].confirmation_count += facts[i].confirmation_count;
252 if facts[i].last_confirmed > facts[j].last_confirmed {
253 facts[j].last_confirmed = facts[i].last_confirmed;
254 }
255 to_remove.insert(i);
256 break;
257 }
258 }
259 }
260 }
261 }
262
263 let count = to_remove.len();
264 let mut sorted: Vec<usize> = to_remove.into_iter().collect();
265 sorted.sort_unstable();
266 for idx in sorted.into_iter().rev() {
267 facts.remove(idx);
268 }
269
270 count
271}
272
273pub fn compact(
274 facts: &mut Vec<KnowledgeFact>,
275 config: &LifecycleConfig,
276) -> (usize, Vec<KnowledgeFact>) {
277 let mut archived: Vec<KnowledgeFact> = Vec::new();
278 let now = Utc::now();
279 let stale_threshold = now - Duration::days(config.stale_days);
280
281 let mut to_archive: Vec<usize> = Vec::new();
282
283 for (i, fact) in facts.iter().enumerate() {
284 let recently_retrieved = fact
285 .last_retrieved
286 .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
287 let frequently_retrieved = fact.retrieval_count >= 5;
288
289 if fact.confidence < config.low_confidence_threshold {
290 to_archive.push(i);
291 continue;
292 }
293
294 if fact.last_confirmed < stale_threshold
295 && fact.confirmation_count <= 1
296 && fact.confidence < 0.5
297 && !recently_retrieved
298 && !frequently_retrieved
299 {
300 to_archive.push(i);
301 }
302 }
303
304 to_archive.sort_unstable();
305 to_archive.dedup();
306 let count = to_archive.len();
307
308 for idx in to_archive.into_iter().rev() {
309 archived.push(facts.remove(idx));
310 }
311
312 if facts.len() > config.max_facts {
313 facts.sort_by(|a, b| {
314 b.confidence
315 .partial_cmp(&a.confidence)
316 .unwrap_or(std::cmp::Ordering::Equal)
317 });
318 let excess: Vec<KnowledgeFact> = facts.drain(config.max_facts..).collect();
319 archived.extend(excess);
320 }
321
322 (count, archived)
323}
324
325pub fn run_lifecycle(facts: &mut Vec<KnowledgeFact>, config: &LifecycleConfig) -> LifecycleReport {
326 let decayed = apply_confidence_decay(facts, config);
327 let consolidated = consolidate_similar(facts, config.consolidation_similarity);
328 let (compacted, archived) = compact(facts, config);
329
330 if !archived.is_empty() {
331 let _ = archive_facts(&archived);
332 }
333
334 LifecycleReport {
335 decayed_count: decayed,
336 consolidated_count: consolidated,
337 archived_count: archived.len(),
338 compacted_count: compacted,
339 remaining_facts: facts.len(),
340 }
341}
342
343#[derive(Debug, Serialize, Deserialize)]
344struct ArchivedFacts {
345 pub archived_at: DateTime<Utc>,
346 pub facts: Vec<KnowledgeFact>,
347}
348
349fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
350 let dir = crate::core::data_dir::lean_ctx_data_dir()?
351 .join("memory")
352 .join("archive");
353 std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
354
355 let now = Utc::now();
358 let suffix = now.timestamp_subsec_nanos() % 1_000_000;
359 let filename = format!("archive-{}-{suffix:06}.json", now.format("%Y%m%d-%H%M%S"));
360 let archive = ArchivedFacts {
361 archived_at: now,
362 facts: facts.to_vec(),
363 };
364 let json = serde_json::to_string_pretty(&archive).map_err(|e| format!("{e}"))?;
365 std::fs::write(dir.join(filename), json).map_err(|e| format!("{e}"))?;
366
367 let archives = list_archives();
371 if archives.len() > MAX_ARCHIVE_FILES {
372 for old in &archives[..archives.len() - MAX_ARCHIVE_FILES] {
373 let _ = std::fs::remove_file(old);
374 }
375 }
376 Ok(())
377}
378
379pub fn restore_archive(archive_path: &str) -> Result<Vec<KnowledgeFact>, String> {
380 let data = std::fs::read_to_string(archive_path).map_err(|e| format!("{e}"))?;
381 let archive: ArchivedFacts = serde_json::from_str(&data).map_err(|e| format!("{e}"))?;
382 Ok(archive.facts)
383}
384
385pub fn list_archives() -> Vec<PathBuf> {
386 let dir = match crate::core::data_dir::lean_ctx_data_dir() {
387 Ok(d) => d.join("memory").join("archive"),
388 Err(_) => return Vec::new(),
389 };
390
391 if !dir.exists() {
392 return Vec::new();
393 }
394
395 let mut archives: Vec<PathBuf> = std::fs::read_dir(&dir)
396 .into_iter()
397 .flatten()
398 .flatten()
399 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
400 .map(|e| e.path())
401 .collect();
402
403 archives.sort();
404 archives
405}
406
407fn word_similarity(a: &str, b: &str) -> f32 {
408 let a_lower = a.to_lowercase();
409 let b_lower = b.to_lowercase();
410 let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
411 let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
412
413 if a_words.is_empty() && b_words.is_empty() {
414 return 1.0;
415 }
416
417 let intersection = a_words.intersection(&b_words).count();
418 let union = a_words.union(&b_words).count();
419
420 if union == 0 {
421 return 0.0;
422 }
423
424 intersection as f32 / union as f32
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::core::knowledge::KnowledgeArchetype;
431
432 fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
433 KnowledgeFact {
434 category: category.to_string(),
435 key: key.to_string(),
436 value: value.to_string(),
437 source_session: "s1".to_string(),
438 confidence,
439 created_at: Utc::now(),
440 last_confirmed: Utc::now(),
441 retrieval_count: 0,
442 last_retrieved: None,
443 valid_from: Some(Utc::now()),
444 valid_until: None,
445 supersedes: None,
446 confirmation_count: 1,
447 feedback_up: 0,
448 feedback_down: 0,
449 last_feedback: None,
450 privacy: crate::core::memory_boundary::FactPrivacy::default(),
451 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
452 imported_from: None,
453 archetype: KnowledgeArchetype::default(),
454 fidelity: None,
455 revision_count: 0,
456 }
457 }
458
459 fn make_old_fact(
460 category: &str,
461 key: &str,
462 value: &str,
463 confidence: f32,
464 days_old: i64,
465 ) -> KnowledgeFact {
466 let past = Utc::now() - Duration::days(days_old);
467 KnowledgeFact {
468 category: category.to_string(),
469 key: key.to_string(),
470 value: value.to_string(),
471 source_session: "s1".to_string(),
472 confidence,
473 created_at: past,
474 last_confirmed: past,
475 retrieval_count: 0,
476 last_retrieved: None,
477 valid_from: Some(past),
478 valid_until: None,
479 supersedes: None,
480 confirmation_count: 1,
481 feedback_up: 0,
482 feedback_down: 0,
483 last_feedback: None,
484 privacy: crate::core::memory_boundary::FactPrivacy::default(),
485 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
486 imported_from: None,
487 archetype: KnowledgeArchetype::default(),
488 fidelity: None,
489 revision_count: 0,
490 }
491 }
492
493 #[test]
494 fn decay_reduces_confidence() {
495 let config = LifecycleConfig::default();
496 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
497
498 let count = apply_confidence_decay(&mut facts, &config);
499 assert_eq!(count, 1);
500 assert!(facts[0].confidence < 0.9);
501 assert!(facts[0].confidence > 0.7);
502 }
503
504 #[test]
505 fn archetype_aware_decay_protects_evidence() {
506 let mut evidence = make_old_fact("arch", "db", "PostgreSQL", 0.9, 30);
509 evidence.archetype = KnowledgeArchetype::Architecture;
510 let mut inference = make_old_fact("pref", "style", "tabs", 0.9, 30);
511 inference.archetype = KnowledgeArchetype::Preference;
512
513 let off = LifecycleConfig::default();
514 let mut a = vec![evidence.clone(), inference.clone()];
515 apply_confidence_decay(&mut a, &off);
516 assert!(
517 (a[0].confidence - a[1].confidence).abs() < 1e-6,
518 "flag off → archetype ignored, equal decay"
519 );
520
521 let on = LifecycleConfig {
522 archetype_aware_decay: true,
523 ..Default::default()
524 };
525 let mut b = vec![evidence, inference];
526 apply_confidence_decay(&mut b, &on);
527 assert!(
528 b[0].confidence > b[1].confidence,
529 "evidence {} should outlast inference {}",
530 b[0].confidence,
531 b[1].confidence
532 );
533 }
534
535 #[test]
536 fn decay_skips_recent_facts() {
537 let config = LifecycleConfig::default();
538 let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
539
540 let count = apply_confidence_decay(&mut facts, &config);
541 assert_eq!(count, 0);
542 }
543
544 #[test]
545 fn feedback_steers_decay_keep_vs_forget() {
546 let config = LifecycleConfig::default();
547 let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
548 praised.feedback_up = 5;
549 let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
550 panned.feedback_down = 5;
551 let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
552
553 let mut facts = vec![praised, panned, neutral];
554 apply_confidence_decay(&mut facts, &config);
555
556 let (praised_c, panned_c, neutral_c) = (
557 facts[0].confidence,
558 facts[1].confidence,
559 facts[2].confidence,
560 );
561
562 assert!(
564 praised_c > neutral_c,
565 "praised {praised_c} should outlast neutral {neutral_c}"
566 );
567 assert!(
568 neutral_c > panned_c,
569 "neutral {neutral_c} should outlast panned {panned_c}"
570 );
571 assert!(panned_c >= 0.05);
573 }
574
575 #[test]
576 fn spacing_effect_protects_frequently_retrieved() {
577 let config = LifecycleConfig::default();
580 let rarely = make_old_fact("arch", "rare", "x", 0.9, 20);
581 let mut often = make_old_fact("arch", "often", "y", 0.9, 20);
582 often.retrieval_count = 20;
583 let mut facts = vec![rarely, often];
584 apply_confidence_decay(&mut facts, &config);
585 assert!(
586 facts[1].confidence > facts[0].confidence,
587 "spacing effect: rehearsed {} should outlast un-rehearsed {}",
588 facts[1].confidence,
589 facts[0].confidence
590 );
591 }
592
593 #[test]
594 fn ebbinghaus_decay_is_deterministic() {
595 let config = LifecycleConfig::default();
597 let mut a = vec![make_old_fact("arch", "k", "v", 0.8, 15)];
598 let mut b = a.clone();
599 apply_confidence_decay(&mut a, &config);
600 apply_confidence_decay(&mut b, &config);
601 assert_eq!(a[0].confidence, b[0].confidence);
602 }
603
604 #[test]
605 fn linear_model_still_available() {
606 let config = LifecycleConfig {
608 forgetting_model: ForgettingModel::Linear,
609 ..Default::default()
610 };
611 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
612 let count = apply_confidence_decay(&mut facts, &config);
613 assert_eq!(count, 1);
614 assert!(facts[0].confidence < 0.9 && facts[0].confidence > 0.7);
615 }
616
617 #[test]
618 fn forgetting_model_parses() {
619 assert_eq!(ForgettingModel::parse("linear"), ForgettingModel::Linear);
620 assert_eq!(
621 ForgettingModel::parse("ebbinghaus"),
622 ForgettingModel::Ebbinghaus
623 );
624 assert_eq!(
625 ForgettingModel::parse("garbage"),
626 ForgettingModel::Ebbinghaus
627 );
628 }
629
630 #[test]
631 fn consolidate_similar_facts() {
632 let mut facts = vec![
633 make_fact("arch", "db", "uses PostgreSQL database", 0.8),
634 make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
635 make_fact("ops", "deploy", "docker compose up", 0.9),
636 ];
637
638 let count = consolidate_similar(&mut facts, 0.7);
639 assert!(count > 0, "Should consolidate similar facts");
640 assert!(facts.len() < 3);
641 }
642
643 #[test]
644 fn consolidate_keeps_different_categories() {
645 let mut facts = vec![
646 make_fact("arch", "db", "PostgreSQL", 0.8),
647 make_fact("ops", "db", "PostgreSQL", 0.8),
648 ];
649
650 let count = consolidate_similar(&mut facts, 0.9);
651 assert_eq!(count, 0, "Different categories should not consolidate");
652 }
653
654 #[test]
655 fn compact_removes_low_confidence() {
656 let config = LifecycleConfig::default();
657 let mut facts = vec![
658 make_fact("arch", "db", "PostgreSQL", 0.9),
659 make_fact("arch", "cache", "Redis", 0.1),
660 ];
661
662 let (count, archived) = compact(&mut facts, &config);
663 assert_eq!(count, 1);
664 assert_eq!(facts.len(), 1);
665 assert_eq!(archived.len(), 1);
666 assert_eq!(archived[0].key, "cache");
667 }
668
669 #[test]
670 fn compact_archives_stale_facts() {
671 let config = LifecycleConfig::default();
672 let mut facts = vec![
673 make_fact("arch", "db", "PostgreSQL", 0.9),
674 make_old_fact("arch", "old", "ancient thing", 0.4, 60),
675 ];
676
677 let (count, archived) = compact(&mut facts, &config);
678 assert_eq!(count, 1);
679 assert_eq!(archived[0].key, "old");
680 }
681
682 #[test]
683 fn full_lifecycle_run() {
684 let config = LifecycleConfig {
685 max_facts: 5,
686 ..Default::default()
687 };
688
689 let mut facts = vec![
690 make_fact("arch", "db", "PostgreSQL", 0.9),
691 make_fact("arch", "cache", "Redis", 0.8),
692 make_old_fact("arch", "old1", "thing1", 0.2, 50),
693 make_old_fact("arch", "old2", "thing2", 0.15, 60),
694 make_fact("ops", "deploy", "docker compose", 0.7),
695 ];
696
697 let report = run_lifecycle(&mut facts, &config);
698 assert!(report.remaining_facts <= config.max_facts);
699 assert!(report.decayed_count > 0 || report.compacted_count > 0);
700 }
701
702 #[test]
703 fn word_similarity_identical() {
704 assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
705 }
706
707 #[test]
708 fn word_similarity_partial() {
709 let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
710 assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
711 assert!(sim < 1.0);
712 }
713
714 #[test]
715 fn word_similarity_different() {
716 let sim = word_similarity("Redis cache", "Docker compose");
717 assert!(sim < 0.1);
718 }
719}