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