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
13/// The textual fields of a fact as the context gateway admits them for the
14/// durable store. `None`: a field is withheld or restricted (G5, E3).
15pub(super) fn admit_fact_fields<const N: usize>(
16    project_root: &str,
17    fields: [&str; N],
18) -> Option<[String; N]> {
19    let admission = super::protection::KnowledgeAdmission::capture(project_root).ok()?;
20    let mut admitted: [String; N] = std::array::from_fn(|_| String::new());
21    for (slot, field) in admitted.iter_mut().zip(fields) {
22        *slot = admission.admit_text(field)?;
23    }
24    admission.authorize_gateway().ok()?;
25    Some(admitted)
26}
27
28impl ProjectKnowledge {
29    /// The quality lifecycle (decay, merge of near-duplicates, archival of
30    /// low-confidence and stale facts). There is no count cap: knowledge grows
31    /// without limit (owner decision 24.10.). Writes never run it — a pass over
32    /// the whole store per write does not scale — the background cognition loop
33    /// does, and reports its failures.
34    pub fn run_memory_lifecycle(
35        &mut self,
36        policy: &MemoryPolicy,
37    ) -> Result<crate::core::memory_lifecycle::LifecycleReport, String> {
38        super::protection::current(&self.project_root)?;
39        let cfg = crate::core::memory_lifecycle::LifecycleConfig::from_policy(policy);
40        let before = self.facts.clone();
41        let report = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
42            std::cell::RefCell::new(Some(std::path::PathBuf::from(&self.project_root))),
43            || crate::core::memory_lifecycle::run_lifecycle(&mut self.facts, &cfg),
44        );
45        if report.is_ok() {
46            self.rebuild_index();
47        } else {
48            self.facts = before;
49        }
50        report
51    }
52
53    pub fn new(project_root: &str) -> Self {
54        Self {
55            project_root: project_root.to_string(),
56            project_hash: hash_project_root(project_root),
57            facts: Vec::new(),
58            patterns: Vec::new(),
59            history: Vec::new(),
60            updated_at: Utc::now(),
61            judged_pairs: Vec::new(),
62            index: Default::default(),
63            withheld: Vec::new(),
64        }
65    }
66
67    /// Rebuilds the transient lookup tables after loading or changing facts.
68    /// The maps are intentionally excluded from JSON serialization.
69    pub fn rebuild_index(&mut self) {
70        self.index = build_knowledge_index(&self.facts);
71    }
72
73    pub fn check_contradiction(
74        &self,
75        category: &str,
76        key: &str,
77        new_value: &str,
78        policy: &MemoryPolicy,
79    ) -> Option<Contradiction> {
80        let existing = self
81            .facts
82            .iter()
83            .find(|f| f.category == category && f.key == key && f.is_current())?;
84
85        if existing.value.to_lowercase() == new_value.to_lowercase() {
86            return None;
87        }
88
89        let similarity = string_similarity(&existing.value, new_value);
90        if similarity > 0.8 {
91            return None;
92        }
93
94        let severity = if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
95            ContradictionSeverity::High
96        } else if existing.confidence >= policy.knowledge.contradiction_threshold {
97            ContradictionSeverity::Medium
98        } else {
99            ContradictionSeverity::Low
100        };
101
102        let resolution = match severity {
103            ContradictionSeverity::High => format!(
104                "High-confidence fact [{category}/{key}] changed: '{}' -> '{new_value}' (was confirmed {}x). Previous value archived.",
105                existing.value, existing.confirmation_count
106            ),
107            ContradictionSeverity::Medium => format!(
108                "Fact [{category}/{key}] updated: '{}' -> '{new_value}'",
109                existing.value
110            ),
111            ContradictionSeverity::Low => format!(
112                "Low-confidence fact [{category}/{key}] replaced: '{}' -> '{new_value}'",
113                existing.value
114            ),
115        };
116
117        Some(Contradiction {
118            existing_key: key.to_string(),
119            existing_value: existing.value.clone(),
120            new_value: new_value.to_string(),
121            category: category.to_string(),
122            severity,
123            resolution,
124        })
125    }
126
127    pub fn remember(
128        &mut self,
129        category: &str,
130        key: &str,
131        value: &str,
132        session_id: &str,
133        confidence: f32,
134        policy: &MemoryPolicy,
135    ) -> Option<Contradiction> {
136        self.remember_with_origin(
137            category,
138            key,
139            value,
140            session_id,
141            confidence,
142            policy,
143            super::FactOrigin::Local,
144        )
145    }
146
147    pub(crate) fn remember_with_origin(
148        &mut self,
149        category: &str,
150        key: &str,
151        value: &str,
152        session_id: &str,
153        confidence: f32,
154        policy: &MemoryPolicy,
155        origin: super::FactOrigin,
156    ) -> Option<Contradiction> {
157        // #1802: the store refuses machine-derived facts while auto-capture is
158        // off, whichever ingestion shape a producer uses. `add_fact` carries the
159        // same guard; between them no producer — present or future — can put an
160        // `auto:*` fact into the durable store behind the operator's back.
161        if super::persist::is_machine_derived(category, key)
162            && !super::persist::machine_derived_writes_allowed()
163        {
164            return None;
165        }
166        // The durable store holds admitted text only, whichever producer
167        // writes it: masked values stay masked, and a fact with a withheld or
168        // restricted field is not stored at all (G5, E3).
169        let Some([category, key, value]) =
170            admit_fact_fields(&self.project_root, [category, key, value])
171        else {
172            tracing::info!("[knowledge] fact withheld by the context gateway");
173            return None;
174        };
175        let (category, key, value) = (category.as_str(), key.as_str(), value.as_str());
176        let contradiction = self.check_contradiction(category, key, value, policy);
177
178        if let Some(existing) = self.facts.iter_mut().find(|f| {
179            f.category == category && f.key == key && f.is_current() && f.origin == origin
180        }) {
181            let now = Utc::now();
182            let same_value_ci = existing.value.to_lowercase() == value.to_lowercase();
183            let similarity = string_similarity(&existing.value, value);
184
185            if existing.value == value || same_value_ci || similarity > 0.8 {
186                existing.last_confirmed = now;
187                existing.source_session = session_id.to_string();
188                existing.confidence = f32::midpoint(existing.confidence, confidence);
189                existing.confirmation_count += 1;
190                existing.revision_count += 1;
191
192                if existing.value != value && similarity > 0.8 && value.len() > existing.value.len()
193                {
194                    existing.value = value.to_string();
195                }
196            } else {
197                let superseded = fact_version_id_v1(existing);
198                let next_revision = existing.revision_count + 1;
199                // A new value of a classified fact keeps its class (AP01 §4).
200                let validity = existing.validity;
201                existing.valid_until = Some(now);
202                existing.valid_from = existing.valid_from.or(Some(existing.created_at));
203
204                self.facts.push(KnowledgeFact {
205                    anchors: Vec::new(),
206                    review: None,
207                    capture: Default::default(),
208                    origin: origin.clone(),
209                    category: category.to_string(),
210                    key: key.to_string(),
211                    value: value.to_string(),
212                    source_session: session_id.to_string(),
213                    confidence,
214                    created_at: now,
215                    last_confirmed: now,
216                    retrieval_count: 0,
217                    last_retrieved: None,
218                    valid_from: Some(now),
219                    valid_until: None,
220                    supersedes: Some(superseded),
221                    confirmation_count: 1,
222                    feedback_up: 0,
223                    feedback_down: 0,
224                    last_feedback: None,
225                    privacy: FactPrivacy::default(),
226                    sensitivity: crate::core::sensitivity::classify_content(value),
227                    imported_from: None,
228                    archetype: KnowledgeArchetype::infer_from_category(category),
229                    fidelity: None,
230                    revision_count: next_revision,
231                    status: None,
232                    validity,
233                });
234            }
235        } else {
236            let now = Utc::now();
237            self.facts.push(KnowledgeFact {
238                anchors: Vec::new(),
239                review: None,
240                capture: Default::default(),
241                origin,
242                category: category.to_string(),
243                key: key.to_string(),
244                value: value.to_string(),
245                source_session: session_id.to_string(),
246                confidence,
247                created_at: now,
248                last_confirmed: now,
249                retrieval_count: 0,
250                last_retrieved: None,
251                valid_from: Some(now),
252                valid_until: None,
253                supersedes: None,
254                confirmation_count: 1,
255                feedback_up: 0,
256                feedback_down: 0,
257                last_feedback: None,
258                privacy: FactPrivacy::default(),
259                sensitivity: crate::core::sensitivity::classify_content(value),
260                imported_from: None,
261                archetype: KnowledgeArchetype::infer_from_category(category),
262                fidelity: None,
263                revision_count: 1,
264                status: None,
265                validity: None,
266            });
267        }
268
269        self.updated_at = Utc::now();
270        self.rebuild_index();
271
272        let action = if contradiction.is_some() {
273            "contradict"
274        } else {
275            "remember"
276        };
277        crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
278            category: category.to_string(),
279            key: key.to_string(),
280            action: action.to_string(),
281        });
282
283        contradiction
284    }
285
286    /// Write-time admission gate for the agent-facing `ctx_knowledge remember`
287    /// path (#970). Unlike [`Self::remember`] (which every internal restorer also
288    /// calls), this enforces the [`crate::core::memory_policy::AdmissionPolicy`]:
289    /// near-duplicates are merged instead of inserted, and low-salience noise is
290    /// kept out of the capped store — so eviction never has to drop a good fact to
291    /// make room for a paraphrase. Internal callers (archive rehydrate, cognition
292    /// auto-promotion) keep using [`Self::remember`] and are never gated.
293    pub fn remember_admitted(
294        &mut self,
295        category: &str,
296        key: &str,
297        value: &str,
298        session_id: &str,
299        confidence: f32,
300        policy: &MemoryPolicy,
301    ) -> AdmissionResult {
302        let adm = &policy.admission;
303
304        // Admission only governs *new* facts. An existing (category,key) is a
305        // confirm/supersede the agent explicitly addressed — defer to remember().
306        let has_exact = self
307            .facts
308            .iter()
309            .any(|f| f.category == category && f.key == key && f.is_current());
310        if !adm.enabled || has_exact {
311            return AdmissionResult::Stored(
312                self.remember(category, key, value, session_id, confidence, policy),
313            );
314        }
315
316        // Salience floor: keep low-signal noise out of a capped store.
317        if adm.min_salience > 0 {
318            let salience = crate::core::memory_salience::text_salience(value);
319            if salience < adm.min_salience {
320                return AdmissionResult::RejectedLowSalience {
321                    salience,
322                    floor: adm.min_salience,
323                };
324            }
325        }
326
327        // Cross-key near-duplicate auto-merge within the same category.
328        if adm.auto_merge_similarity > 0.0
329            && let Some(merged) = self.merge_near_duplicate(
330                category,
331                value,
332                session_id,
333                confidence,
334                adm.auto_merge_similarity,
335            )
336        {
337            return merged;
338        }
339
340        AdmissionResult::Stored(self.remember(category, key, value, session_id, confidence, policy))
341    }
342
343    /// Find the best same-category, different-key, *current* near-duplicate of
344    /// `value` at/above `threshold` and merge into it (confirmation bump,
345    /// confidence midpoint, keep the longer/more-complete value). Returns the
346    /// merge outcome, or `None` when nothing qualifies. Deterministic: ties
347    /// resolve to the earliest-inserted fact (stable scan, strict `>`).
348    fn merge_near_duplicate(
349        &mut self,
350        category: &str,
351        value: &str,
352        session_id: &str,
353        confidence: f32,
354        threshold: f32,
355    ) -> Option<AdmissionResult> {
356        let mut best: Option<(usize, f32)> = None;
357        for (i, f) in self.facts.iter().enumerate() {
358            if !f.is_current() || f.category != category || f.origin != super::FactOrigin::Local {
359                continue;
360            }
361            let sim = string_similarity(value, &f.value);
362            if sim >= threshold && best.is_none_or(|(_, bs)| sim > bs) {
363                best = Some((i, sim));
364            }
365        }
366        let (idx, _) = best?;
367        let now = Utc::now();
368        let f = &mut self.facts[idx];
369        f.last_confirmed = now;
370        f.source_session = session_id.to_string();
371        f.confidence = f32::midpoint(f.confidence, confidence);
372        f.confirmation_count += 1;
373        f.revision_count += 1;
374        if value.len() > f.value.len() {
375            f.value = value.to_string();
376        }
377        let result = AdmissionResult::Merged {
378            category: f.category.clone(),
379            key: f.key.clone(),
380            confirmations: f.confirmation_count,
381            value: f.value.clone(),
382        };
383        self.rebuild_index();
384        Some(result)
385    }
386
387    pub fn add_pattern(
388        &mut self,
389        pattern_type: &str,
390        description: &str,
391        examples: Vec<String>,
392        session_id: &str,
393        policy: &MemoryPolicy,
394    ) {
395        if let Some(existing) = self
396            .patterns
397            .iter_mut()
398            .find(|p| p.pattern_type == pattern_type && p.description == description)
399        {
400            for ex in &examples {
401                if !existing.examples.contains(ex) {
402                    existing.examples.push(ex.clone());
403                }
404            }
405            return;
406        }
407
408        self.patterns.push(ProjectPattern {
409            pattern_type: pattern_type.to_string(),
410            description: description.to_string(),
411            examples,
412            source_session: session_id.to_string(),
413            created_at: Utc::now(),
414        });
415
416        // Lossless capacity reclaim (#995): keep the newest patterns and archive
417        // the rest. The previous `truncate` kept the *oldest* patterns (it dropped
418        // the just-pushed one once at the cap) and lost them permanently.
419        if let Err(error) = crate::core::memory_capacity::reclaim_store(
420            crate::core::memory_archive::MemoryStore::Patterns,
421            Some(&self.project_hash),
422            &mut self.patterns,
423            policy.knowledge.max_patterns,
424            policy.lifecycle.reclaim_headroom_pct,
425            policy.lifecycle.reclaim_enabled,
426            |a, b| {
427                b.created_at
428                    .cmp(&a.created_at)
429                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
430                    .then_with(|| a.description.cmp(&b.description))
431            },
432        ) {
433            tracing::warn!(%error, "pattern capacity reclaim failed");
434        }
435        self.updated_at = Utc::now();
436    }
437
438    pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
439        let before = self.facts.len();
440        self.facts
441            .retain(|f| !(f.category == category && f.key == key));
442        let removed = self.facts.len() < before;
443        if removed {
444            self.updated_at = Utc::now();
445            self.rebuild_index();
446        }
447        removed
448    }
449}