Skip to main content

lean_ctx/core/
consolidation_engine.rs

1//! Canonical session→knowledge consolidation (#995 Phase 4).
2//!
3//! One session-import core (`import_session_into`) and one option set
4//! ([`ConsolidateOptions`]) back every consolidation driver — the CLI/MCP
5//! `consolidate`, the post-dispatch scheduled pass, startup auto-consolidate and
6//! the cognition loop — so promotion budgets, fact keys, confidences and the
7//! lossless capacity reclaim stay identical regardless of who triggers a run.
8//!
9//! The full, locked orchestrator (import + history + lifecycle + per-store
10//! reclaim + report) lives in
11//! `ctx_knowledge::consolidate_project_knowledge_with`; this module owns the
12//! shared import primitive plus the thin `scheduled` wrapper the background
13//! drivers call.
14
15use chrono::{DateTime, Utc};
16
17use crate::core::knowledge::ProjectKnowledge;
18use crate::core::memory_policy::MemoryPolicy;
19use crate::core::session::{Finding, SessionState};
20
21/// Promotion budgets for the scheduled (post-dispatch / cognition) pass.
22#[derive(Debug, Clone, Copy)]
23pub struct ConsolidationBudgets {
24    pub max_decisions: usize,
25    pub max_findings: usize,
26}
27
28impl Default for ConsolidationBudgets {
29    fn default() -> Self {
30        Self {
31            max_decisions: 5,
32            max_findings: 8,
33        }
34    }
35}
36
37/// Leaner outcome kept for the scheduled callers (post_dispatch / tool_lifecycle)
38/// that only need the promotion + lifecycle headline, not the full report.
39#[derive(Debug, Clone)]
40pub struct ConsolidationOutcome {
41    pub promoted: u32,
42    pub promoted_decisions: u32,
43    pub promoted_findings: u32,
44    pub lifecycle_archived: usize,
45    pub lifecycle_remaining: usize,
46}
47
48/// How a consolidation run imports the session and reclaims capacity. One option
49/// set per driver — see the constructors. Replaces the four divergent, copy-pasted
50/// import loops (each with subtly different keys, caps and confidences).
51#[derive(Debug, Clone)]
52pub struct ConsolidateOptions {
53    /// Promote the latest session's findings/decisions into knowledge.
54    pub import_session: bool,
55    /// Cap promoted decisions (`None` = all).
56    pub decision_budget: Option<usize>,
57    /// Cap promoted findings (`None` = all).
58    pub finding_budget: Option<usize>,
59    /// Skip findings below this salience score (`None` = import all).
60    pub finding_salience_floor: Option<u32>,
61    /// Confidence assigned to imported decisions.
62    pub decision_confidence: f32,
63    /// Confidence assigned to imported findings.
64    pub finding_confidence: f32,
65    /// Import only items newer than the session watermark and advance it after
66    /// (incremental auto-consolidate).
67    pub incremental: bool,
68    /// Run the fact lifecycle (decay / dedup / quality + capacity) after import.
69    pub run_lifecycle: bool,
70    /// Run the lossless capacity reclaim for history / procedures / patterns.
71    pub reclaim_stores: bool,
72    /// Emit a `KnowledgeUpdate` event after a successful (non-dry) run.
73    pub emit_event: bool,
74    /// Compute the report without mutating knowledge, archives or the session.
75    pub dry_run: bool,
76}
77
78impl ConsolidateOptions {
79    /// Explicit CLI / MCP `consolidate`: import everything, full lifecycle and a
80    /// lossless reclaim of every store.
81    pub fn manual() -> Self {
82        Self {
83            import_session: true,
84            decision_budget: None,
85            finding_budget: None,
86            finding_salience_floor: None,
87            decision_confidence: 0.85,
88            finding_confidence: 0.7,
89            incremental: false,
90            run_lifecycle: true,
91            reclaim_stores: true,
92            emit_event: false,
93            dry_run: false,
94        }
95    }
96
97    /// Scheduled background pass (post-dispatch / cognition): salience-gated,
98    /// budgeted, runs the fact lifecycle and emits an event.
99    pub fn scheduled(b: ConsolidationBudgets) -> Self {
100        Self {
101            import_session: true,
102            decision_budget: Some(b.max_decisions),
103            finding_budget: Some(b.max_findings),
104            finding_salience_floor: Some(45),
105            decision_confidence: 0.9,
106            finding_confidence: 0.75,
107            incremental: false,
108            run_lifecycle: true,
109            reclaim_stores: false,
110            emit_event: true,
111            dry_run: false,
112        }
113    }
114
115    /// Startup auto-consolidate: incremental (watermark) import only, no lifecycle.
116    pub fn incremental_auto() -> Self {
117        Self {
118            import_session: true,
119            decision_budget: None,
120            finding_budget: None,
121            finding_salience_floor: None,
122            decision_confidence: 0.85,
123            finding_confidence: 0.7,
124            incremental: true,
125            run_lifecycle: false,
126            reclaim_stores: false,
127            emit_event: false,
128            dry_run: false,
129        }
130    }
131
132    /// Same plan, but preview-only: no writes to knowledge, archives or session.
133    #[must_use]
134    pub fn into_dry_run(mut self) -> Self {
135        self.dry_run = true;
136        self
137    }
138}
139
140/// Counts of items promoted by a single `import_session_into` call.
141#[derive(Debug, Default, Clone, Copy)]
142pub struct ImportCounts {
143    pub decisions: usize,
144    pub findings: usize,
145}
146
147impl ImportCounts {
148    pub fn total(self) -> usize {
149        self.decisions + self.findings
150    }
151}
152
153/// The single session→knowledge import. Operates on an already-locked
154/// `knowledge` (no I/O, no lock), so both the locked orchestrator and the
155/// cognition loop — which holds the knowledge lock across all its steps — share
156/// one implementation. `watermark` (incremental mode) imports only newer items.
157pub(crate) fn import_session_into(
158    knowledge: &mut ProjectKnowledge,
159    session: &SessionState,
160    opts: &ConsolidateOptions,
161    policy: &MemoryPolicy,
162    watermark: Option<DateTime<Utc>>,
163) -> ImportCounts {
164    let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
165
166    let mut decisions: Vec<&crate::core::session::Decision> = session
167        .decisions
168        .iter()
169        .filter(|d| is_new(d.timestamp))
170        .collect();
171    decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
172    if let Some(n) = opts.decision_budget {
173        decisions.truncate(n);
174    }
175    let mut decision_count = 0;
176    for d in &decisions {
177        let key = slug_key(&d.summary, 50);
178        knowledge.remember(
179            "decision",
180            &key,
181            &d.summary,
182            &session.id,
183            opts.decision_confidence,
184            policy,
185        );
186        decision_count += 1;
187    }
188
189    let mut findings: Vec<&Finding> = session
190        .findings
191        .iter()
192        .filter(|f| is_new(f.timestamp))
193        .collect();
194    findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
195    let mut finding_count = 0;
196    for f in &findings {
197        if opts.finding_budget.is_some_and(|n| finding_count >= n) {
198            break;
199        }
200        if let Some(floor) = opts.finding_salience_floor
201            && crate::core::memory_salience::text_salience(&f.summary) < floor
202        {
203            continue;
204        }
205        let key = finding_key(f);
206        knowledge.remember(
207            "finding",
208            &key,
209            &f.summary,
210            &session.id,
211            opts.finding_confidence,
212            policy,
213        );
214        finding_count += 1;
215    }
216
217    ImportCounts {
218        decisions: decision_count,
219        findings: finding_count,
220    }
221}
222
223/// Stable knowledge key for a session finding: `file[:line]` when located, else a
224/// content slug. Content-based (never index-based), so re-imports upsert the same
225/// fact and the output stays deterministic across runs (#498).
226pub(crate) fn finding_key(f: &Finding) -> String {
227    match (&f.file, f.line) {
228        (Some(file), Some(line)) => format!("{file}:{line}"),
229        (Some(file), None) => file.clone(),
230        (None, _) => format!("finding-{}", slug_key(&f.summary, 36)),
231    }
232}
233
234/// Scheduled background consolidation. Thin wrapper over the canonical
235/// orchestrator with [`ConsolidateOptions::scheduled`]; kept for the
236/// post-dispatch / tool-lifecycle callers and their `ConsolidationOutcome`.
237pub fn consolidate_latest(
238    project_root: &str,
239    budgets: ConsolidationBudgets,
240) -> Result<ConsolidationOutcome, String> {
241    let opts = ConsolidateOptions::scheduled(budgets);
242    let report =
243        crate::tools::ctx_knowledge::consolidate_project_knowledge_with(project_root, &opts)?;
244    Ok(ConsolidationOutcome {
245        promoted: (report.imported_decisions + report.imported_findings) as u32,
246        promoted_decisions: report.imported_decisions as u32,
247        promoted_findings: report.imported_findings as u32,
248        lifecycle_archived: report.lifecycle.archived_count,
249        lifecycle_remaining: report.lifecycle.remaining_facts,
250    })
251}
252
253/// Deterministic, filesystem-safe slug for a fact key: lowercase alphanumerics,
254/// single dashes for separators, trimmed, capped at `max` bytes.
255pub(crate) fn slug_key(s: &str, max: usize) -> String {
256    let mut out = String::new();
257    for ch in s.chars() {
258        if out.len() >= max {
259            break;
260        }
261        if ch.is_ascii_alphanumeric() {
262            out.push(ch.to_ascii_lowercase());
263        } else if (ch.is_whitespace() || ch == '-' || ch == '_')
264            && !out.ends_with('-')
265            && !out.is_empty()
266        {
267            out.push('-');
268        }
269    }
270    out.trim_matches('-').to_string()
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn consolidate_promotes_decisions_and_salient_findings_only() {
279        let _lock = crate::core::data_dir::test_env_lock();
280        let tmp = tempfile::tempdir().expect("tempdir");
281        crate::test_env::set_var(
282            "LEAN_CTX_DATA_DIR",
283            tmp.path().to_string_lossy().to_string(),
284        );
285
286        let project_root = tmp.path().join("proj");
287        std::fs::create_dir_all(&project_root).expect("mkdir");
288        let project_root_str = project_root.to_string_lossy().to_string();
289
290        let mut session = SessionState::new();
291        session.project_root = Some(project_root_str.clone());
292        session.add_decision("Use archive-only memory lifecycle", None);
293        session.add_finding(None, None, "panic: index out of bounds");
294        session.add_finding(None, None, "just a note");
295        session.save().expect("save session");
296
297        let out = consolidate_latest(
298            &project_root_str,
299            ConsolidationBudgets {
300                max_decisions: 5,
301                max_findings: 5,
302            },
303        )
304        .expect("consolidate");
305        assert!(out.promoted_decisions >= 1);
306        assert!(out.promoted_findings >= 1);
307
308        let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
309        let active = k.facts.iter().filter(|f| f.is_current()).count();
310        assert!(active >= 2, "expected promoted facts");
311
312        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
313    }
314
315    #[test]
316    fn finding_key_is_content_based_and_deterministic() {
317        let f1 = Finding {
318            file: Some("src/main.rs".into()),
319            line: Some(42),
320            summary: "boom".into(),
321            timestamp: Utc::now(),
322        };
323        assert_eq!(finding_key(&f1), "src/main.rs:42");
324
325        let f2 = Finding {
326            file: None,
327            line: None,
328            summary: "Race condition in cache".into(),
329            timestamp: Utc::now(),
330        };
331        // Same content → same key (idempotent re-import, no index drift).
332        assert_eq!(finding_key(&f2), finding_key(&f2));
333        assert_eq!(finding_key(&f2), "finding-race-condition-in-cache");
334    }
335}