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