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