Skip to main content

lean_ctx/core/knowledge/
core.rs

1use chrono::Utc;
2
3use super::ranking::{
4    build_knowledge_index, fact_version_id_v1, hash_project_root, string_similarity,
5};
6use super::types::{
7    AdmissionResult, Contradiction, ContradictionSeverity, KnowledgeArchetype, KnowledgeFact,
8    ProjectKnowledge, ProjectPattern,
9};
10use crate::core::memory_boundary::FactPrivacy;
11use crate::core::memory_policy::MemoryPolicy;
12
13impl ProjectKnowledge {
14    pub fn run_memory_lifecycle(
15        &mut self,
16        policy: &MemoryPolicy,
17    ) -> Result<crate::core::memory_lifecycle::LifecycleReport, String> {
18        let cfg = crate::core::memory_lifecycle::LifecycleConfig::from_policy(policy);
19        let report = crate::core::memory_lifecycle::run_lifecycle(&mut self.facts, &cfg);
20        if report.is_ok() {
21            self.rebuild_index();
22        }
23        report
24    }
25
26    /// Cluster compaction (#971): collapse piles of low-value, mutually-similar
27    /// facts into recoverable digests, returning the number of clusters
28    /// collapsed. Heavier than [`Self::run_memory_lifecycle`], so it runs only
29    /// from the background cognition loop (hourly), never on every write. The
30    /// originals are archived and rehydrate on recall — nothing is lost.
31    pub fn compact_low_value_clusters(&mut self, policy: &MemoryPolicy) -> u32 {
32        if !policy.compaction.enabled {
33            return 0;
34        }
35        let cfg = crate::core::memory_lifecycle::ClusterCompactionConfig {
36            min_cluster: policy.compaction.min_cluster,
37            similarity: policy.compaction.similarity,
38            max_confidence: policy.compaction.max_confidence,
39            max_confirmations: policy.compaction.max_confirmations,
40        };
41        let (collapsed, archived) =
42            crate::core::memory_lifecycle::compact_clusters(&mut self.facts, &cfg);
43        if !archived.is_empty() {
44            let _ = crate::core::memory_lifecycle::archive_facts(&archived);
45        }
46        self.rebuild_index();
47        collapsed as u32
48    }
49
50    pub fn new(project_root: &str) -> Self {
51        Self {
52            project_root: project_root.to_string(),
53            project_hash: hash_project_root(project_root),
54            facts: Vec::new(),
55            patterns: Vec::new(),
56            history: Vec::new(),
57            updated_at: Utc::now(),
58            judged_pairs: Vec::new(),
59            index: Default::default(),
60        }
61    }
62
63    /// Rebuilds the transient lookup tables after loading or changing facts.
64    /// The maps are intentionally excluded from JSON serialization.
65    pub fn rebuild_index(&mut self) {
66        self.index = build_knowledge_index(&self.facts);
67    }
68
69    pub fn check_contradiction(
70        &self,
71        category: &str,
72        key: &str,
73        new_value: &str,
74        policy: &MemoryPolicy,
75    ) -> Option<Contradiction> {
76        let existing = self
77            .facts
78            .iter()
79            .find(|f| f.category == category && f.key == key && f.is_current())?;
80
81        if existing.value.to_lowercase() == new_value.to_lowercase() {
82            return None;
83        }
84
85        let similarity = string_similarity(&existing.value, new_value);
86        if similarity > 0.8 {
87            return None;
88        }
89
90        let severity = if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
91            ContradictionSeverity::High
92        } else if existing.confidence >= policy.knowledge.contradiction_threshold {
93            ContradictionSeverity::Medium
94        } else {
95            ContradictionSeverity::Low
96        };
97
98        let resolution = match severity {
99            ContradictionSeverity::High => format!(
100                "High-confidence fact [{category}/{key}] changed: '{}' -> '{new_value}' (was confirmed {}x). Previous value archived.",
101                existing.value, existing.confirmation_count
102            ),
103            ContradictionSeverity::Medium => format!(
104                "Fact [{category}/{key}] updated: '{}' -> '{new_value}'",
105                existing.value
106            ),
107            ContradictionSeverity::Low => format!(
108                "Low-confidence fact [{category}/{key}] replaced: '{}' -> '{new_value}'",
109                existing.value
110            ),
111        };
112
113        Some(Contradiction {
114            existing_key: key.to_string(),
115            existing_value: existing.value.clone(),
116            new_value: new_value.to_string(),
117            category: category.to_string(),
118            severity,
119            resolution,
120        })
121    }
122
123    pub fn remember(
124        &mut self,
125        category: &str,
126        key: &str,
127        value: &str,
128        session_id: &str,
129        confidence: f32,
130        policy: &MemoryPolicy,
131    ) -> Option<Contradiction> {
132        let contradiction = self.check_contradiction(category, key, value, policy);
133
134        if let Some(existing) = self
135            .facts
136            .iter_mut()
137            .find(|f| f.category == category && f.key == key && f.is_current())
138        {
139            let now = Utc::now();
140            let same_value_ci = existing.value.to_lowercase() == value.to_lowercase();
141            let similarity = string_similarity(&existing.value, value);
142
143            if existing.value == value || same_value_ci || similarity > 0.8 {
144                existing.last_confirmed = now;
145                existing.source_session = session_id.to_string();
146                existing.confidence = f32::midpoint(existing.confidence, confidence);
147                existing.confirmation_count += 1;
148                existing.revision_count += 1;
149
150                if existing.value != value && similarity > 0.8 && value.len() > existing.value.len()
151                {
152                    existing.value = value.to_string();
153                }
154            } else {
155                let superseded = fact_version_id_v1(existing);
156                let next_revision = existing.revision_count + 1;
157                existing.valid_until = Some(now);
158                existing.valid_from = existing.valid_from.or(Some(existing.created_at));
159
160                self.facts.push(KnowledgeFact {
161                    category: category.to_string(),
162                    key: key.to_string(),
163                    value: value.to_string(),
164                    source_session: session_id.to_string(),
165                    confidence,
166                    created_at: now,
167                    last_confirmed: now,
168                    retrieval_count: 0,
169                    last_retrieved: None,
170                    valid_from: Some(now),
171                    valid_until: None,
172                    supersedes: Some(superseded),
173                    confirmation_count: 1,
174                    feedback_up: 0,
175                    feedback_down: 0,
176                    last_feedback: None,
177                    privacy: FactPrivacy::default(),
178                    sensitivity: crate::core::sensitivity::classify_content(value),
179                    imported_from: None,
180                    archetype: KnowledgeArchetype::infer_from_category(category),
181                    fidelity: None,
182                    revision_count: next_revision,
183                });
184            }
185        } else {
186            let now = Utc::now();
187            self.facts.push(KnowledgeFact {
188                category: category.to_string(),
189                key: key.to_string(),
190                value: value.to_string(),
191                source_session: session_id.to_string(),
192                confidence,
193                created_at: now,
194                last_confirmed: now,
195                retrieval_count: 0,
196                last_retrieved: None,
197                valid_from: Some(now),
198                valid_until: None,
199                supersedes: None,
200                confirmation_count: 1,
201                feedback_up: 0,
202                feedback_down: 0,
203                last_feedback: None,
204                privacy: FactPrivacy::default(),
205                sensitivity: crate::core::sensitivity::classify_content(value),
206                imported_from: None,
207                archetype: KnowledgeArchetype::infer_from_category(category),
208                fidelity: None,
209                revision_count: 1,
210            });
211        }
212
213        // Run the lifecycle as soon as we exceed the configured budget.
214        // `run_lifecycle` sorts by importance and drains the excess back down to
215        // `max_facts` (archiving it), so this is self-limiting: the store settles
216        // at <= max_facts. The previous `* 2` guard let a project's facts grow to
217        // twice the cap before any eviction fired, which is why stores were
218        // observed sitting at 103% (206/200) with no reclamation.
219        if self.facts.len() > policy.knowledge.max_facts {
220            let _ = self.run_memory_lifecycle(policy);
221        }
222
223        self.updated_at = Utc::now();
224        self.rebuild_index();
225
226        let action = if contradiction.is_some() {
227            "contradict"
228        } else {
229            "remember"
230        };
231        crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
232            category: category.to_string(),
233            key: key.to_string(),
234            action: action.to_string(),
235        });
236
237        contradiction
238    }
239
240    /// Write-time admission gate for the agent-facing `ctx_knowledge remember`
241    /// path (#970). Unlike [`Self::remember`] (which every internal restorer also
242    /// calls), this enforces the [`crate::core::memory_policy::AdmissionPolicy`]:
243    /// near-duplicates are merged instead of inserted, and low-salience noise is
244    /// kept out of the capped store — so eviction never has to drop a good fact to
245    /// make room for a paraphrase. Internal callers (archive rehydrate, cognition
246    /// auto-promotion) keep using [`Self::remember`] and are never gated.
247    pub fn remember_admitted(
248        &mut self,
249        category: &str,
250        key: &str,
251        value: &str,
252        session_id: &str,
253        confidence: f32,
254        policy: &MemoryPolicy,
255    ) -> AdmissionResult {
256        let adm = &policy.admission;
257
258        // Admission only governs *new* facts. An existing (category,key) is a
259        // confirm/supersede the agent explicitly addressed — defer to remember().
260        let has_exact = self
261            .facts
262            .iter()
263            .any(|f| f.category == category && f.key == key && f.is_current());
264        if !adm.enabled || has_exact {
265            return AdmissionResult::Stored(
266                self.remember(category, key, value, session_id, confidence, policy),
267            );
268        }
269
270        // Salience floor: keep low-signal noise out of a capped store.
271        if adm.min_salience > 0 {
272            let salience = crate::core::memory_salience::text_salience(value);
273            if salience < adm.min_salience {
274                return AdmissionResult::RejectedLowSalience {
275                    salience,
276                    floor: adm.min_salience,
277                };
278            }
279        }
280
281        // Cross-key near-duplicate auto-merge within the same category.
282        if adm.auto_merge_similarity > 0.0
283            && let Some(merged) = self.merge_near_duplicate(
284                category,
285                value,
286                session_id,
287                confidence,
288                adm.auto_merge_similarity,
289            )
290        {
291            return merged;
292        }
293
294        AdmissionResult::Stored(self.remember(category, key, value, session_id, confidence, policy))
295    }
296
297    /// Find the best same-category, different-key, *current* near-duplicate of
298    /// `value` at/above `threshold` and merge into it (confirmation bump,
299    /// confidence midpoint, keep the longer/more-complete value). Returns the
300    /// merge outcome, or `None` when nothing qualifies. Deterministic: ties
301    /// resolve to the earliest-inserted fact (stable scan, strict `>`).
302    fn merge_near_duplicate(
303        &mut self,
304        category: &str,
305        value: &str,
306        session_id: &str,
307        confidence: f32,
308        threshold: f32,
309    ) -> Option<AdmissionResult> {
310        let mut best: Option<(usize, f32)> = None;
311        for (i, f) in self.facts.iter().enumerate() {
312            if !f.is_current() || f.category != category {
313                continue;
314            }
315            let sim = string_similarity(value, &f.value);
316            if sim >= threshold && best.is_none_or(|(_, bs)| sim > bs) {
317                best = Some((i, sim));
318            }
319        }
320        let (idx, _) = best?;
321        let now = Utc::now();
322        let f = &mut self.facts[idx];
323        f.last_confirmed = now;
324        f.source_session = session_id.to_string();
325        f.confidence = f32::midpoint(f.confidence, confidence);
326        f.confirmation_count += 1;
327        f.revision_count += 1;
328        if value.len() > f.value.len() {
329            f.value = value.to_string();
330        }
331        let result = AdmissionResult::Merged {
332            category: f.category.clone(),
333            key: f.key.clone(),
334            confirmations: f.confirmation_count,
335            value: f.value.clone(),
336        };
337        self.rebuild_index();
338        Some(result)
339    }
340
341    pub fn add_pattern(
342        &mut self,
343        pattern_type: &str,
344        description: &str,
345        examples: Vec<String>,
346        session_id: &str,
347        policy: &MemoryPolicy,
348    ) {
349        if let Some(existing) = self
350            .patterns
351            .iter_mut()
352            .find(|p| p.pattern_type == pattern_type && p.description == description)
353        {
354            for ex in &examples {
355                if !existing.examples.contains(ex) {
356                    existing.examples.push(ex.clone());
357                }
358            }
359            return;
360        }
361
362        self.patterns.push(ProjectPattern {
363            pattern_type: pattern_type.to_string(),
364            description: description.to_string(),
365            examples,
366            source_session: session_id.to_string(),
367            created_at: Utc::now(),
368        });
369
370        // Lossless capacity reclaim (#995): keep the newest patterns and archive
371        // the rest. The previous `truncate` kept the *oldest* patterns (it dropped
372        // the just-pushed one once at the cap) and lost them permanently.
373        if let Err(error) = crate::core::memory_capacity::reclaim_store(
374            crate::core::memory_archive::MemoryStore::Patterns,
375            Some(&self.project_hash),
376            &mut self.patterns,
377            policy.knowledge.max_patterns,
378            policy.lifecycle.reclaim_headroom_pct,
379            policy.lifecycle.reclaim_enabled,
380            |a, b| {
381                b.created_at
382                    .cmp(&a.created_at)
383                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
384                    .then_with(|| a.description.cmp(&b.description))
385            },
386        ) {
387            tracing::warn!(%error, "pattern capacity reclaim failed");
388        }
389        self.updated_at = Utc::now();
390    }
391
392    pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
393        let before = self.facts.len();
394        self.facts
395            .retain(|f| !(f.category == category && f.key == key));
396        let removed = self.facts.len() < before;
397        if removed {
398            self.updated_at = Utc::now();
399            self.rebuild_index();
400        }
401        removed
402    }
403}