Skip to main content

lean_ctx/core/knowledge/
core.rs

1use chrono::Utc;
2
3use super::ranking::{fact_version_id_v1, hash_project_root, string_similarity};
4use super::types::{
5    Contradiction, ContradictionSeverity, KnowledgeArchetype, KnowledgeFact, ProjectKnowledge,
6    ProjectPattern,
7};
8use crate::core::memory_boundary::FactPrivacy;
9use crate::core::memory_policy::MemoryPolicy;
10
11impl ProjectKnowledge {
12    pub fn run_memory_lifecycle(
13        &mut self,
14        policy: &MemoryPolicy,
15    ) -> crate::core::memory_lifecycle::LifecycleReport {
16        let cfg = crate::core::memory_lifecycle::LifecycleConfig {
17            max_facts: policy.knowledge.max_facts,
18            decay_rate_per_day: policy.lifecycle.decay_rate,
19            low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
20            stale_days: policy.lifecycle.stale_days,
21            consolidation_similarity: policy.lifecycle.similarity_threshold,
22            forgetting_model: crate::core::memory_lifecycle::ForgettingModel::parse(
23                &policy.lifecycle.forgetting_model,
24            ),
25            base_stability_days: policy.lifecycle.base_stability_days,
26            archetype_aware_decay: policy.lifecycle.archetype_aware_decay,
27        };
28        crate::core::memory_lifecycle::run_lifecycle(&mut self.facts, &cfg)
29    }
30
31    pub fn new(project_root: &str) -> Self {
32        Self {
33            project_root: project_root.to_string(),
34            project_hash: hash_project_root(project_root),
35            facts: Vec::new(),
36            patterns: Vec::new(),
37            history: Vec::new(),
38            updated_at: Utc::now(),
39            judged_pairs: Vec::new(),
40        }
41    }
42
43    pub fn check_contradiction(
44        &self,
45        category: &str,
46        key: &str,
47        new_value: &str,
48        policy: &MemoryPolicy,
49    ) -> Option<Contradiction> {
50        let existing = self
51            .facts
52            .iter()
53            .find(|f| f.category == category && f.key == key && f.is_current())?;
54
55        if existing.value.to_lowercase() == new_value.to_lowercase() {
56            return None;
57        }
58
59        let similarity = string_similarity(&existing.value, new_value);
60        if similarity > 0.8 {
61            return None;
62        }
63
64        let severity = if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
65            ContradictionSeverity::High
66        } else if existing.confidence >= policy.knowledge.contradiction_threshold {
67            ContradictionSeverity::Medium
68        } else {
69            ContradictionSeverity::Low
70        };
71
72        let resolution = match severity {
73            ContradictionSeverity::High => format!(
74                "High-confidence fact [{category}/{key}] changed: '{}' -> '{new_value}' (was confirmed {}x). Previous value archived.",
75                existing.value, existing.confirmation_count
76            ),
77            ContradictionSeverity::Medium => format!(
78                "Fact [{category}/{key}] updated: '{}' -> '{new_value}'",
79                existing.value
80            ),
81            ContradictionSeverity::Low => format!(
82                "Low-confidence fact [{category}/{key}] replaced: '{}' -> '{new_value}'",
83                existing.value
84            ),
85        };
86
87        Some(Contradiction {
88            existing_key: key.to_string(),
89            existing_value: existing.value.clone(),
90            new_value: new_value.to_string(),
91            category: category.to_string(),
92            severity,
93            resolution,
94        })
95    }
96
97    pub fn remember(
98        &mut self,
99        category: &str,
100        key: &str,
101        value: &str,
102        session_id: &str,
103        confidence: f32,
104        policy: &MemoryPolicy,
105    ) -> Option<Contradiction> {
106        let contradiction = self.check_contradiction(category, key, value, policy);
107
108        if let Some(existing) = self
109            .facts
110            .iter_mut()
111            .find(|f| f.category == category && f.key == key && f.is_current())
112        {
113            let now = Utc::now();
114            let same_value_ci = existing.value.to_lowercase() == value.to_lowercase();
115            let similarity = string_similarity(&existing.value, value);
116
117            if existing.value == value || same_value_ci || similarity > 0.8 {
118                existing.last_confirmed = now;
119                existing.source_session = session_id.to_string();
120                existing.confidence = f32::midpoint(existing.confidence, confidence);
121                existing.confirmation_count += 1;
122                existing.revision_count += 1;
123
124                if existing.value != value && similarity > 0.8 && value.len() > existing.value.len()
125                {
126                    existing.value = value.to_string();
127                }
128            } else {
129                let superseded = fact_version_id_v1(existing);
130                let next_revision = existing.revision_count + 1;
131                existing.valid_until = Some(now);
132                existing.valid_from = existing.valid_from.or(Some(existing.created_at));
133
134                self.facts.push(KnowledgeFact {
135                    category: category.to_string(),
136                    key: key.to_string(),
137                    value: value.to_string(),
138                    source_session: session_id.to_string(),
139                    confidence,
140                    created_at: now,
141                    last_confirmed: now,
142                    retrieval_count: 0,
143                    last_retrieved: None,
144                    valid_from: Some(now),
145                    valid_until: None,
146                    supersedes: Some(superseded),
147                    confirmation_count: 1,
148                    feedback_up: 0,
149                    feedback_down: 0,
150                    last_feedback: None,
151                    privacy: FactPrivacy::default(),
152                    sensitivity: crate::core::sensitivity::classify_content(value),
153                    imported_from: None,
154                    archetype: KnowledgeArchetype::infer_from_category(category),
155                    fidelity: None,
156                    revision_count: next_revision,
157                });
158            }
159        } else {
160            let now = Utc::now();
161            self.facts.push(KnowledgeFact {
162                category: category.to_string(),
163                key: key.to_string(),
164                value: value.to_string(),
165                source_session: session_id.to_string(),
166                confidence,
167                created_at: now,
168                last_confirmed: now,
169                retrieval_count: 0,
170                last_retrieved: None,
171                valid_from: Some(now),
172                valid_until: None,
173                supersedes: None,
174                confirmation_count: 1,
175                feedback_up: 0,
176                feedback_down: 0,
177                last_feedback: None,
178                privacy: FactPrivacy::default(),
179                sensitivity: crate::core::sensitivity::classify_content(value),
180                imported_from: None,
181                archetype: KnowledgeArchetype::infer_from_category(category),
182                fidelity: None,
183                revision_count: 1,
184            });
185        }
186
187        // Run the lifecycle as soon as we exceed the configured budget.
188        // `run_lifecycle` sorts by importance and drains the excess back down to
189        // `max_facts` (archiving it), so this is self-limiting: the store settles
190        // at <= max_facts. The previous `* 2` guard let a project's facts grow to
191        // twice the cap before any eviction fired, which is why stores were
192        // observed sitting at 103% (206/200) with no reclamation.
193        if self.facts.len() > policy.knowledge.max_facts {
194            let _ = self.run_memory_lifecycle(policy);
195        }
196
197        self.updated_at = Utc::now();
198
199        let action = if contradiction.is_some() {
200            "contradict"
201        } else {
202            "remember"
203        };
204        crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
205            category: category.to_string(),
206            key: key.to_string(),
207            action: action.to_string(),
208        });
209
210        contradiction
211    }
212
213    pub fn add_pattern(
214        &mut self,
215        pattern_type: &str,
216        description: &str,
217        examples: Vec<String>,
218        session_id: &str,
219        policy: &MemoryPolicy,
220    ) {
221        if let Some(existing) = self
222            .patterns
223            .iter_mut()
224            .find(|p| p.pattern_type == pattern_type && p.description == description)
225        {
226            for ex in &examples {
227                if !existing.examples.contains(ex) {
228                    existing.examples.push(ex.clone());
229                }
230            }
231            return;
232        }
233
234        self.patterns.push(ProjectPattern {
235            pattern_type: pattern_type.to_string(),
236            description: description.to_string(),
237            examples,
238            source_session: session_id.to_string(),
239            created_at: Utc::now(),
240        });
241
242        if self.patterns.len() > policy.knowledge.max_patterns {
243            self.patterns.truncate(policy.knowledge.max_patterns);
244        }
245        self.updated_at = Utc::now();
246    }
247
248    pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
249        let before = self.facts.len();
250        self.facts
251            .retain(|f| !(f.category == category && f.key == key));
252        let removed = self.facts.len() < before;
253        if removed {
254            self.updated_at = Utc::now();
255        }
256        removed
257    }
258}