lean_ctx/core/
consolidation_engine.rs1use chrono::Utc;
2
3use crate::core::knowledge::ProjectKnowledge;
4use crate::core::session::SessionState;
5
6#[derive(Debug, Clone)]
7pub struct ConsolidationBudgets {
8 pub max_decisions: usize,
9 pub max_findings: usize,
10}
11
12impl Default for ConsolidationBudgets {
13 fn default() -> Self {
14 Self {
15 max_decisions: 5,
16 max_findings: 8,
17 }
18 }
19}
20
21#[derive(Debug, Clone)]
22pub struct ConsolidationOutcome {
23 pub promoted: u32,
24 pub promoted_decisions: u32,
25 pub promoted_findings: u32,
26 pub lifecycle_archived: usize,
27 pub lifecycle_remaining: usize,
28}
29
30pub fn consolidate_latest(
31 project_root: &str,
32 budgets: ConsolidationBudgets,
33) -> Result<ConsolidationOutcome, String> {
34 let session = SessionState::load_latest().ok_or_else(|| "no active session".to_string())?;
35
36 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
37
38 let mut promoted_decisions = 0u32;
39 let mut promoted_findings = 0u32;
40
41 let mut decisions = session.decisions.clone();
42 decisions.sort_by_key(|x| std::cmp::Reverse(x.timestamp));
43 decisions.truncate(budgets.max_decisions);
44 for d in &decisions {
45 let key = slug_key(&d.summary, 50);
46 knowledge.remember("decision", &key, &d.summary, &session.id, 0.9);
47 promoted_decisions += 1;
48 }
49
50 let mut findings = session.findings.clone();
51 findings.sort_by_key(|x| std::cmp::Reverse(x.timestamp));
52 let mut kept = Vec::new();
53 for f in &findings {
54 if kept.len() >= budgets.max_findings {
55 break;
56 }
57 if finding_salience(&f.summary) < 45 {
58 continue;
59 }
60 kept.push(f.clone());
61 }
62
63 for f in &kept {
64 let key = if let Some(ref file) = f.file {
65 if let Some(line) = f.line {
66 format!("{file}:{line}")
67 } else {
68 file.clone()
69 }
70 } else {
71 format!("finding-{}", slug_key(&f.summary, 36))
72 };
73 knowledge.remember("finding", &key, &f.summary, &session.id, 0.75);
74 promoted_findings += 1;
75 }
76
77 let task_desc = session
79 .task
80 .as_ref()
81 .map(|t| t.description.clone())
82 .unwrap_or_else(|| "(no task)".into());
83 let summary = format!(
84 "consolidate@{} session={} task=\"{}\" decisions={} findings={}",
85 Utc::now().format("%Y-%m-%d"),
86 session.id,
87 task_desc,
88 promoted_decisions,
89 promoted_findings
90 );
91 knowledge.consolidate(&summary, vec![session.id.clone()]);
92
93 let lifecycle = knowledge.run_memory_lifecycle();
94 knowledge.save()?;
95
96 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
97 category: "memory".to_string(),
98 key: "consolidation".to_string(),
99 action: "run".to_string(),
100 });
101
102 Ok(ConsolidationOutcome {
103 promoted: promoted_decisions + promoted_findings,
104 promoted_decisions,
105 promoted_findings,
106 lifecycle_archived: lifecycle.archived_count,
107 lifecycle_remaining: lifecycle.remaining_facts,
108 })
109}
110
111fn slug_key(s: &str, max: usize) -> String {
112 let mut out = String::new();
113 for ch in s.chars() {
114 if out.len() >= max {
115 break;
116 }
117 if ch.is_ascii_alphanumeric() {
118 out.push(ch.to_ascii_lowercase());
119 } else if (ch.is_whitespace() || ch == '-' || ch == '_')
120 && !out.ends_with('-')
121 && !out.is_empty()
122 {
123 out.push('-');
124 }
125 }
126 out.trim_matches('-').to_string()
127}
128
129fn finding_salience(summary: &str) -> u32 {
130 let s = summary.to_lowercase();
131 let mut score = 20u32;
132
133 let boosts = [
134 ("error", 25),
135 ("failed", 25),
136 ("panic", 30),
137 ("assert", 20),
138 ("forbidden", 25),
139 ("timeout", 20),
140 ("deadlock", 25),
141 ("security", 25),
142 ("vuln", 25),
143 ("e0", 15), ];
145
146 for (pat, b) in boosts {
147 if s.contains(pat) {
148 score = score.saturating_add(b);
149 }
150 }
151
152 score
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn consolidate_promotes_decisions_and_salient_findings_only() {
161 let _lock = crate::core::data_dir::test_env_lock();
162 let tmp = tempfile::tempdir().expect("tempdir");
163 std::env::set_var(
164 "LEAN_CTX_DATA_DIR",
165 tmp.path().to_string_lossy().to_string(),
166 );
167
168 let project_root = tmp.path().join("proj");
169 std::fs::create_dir_all(&project_root).expect("mkdir");
170 let project_root_str = project_root.to_string_lossy().to_string();
171
172 let mut session = SessionState::new();
173 session.project_root = Some(project_root_str.clone());
174 session.add_decision("Use archive-only memory lifecycle", None);
175 session.add_finding(None, None, "panic: index out of bounds");
176 session.add_finding(None, None, "just a note");
177 session.save().expect("save session");
178
179 let out = consolidate_latest(
180 &project_root_str,
181 ConsolidationBudgets {
182 max_decisions: 5,
183 max_findings: 5,
184 },
185 )
186 .expect("consolidate");
187 assert!(out.promoted_decisions >= 1);
188 assert!(out.promoted_findings >= 1);
189
190 let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
191 let active = k.facts.iter().filter(|f| f.is_current()).count();
192 assert!(active >= 2, "expected promoted facts");
193
194 std::env::remove_var("LEAN_CTX_DATA_DIR");
195 }
196}