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