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
24#[derive(Debug, Clone)]
25pub struct LifecycleConfig {
26 pub decay_rate_per_day: f32,
27 pub max_facts: usize,
28 pub low_confidence_threshold: f32,
29 pub stale_days: i64,
30 pub consolidation_similarity: f32,
31}
32
33impl Default for LifecycleConfig {
34 fn default() -> Self {
35 Self {
36 decay_rate_per_day: DEFAULT_DECAY_RATE,
37 max_facts: DEFAULT_MAX_FACTS,
38 low_confidence_threshold: LOW_CONFIDENCE_THRESHOLD,
39 stale_days: STALE_DAYS,
40 consolidation_similarity: 0.85,
41 }
42 }
43}
44
45#[derive(Debug, Default)]
46pub struct LifecycleReport {
47 pub decayed_count: usize,
48 pub consolidated_count: usize,
49 pub archived_count: usize,
50 pub compacted_count: usize,
51 pub remaining_facts: usize,
52}
53
54pub fn apply_confidence_decay(facts: &mut [KnowledgeFact], config: &LifecycleConfig) -> usize {
55 let now = Utc::now();
56 let mut count = 0;
57
58 for fact in facts.iter_mut() {
59 if !fact.is_current() {
60 continue;
61 }
62
63 if let Some(valid_until) = fact.valid_until
64 && valid_until < now
65 && fact.confidence > 0.1
66 {
67 fact.confidence = 0.1;
68 count += 1;
69 continue;
70 }
71
72 let days_since_confirmed = now.signed_duration_since(fact.last_confirmed).num_days() as f32;
73 let days_since_retrieved = fact
74 .last_retrieved
75 .map_or(3650.0, |t| now.signed_duration_since(t).num_days() as f32);
76 let retrieval_count = fact.retrieval_count as f32;
77
78 if days_since_confirmed > 0.0 {
79 let freq_protect = 1.0 / (1.0 + retrieval_count.ln_1p()); let recency_protect = (1.0 - (days_since_retrieved / 30.0).min(1.0)).max(0.0); let protect = (freq_protect * (1.0 - 0.5 * recency_protect)).max(0.05);
84 let net_feedback = i64::from(fact.feedback_up) - i64::from(fact.feedback_down);
90 let feedback_factor = match net_feedback.cmp(&0) {
91 std::cmp::Ordering::Greater => 1.0 / (1.0 + (net_feedback as f32).ln_1p()),
92 std::cmp::Ordering::Less => {
93 (1.0 + (net_feedback.unsigned_abs() as f32).ln_1p()).min(4.0)
94 }
95 std::cmp::Ordering::Equal => 1.0,
96 };
97 let decay =
98 config.decay_rate_per_day * days_since_confirmed * protect * feedback_factor;
99 let new_confidence = (fact.confidence - decay).max(0.05);
100 if (new_confidence - fact.confidence).abs() > 0.001 {
101 fact.confidence = new_confidence;
102 count += 1;
103 }
104 }
105 }
106
107 count
108}
109
110pub fn consolidate_similar(facts: &mut Vec<KnowledgeFact>, similarity_threshold: f32) -> usize {
111 let mut to_remove: std::collections::HashSet<usize> = std::collections::HashSet::new();
112
113 let mut category_groups: std::collections::HashMap<String, Vec<usize>> =
114 std::collections::HashMap::new();
115 for (i, f) in facts.iter().enumerate() {
116 if f.is_current() {
117 category_groups
118 .entry(f.category.clone())
119 .or_default()
120 .push(i);
121 }
122 }
123
124 for indices in category_groups.values() {
125 for (pos_a, &i) in indices.iter().enumerate() {
126 if to_remove.contains(&i) {
127 continue;
128 }
129 for &j in &indices[pos_a + 1..] {
130 if to_remove.contains(&j) {
131 continue;
132 }
133 let sim = word_similarity(&facts[i].value, &facts[j].value);
134 if sim >= similarity_threshold {
135 if facts[i].confidence >= facts[j].confidence {
136 facts[i].confirmation_count += facts[j].confirmation_count;
137 if facts[j].last_confirmed > facts[i].last_confirmed {
138 facts[i].last_confirmed = facts[j].last_confirmed;
139 }
140 to_remove.insert(j);
141 } else {
142 facts[j].confirmation_count += facts[i].confirmation_count;
143 if facts[i].last_confirmed > facts[j].last_confirmed {
144 facts[j].last_confirmed = facts[i].last_confirmed;
145 }
146 to_remove.insert(i);
147 break;
148 }
149 }
150 }
151 }
152 }
153
154 let count = to_remove.len();
155 let mut sorted: Vec<usize> = to_remove.into_iter().collect();
156 sorted.sort_unstable();
157 for idx in sorted.into_iter().rev() {
158 facts.remove(idx);
159 }
160
161 count
162}
163
164pub fn compact(
165 facts: &mut Vec<KnowledgeFact>,
166 config: &LifecycleConfig,
167) -> (usize, Vec<KnowledgeFact>) {
168 let mut archived: Vec<KnowledgeFact> = Vec::new();
169 let now = Utc::now();
170 let stale_threshold = now - Duration::days(config.stale_days);
171
172 let mut to_archive: Vec<usize> = Vec::new();
173
174 for (i, fact) in facts.iter().enumerate() {
175 let recently_retrieved = fact
176 .last_retrieved
177 .is_some_and(|t| now.signed_duration_since(t).num_days() < 14);
178 let frequently_retrieved = fact.retrieval_count >= 5;
179
180 if fact.confidence < config.low_confidence_threshold {
181 to_archive.push(i);
182 continue;
183 }
184
185 if fact.last_confirmed < stale_threshold
186 && fact.confirmation_count <= 1
187 && fact.confidence < 0.5
188 && !recently_retrieved
189 && !frequently_retrieved
190 {
191 to_archive.push(i);
192 }
193 }
194
195 to_archive.sort_unstable();
196 to_archive.dedup();
197 let count = to_archive.len();
198
199 for idx in to_archive.into_iter().rev() {
200 archived.push(facts.remove(idx));
201 }
202
203 if facts.len() > config.max_facts {
204 facts.sort_by(|a, b| {
205 b.confidence
206 .partial_cmp(&a.confidence)
207 .unwrap_or(std::cmp::Ordering::Equal)
208 });
209 let excess: Vec<KnowledgeFact> = facts.drain(config.max_facts..).collect();
210 archived.extend(excess);
211 }
212
213 (count, archived)
214}
215
216pub fn run_lifecycle(facts: &mut Vec<KnowledgeFact>, config: &LifecycleConfig) -> LifecycleReport {
217 let decayed = apply_confidence_decay(facts, config);
218 let consolidated = consolidate_similar(facts, config.consolidation_similarity);
219 let (compacted, archived) = compact(facts, config);
220
221 if !archived.is_empty() {
222 let _ = archive_facts(&archived);
223 }
224
225 LifecycleReport {
226 decayed_count: decayed,
227 consolidated_count: consolidated,
228 archived_count: archived.len(),
229 compacted_count: compacted,
230 remaining_facts: facts.len(),
231 }
232}
233
234#[derive(Debug, Serialize, Deserialize)]
235struct ArchivedFacts {
236 pub archived_at: DateTime<Utc>,
237 pub facts: Vec<KnowledgeFact>,
238}
239
240fn archive_facts(facts: &[KnowledgeFact]) -> Result<(), String> {
241 let dir = crate::core::data_dir::lean_ctx_data_dir()?
242 .join("memory")
243 .join("archive");
244 std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
245
246 let now = Utc::now();
249 let suffix = now.timestamp_subsec_nanos() % 1_000_000;
250 let filename = format!("archive-{}-{suffix:06}.json", now.format("%Y%m%d-%H%M%S"));
251 let archive = ArchivedFacts {
252 archived_at: now,
253 facts: facts.to_vec(),
254 };
255 let json = serde_json::to_string_pretty(&archive).map_err(|e| format!("{e}"))?;
256 std::fs::write(dir.join(filename), json).map_err(|e| format!("{e}"))?;
257
258 let archives = list_archives();
262 if archives.len() > MAX_ARCHIVE_FILES {
263 for old in &archives[..archives.len() - MAX_ARCHIVE_FILES] {
264 let _ = std::fs::remove_file(old);
265 }
266 }
267 Ok(())
268}
269
270pub fn restore_archive(archive_path: &str) -> Result<Vec<KnowledgeFact>, String> {
271 let data = std::fs::read_to_string(archive_path).map_err(|e| format!("{e}"))?;
272 let archive: ArchivedFacts = serde_json::from_str(&data).map_err(|e| format!("{e}"))?;
273 Ok(archive.facts)
274}
275
276pub fn list_archives() -> Vec<PathBuf> {
277 let dir = match crate::core::data_dir::lean_ctx_data_dir() {
278 Ok(d) => d.join("memory").join("archive"),
279 Err(_) => return Vec::new(),
280 };
281
282 if !dir.exists() {
283 return Vec::new();
284 }
285
286 let mut archives: Vec<PathBuf> = std::fs::read_dir(&dir)
287 .into_iter()
288 .flatten()
289 .flatten()
290 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
291 .map(|e| e.path())
292 .collect();
293
294 archives.sort();
295 archives
296}
297
298fn word_similarity(a: &str, b: &str) -> f32 {
299 let a_lower = a.to_lowercase();
300 let b_lower = b.to_lowercase();
301 let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
302 let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
303
304 if a_words.is_empty() && b_words.is_empty() {
305 return 1.0;
306 }
307
308 let intersection = a_words.intersection(&b_words).count();
309 let union = a_words.union(&b_words).count();
310
311 if union == 0 {
312 return 0.0;
313 }
314
315 intersection as f32 / union as f32
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::core::knowledge::KnowledgeArchetype;
322
323 fn make_fact(category: &str, key: &str, value: &str, confidence: f32) -> KnowledgeFact {
324 KnowledgeFact {
325 category: category.to_string(),
326 key: key.to_string(),
327 value: value.to_string(),
328 source_session: "s1".to_string(),
329 confidence,
330 created_at: Utc::now(),
331 last_confirmed: Utc::now(),
332 retrieval_count: 0,
333 last_retrieved: None,
334 valid_from: Some(Utc::now()),
335 valid_until: None,
336 supersedes: None,
337 confirmation_count: 1,
338 feedback_up: 0,
339 feedback_down: 0,
340 last_feedback: None,
341 privacy: crate::core::memory_boundary::FactPrivacy::default(),
342 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
343 imported_from: None,
344 archetype: KnowledgeArchetype::default(),
345 fidelity: None,
346 revision_count: 0,
347 }
348 }
349
350 fn make_old_fact(
351 category: &str,
352 key: &str,
353 value: &str,
354 confidence: f32,
355 days_old: i64,
356 ) -> KnowledgeFact {
357 let past = Utc::now() - Duration::days(days_old);
358 KnowledgeFact {
359 category: category.to_string(),
360 key: key.to_string(),
361 value: value.to_string(),
362 source_session: "s1".to_string(),
363 confidence,
364 created_at: past,
365 last_confirmed: past,
366 retrieval_count: 0,
367 last_retrieved: None,
368 valid_from: Some(past),
369 valid_until: None,
370 supersedes: None,
371 confirmation_count: 1,
372 feedback_up: 0,
373 feedback_down: 0,
374 last_feedback: None,
375 privacy: crate::core::memory_boundary::FactPrivacy::default(),
376 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
377 imported_from: None,
378 archetype: KnowledgeArchetype::default(),
379 fidelity: None,
380 revision_count: 0,
381 }
382 }
383
384 #[test]
385 fn decay_reduces_confidence() {
386 let config = LifecycleConfig::default();
387 let mut facts = vec![make_old_fact("arch", "db", "PostgreSQL", 0.9, 10)];
388
389 let count = apply_confidence_decay(&mut facts, &config);
390 assert_eq!(count, 1);
391 assert!(facts[0].confidence < 0.9);
392 assert!(facts[0].confidence > 0.7);
393 }
394
395 #[test]
396 fn decay_skips_recent_facts() {
397 let config = LifecycleConfig::default();
398 let mut facts = vec![make_fact("arch", "db", "PostgreSQL", 0.9)];
399
400 let count = apply_confidence_decay(&mut facts, &config);
401 assert_eq!(count, 0);
402 }
403
404 #[test]
405 fn feedback_steers_decay_keep_vs_forget() {
406 let config = LifecycleConfig::default();
407 let mut praised = make_old_fact("arch", "loved", "keep me", 0.9, 10);
408 praised.feedback_up = 5;
409 let mut panned = make_old_fact("arch", "hated", "forget me", 0.9, 10);
410 panned.feedback_down = 5;
411 let neutral = make_old_fact("arch", "meh", "neutral", 0.9, 10);
412
413 let mut facts = vec![praised, panned, neutral];
414 apply_confidence_decay(&mut facts, &config);
415
416 let (praised_c, panned_c, neutral_c) = (
417 facts[0].confidence,
418 facts[1].confidence,
419 facts[2].confidence,
420 );
421
422 assert!(
424 praised_c > neutral_c,
425 "praised {praised_c} should outlast neutral {neutral_c}"
426 );
427 assert!(
428 neutral_c > panned_c,
429 "neutral {neutral_c} should outlast panned {panned_c}"
430 );
431 assert!(panned_c >= 0.05);
433 }
434
435 #[test]
436 fn consolidate_similar_facts() {
437 let mut facts = vec![
438 make_fact("arch", "db", "uses PostgreSQL database", 0.8),
439 make_fact("arch", "db2", "uses PostgreSQL database system", 0.6),
440 make_fact("ops", "deploy", "docker compose up", 0.9),
441 ];
442
443 let count = consolidate_similar(&mut facts, 0.7);
444 assert!(count > 0, "Should consolidate similar facts");
445 assert!(facts.len() < 3);
446 }
447
448 #[test]
449 fn consolidate_keeps_different_categories() {
450 let mut facts = vec![
451 make_fact("arch", "db", "PostgreSQL", 0.8),
452 make_fact("ops", "db", "PostgreSQL", 0.8),
453 ];
454
455 let count = consolidate_similar(&mut facts, 0.9);
456 assert_eq!(count, 0, "Different categories should not consolidate");
457 }
458
459 #[test]
460 fn compact_removes_low_confidence() {
461 let config = LifecycleConfig::default();
462 let mut facts = vec![
463 make_fact("arch", "db", "PostgreSQL", 0.9),
464 make_fact("arch", "cache", "Redis", 0.1),
465 ];
466
467 let (count, archived) = compact(&mut facts, &config);
468 assert_eq!(count, 1);
469 assert_eq!(facts.len(), 1);
470 assert_eq!(archived.len(), 1);
471 assert_eq!(archived[0].key, "cache");
472 }
473
474 #[test]
475 fn compact_archives_stale_facts() {
476 let config = LifecycleConfig::default();
477 let mut facts = vec![
478 make_fact("arch", "db", "PostgreSQL", 0.9),
479 make_old_fact("arch", "old", "ancient thing", 0.4, 60),
480 ];
481
482 let (count, archived) = compact(&mut facts, &config);
483 assert_eq!(count, 1);
484 assert_eq!(archived[0].key, "old");
485 }
486
487 #[test]
488 fn full_lifecycle_run() {
489 let config = LifecycleConfig {
490 max_facts: 5,
491 ..Default::default()
492 };
493
494 let mut facts = vec![
495 make_fact("arch", "db", "PostgreSQL", 0.9),
496 make_fact("arch", "cache", "Redis", 0.8),
497 make_old_fact("arch", "old1", "thing1", 0.2, 50),
498 make_old_fact("arch", "old2", "thing2", 0.15, 60),
499 make_fact("ops", "deploy", "docker compose", 0.7),
500 ];
501
502 let report = run_lifecycle(&mut facts, &config);
503 assert!(report.remaining_facts <= config.max_facts);
504 assert!(report.decayed_count > 0 || report.compacted_count > 0);
505 }
506
507 #[test]
508 fn word_similarity_identical() {
509 assert!((word_similarity("hello world", "hello world") - 1.0).abs() < 0.01);
510 }
511
512 #[test]
513 fn word_similarity_partial() {
514 let sim = word_similarity("uses PostgreSQL database", "PostgreSQL database system");
515 assert!(sim >= 0.5, "Expected >= 0.5 but got {sim}");
516 assert!(sim < 1.0);
517 }
518
519 #[test]
520 fn word_similarity_different() {
521 let sim = word_similarity("Redis cache", "Docker compose");
522 assert!(sim < 0.1);
523 }
524}