Skip to main content

lean_ctx/core/
knowledge.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::core::memory_boundary::FactPrivacy;
6use crate::core::memory_policy::MemoryPolicy;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ProjectKnowledge {
10    pub project_root: String,
11    pub project_hash: String,
12    pub facts: Vec<KnowledgeFact>,
13    pub patterns: Vec<ProjectPattern>,
14    pub history: Vec<ConsolidatedInsight>,
15    pub updated_at: DateTime<Utc>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct KnowledgeFact {
20    pub category: String,
21    pub key: String,
22    pub value: String,
23    pub source_session: String,
24    pub confidence: f32,
25    pub created_at: DateTime<Utc>,
26    pub last_confirmed: DateTime<Utc>,
27    #[serde(default)]
28    pub retrieval_count: u32,
29    #[serde(default)]
30    pub last_retrieved: Option<DateTime<Utc>>,
31    #[serde(default)]
32    pub valid_from: Option<DateTime<Utc>>,
33    #[serde(default)]
34    pub valid_until: Option<DateTime<Utc>>,
35    #[serde(default)]
36    pub supersedes: Option<String>,
37    #[serde(default)]
38    pub confirmation_count: u32,
39    #[serde(default)]
40    pub feedback_up: u32,
41    #[serde(default)]
42    pub feedback_down: u32,
43    #[serde(default)]
44    pub last_feedback: Option<DateTime<Utc>>,
45    #[serde(default)]
46    pub privacy: FactPrivacy,
47    #[serde(default)]
48    pub imported_from: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Contradiction {
53    pub existing_key: String,
54    pub existing_value: String,
55    pub new_value: String,
56    pub category: String,
57    pub severity: ContradictionSeverity,
58    pub resolution: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub enum ContradictionSeverity {
63    Low,
64    Medium,
65    High,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ProjectPattern {
70    pub pattern_type: String,
71    pub description: String,
72    pub examples: Vec<String>,
73    pub source_session: String,
74    pub created_at: DateTime<Utc>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ConsolidatedInsight {
79    pub summary: String,
80    pub from_sessions: Vec<String>,
81    pub timestamp: DateTime<Utc>,
82}
83
84impl ProjectKnowledge {
85    pub fn run_memory_lifecycle(
86        &mut self,
87        policy: &MemoryPolicy,
88    ) -> crate::core::memory_lifecycle::LifecycleReport {
89        let cfg = crate::core::memory_lifecycle::LifecycleConfig {
90            max_facts: policy.knowledge.max_facts,
91            decay_rate_per_day: policy.lifecycle.decay_rate,
92            low_confidence_threshold: policy.lifecycle.low_confidence_threshold,
93            stale_days: policy.lifecycle.stale_days,
94            consolidation_similarity: policy.lifecycle.similarity_threshold,
95        };
96        crate::core::memory_lifecycle::run_lifecycle(&mut self.facts, &cfg)
97    }
98
99    pub fn new(project_root: &str) -> Self {
100        Self {
101            project_root: project_root.to_string(),
102            project_hash: hash_project_root(project_root),
103            facts: Vec::new(),
104            patterns: Vec::new(),
105            history: Vec::new(),
106            updated_at: Utc::now(),
107        }
108    }
109
110    pub fn check_contradiction(
111        &self,
112        category: &str,
113        key: &str,
114        new_value: &str,
115        policy: &MemoryPolicy,
116    ) -> Option<Contradiction> {
117        let existing = self
118            .facts
119            .iter()
120            .find(|f| f.category == category && f.key == key && f.is_current())?;
121
122        if existing.value.to_lowercase() == new_value.to_lowercase() {
123            return None;
124        }
125
126        let similarity = string_similarity(&existing.value, new_value);
127        if similarity > 0.8 {
128            return None;
129        }
130
131        let severity = if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
132            ContradictionSeverity::High
133        } else if existing.confidence >= policy.knowledge.contradiction_threshold {
134            ContradictionSeverity::Medium
135        } else {
136            ContradictionSeverity::Low
137        };
138
139        let resolution = match severity {
140            ContradictionSeverity::High => format!(
141                "High-confidence fact [{category}/{key}] changed: '{}' -> '{new_value}' (was confirmed {}x). Previous value archived.",
142                existing.value, existing.confirmation_count
143            ),
144            ContradictionSeverity::Medium => format!(
145                "Fact [{category}/{key}] updated: '{}' -> '{new_value}'",
146                existing.value
147            ),
148            ContradictionSeverity::Low => format!(
149                "Low-confidence fact [{category}/{key}] replaced: '{}' -> '{new_value}'",
150                existing.value
151            ),
152        };
153
154        Some(Contradiction {
155            existing_key: key.to_string(),
156            existing_value: existing.value.clone(),
157            new_value: new_value.to_string(),
158            category: category.to_string(),
159            severity,
160            resolution,
161        })
162    }
163
164    pub fn remember(
165        &mut self,
166        category: &str,
167        key: &str,
168        value: &str,
169        session_id: &str,
170        confidence: f32,
171        policy: &MemoryPolicy,
172    ) -> Option<Contradiction> {
173        let contradiction = self.check_contradiction(category, key, value, policy);
174
175        if let Some(existing) = self
176            .facts
177            .iter_mut()
178            .find(|f| f.category == category && f.key == key && f.is_current())
179        {
180            let now = Utc::now();
181            let same_value_ci = existing.value.to_lowercase() == value.to_lowercase();
182            let similarity = string_similarity(&existing.value, value);
183
184            if existing.value == value || same_value_ci || similarity > 0.8 {
185                existing.last_confirmed = now;
186                existing.source_session = session_id.to_string();
187                existing.confidence = f32::midpoint(existing.confidence, confidence);
188                existing.confirmation_count += 1;
189
190                if existing.value != value && similarity > 0.8 && value.len() > existing.value.len()
191                {
192                    // Prefer the more informative value when semantically equivalent.
193                    existing.value = value.to_string();
194                }
195            } else {
196                let superseded = fact_version_id_v1(existing);
197                existing.valid_until = Some(now);
198                existing.valid_from = existing.valid_from.or(Some(existing.created_at));
199
200                self.facts.push(KnowledgeFact {
201                    category: category.to_string(),
202                    key: key.to_string(),
203                    value: value.to_string(),
204                    source_session: session_id.to_string(),
205                    confidence,
206                    created_at: now,
207                    last_confirmed: now,
208                    retrieval_count: 0,
209                    last_retrieved: None,
210                    valid_from: Some(now),
211                    valid_until: None,
212                    supersedes: Some(superseded),
213                    confirmation_count: 1,
214                    feedback_up: 0,
215                    feedback_down: 0,
216                    last_feedback: None,
217                    privacy: FactPrivacy::default(),
218                    imported_from: None,
219                });
220            }
221        } else {
222            let now = Utc::now();
223            self.facts.push(KnowledgeFact {
224                category: category.to_string(),
225                key: key.to_string(),
226                value: value.to_string(),
227                source_session: session_id.to_string(),
228                confidence,
229                created_at: now,
230                last_confirmed: now,
231                retrieval_count: 0,
232                last_retrieved: None,
233                valid_from: Some(now),
234                valid_until: None,
235                supersedes: None,
236                confirmation_count: 1,
237                feedback_up: 0,
238                feedback_down: 0,
239                last_feedback: None,
240                privacy: FactPrivacy::default(),
241                imported_from: None,
242            });
243        }
244
245        // No hard-prune: archive-only lifecycle will compact if needed.
246        if self.facts.len() > policy.knowledge.max_facts.saturating_mul(2) {
247            let _ = self.run_memory_lifecycle(policy);
248        }
249
250        self.updated_at = Utc::now();
251
252        let action = if contradiction.is_some() {
253            "contradict"
254        } else {
255            "remember"
256        };
257        crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
258            category: category.to_string(),
259            key: key.to_string(),
260            action: action.to_string(),
261        });
262
263        contradiction
264    }
265
266    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
267        let q = query.to_lowercase();
268        let terms: Vec<&str> = q.split_whitespace().collect();
269        if terms.is_empty() {
270            return Vec::new();
271        }
272
273        let index = build_token_index(&self.facts, true);
274        let mut match_counts: std::collections::HashMap<usize, usize> =
275            std::collections::HashMap::new();
276        for term in &terms {
277            if let Some(indices) = index.get(*term) {
278                for &idx in indices {
279                    if self.facts[idx].is_current() {
280                        *match_counts.entry(idx).or_insert(0) += 1;
281                    }
282                }
283            }
284        }
285
286        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
287            .into_iter()
288            .map(|(idx, count)| {
289                let f = &self.facts[idx];
290                let relevance = (count as f32 / terms.len() as f32) * f.quality_score();
291                (f, relevance)
292            })
293            .collect();
294
295        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
296        results.into_iter().map(|(f, _)| f).collect()
297    }
298
299    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
300        self.facts
301            .iter()
302            .filter(|f| f.category == category && f.is_current())
303            .collect()
304    }
305
306    pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
307        let q = query.to_lowercase();
308        let terms: Vec<&str> = q.split_whitespace().collect();
309        if terms.is_empty() {
310            return Vec::new();
311        }
312
313        let index = build_token_index(&self.facts, false);
314        let mut match_counts: std::collections::HashMap<usize, usize> =
315            std::collections::HashMap::new();
316        for term in &terms {
317            if let Some(indices) = index.get(*term) {
318                for &idx in indices {
319                    if self.facts[idx].was_valid_at(at) {
320                        *match_counts.entry(idx).or_insert(0) += 1;
321                    }
322                }
323            }
324        }
325
326        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
327            .into_iter()
328            .map(|(idx, count)| {
329                let f = &self.facts[idx];
330                (f, count as f32 / terms.len() as f32)
331            })
332            .collect();
333
334        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
335        results.into_iter().map(|(f, _)| f).collect()
336    }
337
338    pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
339        let mut facts: Vec<&KnowledgeFact> = self
340            .facts
341            .iter()
342            .filter(|f| f.category == category)
343            .collect();
344        facts.sort_by_key(|x| x.created_at);
345        facts
346    }
347
348    pub fn list_rooms(&self) -> Vec<(String, usize)> {
349        let mut categories: std::collections::BTreeMap<String, usize> =
350            std::collections::BTreeMap::new();
351        for f in &self.facts {
352            if f.is_current() {
353                *categories.entry(f.category.clone()).or_insert(0) += 1;
354            }
355        }
356        categories.into_iter().collect()
357    }
358
359    pub fn add_pattern(
360        &mut self,
361        pattern_type: &str,
362        description: &str,
363        examples: Vec<String>,
364        session_id: &str,
365        policy: &MemoryPolicy,
366    ) {
367        if let Some(existing) = self
368            .patterns
369            .iter_mut()
370            .find(|p| p.pattern_type == pattern_type && p.description == description)
371        {
372            for ex in &examples {
373                if !existing.examples.contains(ex) {
374                    existing.examples.push(ex.clone());
375                }
376            }
377            return;
378        }
379
380        self.patterns.push(ProjectPattern {
381            pattern_type: pattern_type.to_string(),
382            description: description.to_string(),
383            examples,
384            source_session: session_id.to_string(),
385            created_at: Utc::now(),
386        });
387
388        if self.patterns.len() > policy.knowledge.max_patterns {
389            self.patterns.truncate(policy.knowledge.max_patterns);
390        }
391        self.updated_at = Utc::now();
392    }
393
394    pub fn consolidate(&mut self, summary: &str, session_ids: Vec<String>, policy: &MemoryPolicy) {
395        self.history.push(ConsolidatedInsight {
396            summary: summary.to_string(),
397            from_sessions: session_ids,
398            timestamp: Utc::now(),
399        });
400
401        if self.history.len() > policy.knowledge.max_history {
402            self.history
403                .drain(0..self.history.len() - policy.knowledge.max_history);
404        }
405        self.updated_at = Utc::now();
406    }
407
408    /// Import facts from an external source with a configurable merge strategy.
409    /// Returns (added, skipped, replaced) counts.
410    pub fn import_facts(
411        &mut self,
412        incoming: Vec<KnowledgeFact>,
413        merge: ImportMerge,
414        session_id: &str,
415        policy: &MemoryPolicy,
416    ) -> ImportResult {
417        let mut added = 0u32;
418        let mut skipped = 0u32;
419        let mut replaced = 0u32;
420
421        for fact in incoming {
422            let existing = self
423                .facts
424                .iter()
425                .position(|f| f.category == fact.category && f.key == fact.key && f.is_current());
426
427            match (&merge, existing) {
428                (ImportMerge::SkipExisting, Some(_)) => {
429                    skipped += 1;
430                }
431                (ImportMerge::Replace, Some(idx)) => {
432                    self.facts[idx].valid_until = Some(Utc::now());
433                    self.facts.push(imported_fact(&fact, session_id));
434                    replaced += 1;
435                }
436                (ImportMerge::Append, Some(_)) | (_, None) => {
437                    self.facts.push(imported_fact(&fact, session_id));
438                    added += 1;
439                }
440            }
441        }
442
443        if added > 0 || replaced > 0 {
444            self.updated_at = Utc::now();
445            if self.facts.len() > policy.knowledge.max_facts.saturating_mul(2) {
446                let _ = self.run_memory_lifecycle(policy);
447            }
448        }
449
450        ImportResult {
451            added,
452            skipped,
453            replaced,
454        }
455    }
456
457    /// Export current facts as a simple JSON array (community-compatible schema).
458    pub fn export_simple(&self) -> Vec<SimpleFactEntry> {
459        self.facts
460            .iter()
461            .filter(|f| f.is_current())
462            .map(|f| SimpleFactEntry {
463                category: f.category.clone(),
464                key: f.key.clone(),
465                value: f.value.clone(),
466                confidence: Some(f.confidence),
467                source: Some(f.source_session.clone()),
468                timestamp: Some(f.created_at.to_rfc3339()),
469            })
470            .collect()
471    }
472
473    pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
474        let before = self.facts.len();
475        self.facts
476            .retain(|f| !(f.category == category && f.key == key));
477        let removed = self.facts.len() < before;
478        if removed {
479            self.updated_at = Utc::now();
480        }
481        removed
482    }
483
484    pub fn format_summary(&self) -> String {
485        let mut out = String::new();
486        let current_facts: Vec<&KnowledgeFact> =
487            self.facts.iter().filter(|f| f.is_current()).collect();
488
489        if !current_facts.is_empty() {
490            out.push_str("PROJECT KNOWLEDGE:\n");
491            let mut rooms: Vec<(String, usize)> = self.list_rooms();
492            rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
493
494            let total_rooms = rooms.len();
495            rooms.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT);
496
497            for (cat, _count) in rooms {
498                out.push_str(&format!("  [{cat}]\n"));
499
500                let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
501                    .iter()
502                    .copied()
503                    .filter(|f| f.category == cat)
504                    .collect();
505                facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
506
507                let total_in_cat = facts_in_cat.len();
508                facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT);
509
510                for f in facts_in_cat {
511                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
512                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
513                    out.push_str(&format!(
514                        "    {}: {} (confidence: {:.0}%)\n",
515                        key,
516                        val,
517                        f.confidence * 100.0
518                    ));
519                }
520                if total_in_cat > crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT {
521                    out.push_str(&format!(
522                        "    … +{} more\n",
523                        total_in_cat - crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT
524                    ));
525                }
526            }
527
528            if total_rooms > crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT {
529                out.push_str(&format!(
530                    "  … +{} more rooms\n",
531                    total_rooms - crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT
532                ));
533            }
534        }
535
536        if !self.patterns.is_empty() {
537            out.push_str("PROJECT PATTERNS:\n");
538            let mut patterns = self.patterns.clone();
539            patterns.sort_by(|a, b| {
540                b.created_at
541                    .cmp(&a.created_at)
542                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
543                    .then_with(|| a.description.cmp(&b.description))
544            });
545            let total = patterns.len();
546            patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
547            for p in &patterns {
548                let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
549                let desc = crate::core::sanitize::neutralize_metadata(&p.description);
550                out.push_str(&format!("  [{ty}] {desc}\n"));
551            }
552            if total > crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT {
553                out.push_str(&format!(
554                    "  … +{} more\n",
555                    total - crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT
556                ));
557            }
558        }
559
560        if out.is_empty() {
561            out
562        } else {
563            crate::core::sanitize::fence_content("project_knowledge", out.trim_end())
564        }
565    }
566
567    pub fn format_aaak(&self) -> String {
568        let current_facts: Vec<&KnowledgeFact> =
569            self.facts.iter().filter(|f| f.is_current()).collect();
570
571        if current_facts.is_empty() && self.patterns.is_empty() {
572            return String::new();
573        }
574
575        let mut out = String::new();
576
577        let mut rooms: Vec<(String, usize)> = self.list_rooms();
578        rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
579        rooms.truncate(crate::core::budgets::KNOWLEDGE_AAAK_ROOMS_LIMIT);
580
581        for (cat, _count) in rooms {
582            let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
583                .iter()
584                .copied()
585                .filter(|f| f.category == cat)
586                .collect();
587            facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
588            facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_AAAK_FACTS_PER_ROOM_LIMIT);
589
590            let items: Vec<String> = facts_in_cat
591                .iter()
592                .map(|f| {
593                    let stars = confidence_stars(f.confidence);
594                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
595                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
596                    format!("{key}={val}{stars}")
597                })
598                .collect();
599            out.push_str(&format!(
600                "{}:{}\n",
601                crate::core::sanitize::neutralize_metadata(&cat.to_uppercase()),
602                items.join("|")
603            ));
604        }
605
606        if !self.patterns.is_empty() {
607            let mut patterns = self.patterns.clone();
608            patterns.sort_by(|a, b| {
609                b.created_at
610                    .cmp(&a.created_at)
611                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
612                    .then_with(|| a.description.cmp(&b.description))
613            });
614            patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
615            let pat_items: Vec<String> = patterns
616                .iter()
617                .map(|p| {
618                    let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
619                    let desc = crate::core::sanitize::neutralize_metadata(&p.description);
620                    format!("{ty}.{desc}")
621                })
622                .collect();
623            out.push_str(&format!("PAT:{}\n", pat_items.join("|")));
624        }
625
626        if out.is_empty() {
627            out
628        } else {
629            crate::core::sanitize::fence_content("project_memory_aaak", out.trim_end())
630        }
631    }
632
633    pub fn format_wakeup(&self) -> String {
634        let current_facts: Vec<&KnowledgeFact> = self
635            .facts
636            .iter()
637            .filter(|f| f.is_current() && f.confidence >= 0.7)
638            .collect();
639
640        if current_facts.is_empty() {
641            return String::new();
642        }
643
644        let mut top_facts: Vec<&KnowledgeFact> = current_facts;
645        top_facts.sort_by(|a, b| sort_fact_for_output(a, b));
646        top_facts.truncate(10);
647
648        let items: Vec<String> = top_facts
649            .iter()
650            .map(|f| {
651                let cat = crate::core::sanitize::neutralize_metadata(&f.category);
652                let key = crate::core::sanitize::neutralize_metadata(&f.key);
653                let val = crate::core::sanitize::neutralize_metadata(&f.value);
654                format!("{cat}/{key}={val}")
655            })
656            .collect();
657
658        crate::core::sanitize::fence_content(
659            "project_facts_wakeup",
660            &format!("FACTS:{}", items.join("|")),
661        )
662    }
663
664    pub fn save(&self) -> Result<(), String> {
665        let dir = knowledge_dir(&self.project_hash)?;
666        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
667
668        let path = dir.join("knowledge.json");
669        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
670        std::fs::write(&path, json).map_err(|e| e.to_string())
671    }
672
673    pub fn load(project_root: &str) -> Option<Self> {
674        let hash = hash_project_root(project_root);
675        let dir = knowledge_dir(&hash).ok()?;
676        let path = dir.join("knowledge.json");
677
678        if let Ok(content) = std::fs::read_to_string(&path) {
679            if let Ok(k) = serde_json::from_str::<Self>(&content) {
680                return Some(k);
681            }
682        }
683
684        let old_hash = crate::core::project_hash::hash_path_only(project_root);
685        if old_hash != hash {
686            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
687            if let Ok(content) = std::fs::read_to_string(&path) {
688                if let Ok(mut k) = serde_json::from_str::<Self>(&content) {
689                    k.project_hash = hash;
690                    let _ = k.save();
691                    return Some(k);
692                }
693            }
694        }
695
696        None
697    }
698
699    pub fn load_or_create(project_root: &str) -> Self {
700        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
701    }
702
703    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
704    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
705    pub fn migrate_legacy_empty_root(
706        target_root: &str,
707        policy: &MemoryPolicy,
708    ) -> Result<bool, String> {
709        if target_root.trim().is_empty() {
710            return Ok(false);
711        }
712
713        let Some(legacy) = Self::load("") else {
714            return Ok(false);
715        };
716
717        if !legacy.project_root.trim().is_empty() {
718            return Ok(false);
719        }
720        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
721            return Ok(false);
722        }
723
724        let mut target = Self::load_or_create(target_root);
725
726        fn fact_key(f: &KnowledgeFact) -> String {
727            format!(
728                "{}|{}|{}|{}|{}",
729                f.category, f.key, f.value, f.source_session, f.created_at
730            )
731        }
732        fn pattern_key(p: &ProjectPattern) -> String {
733            format!(
734                "{}|{}|{}|{}",
735                p.pattern_type, p.description, p.source_session, p.created_at
736            )
737        }
738        fn history_key(h: &ConsolidatedInsight) -> String {
739            format!(
740                "{}|{}|{}",
741                h.summary,
742                h.from_sessions.join(","),
743                h.timestamp
744            )
745        }
746
747        let mut seen_facts: std::collections::HashSet<String> =
748            target.facts.iter().map(fact_key).collect();
749        for f in legacy.facts {
750            if seen_facts.insert(fact_key(&f)) {
751                target.facts.push(f);
752            }
753        }
754
755        let mut seen_patterns: std::collections::HashSet<String> =
756            target.patterns.iter().map(pattern_key).collect();
757        for p in legacy.patterns {
758            if seen_patterns.insert(pattern_key(&p)) {
759                target.patterns.push(p);
760            }
761        }
762
763        let mut seen_history: std::collections::HashSet<String> =
764            target.history.iter().map(history_key).collect();
765        for h in legacy.history {
766            if seen_history.insert(history_key(&h)) {
767                target.history.push(h);
768            }
769        }
770
771        // Enforce caps to avoid unbounded growth from migration.
772        target.facts.sort_by(|a, b| {
773            b.created_at
774                .cmp(&a.created_at)
775                .then_with(|| b.confidence.total_cmp(&a.confidence))
776        });
777        if target.facts.len() > policy.knowledge.max_facts {
778            target.facts.truncate(policy.knowledge.max_facts);
779        }
780        target
781            .patterns
782            .sort_by_key(|x| std::cmp::Reverse(x.created_at));
783        if target.patterns.len() > policy.knowledge.max_patterns {
784            target.patterns.truncate(policy.knowledge.max_patterns);
785        }
786        target
787            .history
788            .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
789        if target.history.len() > policy.knowledge.max_history {
790            target.history.truncate(policy.knowledge.max_history);
791        }
792
793        target.updated_at = Utc::now();
794        target.save()?;
795
796        let legacy_hash = crate::core::project_hash::hash_path_only("");
797        let legacy_dir = knowledge_dir(&legacy_hash)?;
798        let legacy_path = legacy_dir.join("knowledge.json");
799        if legacy_path.exists() {
800            let ts = Utc::now().format("%Y%m%d-%H%M%S");
801            let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
802            std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
803        }
804
805        Ok(true)
806    }
807
808    pub fn recall_for_output(&mut self, query: &str, limit: usize) -> (Vec<KnowledgeFact>, usize) {
809        let q = query.to_lowercase();
810        let terms: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect();
811        if terms.is_empty() {
812            return (Vec::new(), 0);
813        }
814
815        let index = build_token_index(&self.facts, true);
816        let mut match_counts: std::collections::HashMap<usize, usize> =
817            std::collections::HashMap::new();
818        for term in &terms {
819            if let Some(indices) = index.get(*term) {
820                for &idx in indices {
821                    if self.facts[idx].is_current() {
822                        *match_counts.entry(idx).or_insert(0) += 1;
823                    }
824                }
825            }
826        }
827
828        struct Scored {
829            idx: usize,
830            relevance: f32,
831        }
832
833        let mut scored: Vec<Scored> = match_counts
834            .into_iter()
835            .map(|(idx, count)| {
836                let f = &self.facts[idx];
837                let relevance = (count as f32 / terms.len() as f32) * f.confidence;
838                Scored { idx, relevance }
839            })
840            .collect();
841
842        scored.sort_by(|a, b| {
843            b.relevance
844                .partial_cmp(&a.relevance)
845                .unwrap_or(std::cmp::Ordering::Equal)
846                .then_with(|| sort_fact_for_output(&self.facts[a.idx], &self.facts[b.idx]))
847        });
848
849        let total = scored.len();
850        scored.truncate(limit);
851
852        let now = Utc::now();
853        let mut out: Vec<KnowledgeFact> = Vec::new();
854        for s in scored {
855            if let Some(f) = self.facts.get_mut(s.idx) {
856                f.retrieval_count = f.retrieval_count.saturating_add(1);
857                f.last_retrieved = Some(now);
858                out.push(f.clone());
859            }
860        }
861
862        (out, total)
863    }
864
865    pub fn recall_by_category_for_output(
866        &mut self,
867        category: &str,
868        limit: usize,
869    ) -> (Vec<KnowledgeFact>, usize) {
870        let mut idxs: Vec<usize> = self
871            .facts
872            .iter()
873            .enumerate()
874            .filter(|(_, f)| f.is_current() && f.category == category)
875            .map(|(i, _)| i)
876            .collect();
877
878        idxs.sort_by(|a, b| sort_fact_for_output(&self.facts[*a], &self.facts[*b]));
879
880        let total = idxs.len();
881        idxs.truncate(limit);
882
883        let now = Utc::now();
884        let mut out = Vec::new();
885        for idx in idxs {
886            if let Some(f) = self.facts.get_mut(idx) {
887                f.retrieval_count = f.retrieval_count.saturating_add(1);
888                f.last_retrieved = Some(now);
889                out.push(f.clone());
890            }
891        }
892
893        (out, total)
894    }
895}
896
897impl KnowledgeFact {
898    pub fn is_current(&self) -> bool {
899        self.valid_until.is_none()
900    }
901
902    /// Stable, intrinsic quality metric (0.0..1.0).
903    ///
904    /// Based only on confidence, confirmation count, and feedback balance.
905    /// Deliberately excludes volatile signals (retrieval count, recency) to
906    /// keep recall output deterministic. For display ordering use
907    /// `salience_score()` which adds recency and category weighting.
908    pub fn quality_score(&self) -> f32 {
909        let confidence = self.confidence.clamp(0.0, 1.0);
910        let confirmations_norm = (self.confirmation_count.min(5) as f32) / 5.0;
911        let balance = self.feedback_up as i32 - self.feedback_down as i32;
912        let feedback_effect = (balance as f32 / 4.0).tanh() * 0.1;
913
914        // IMPORTANT: quality_score must be stable across repeated recall calls.
915        // Retrieval signals (retrieval_count/last_retrieved) are persisted, but should not change
916        // the displayed "quality" score, otherwise recall output becomes non-deterministic.
917        (0.8 * confidence + 0.2 * confirmations_norm + feedback_effect).clamp(0.0, 1.0)
918    }
919
920    pub fn was_valid_at(&self, at: DateTime<Utc>) -> bool {
921        let after_start = self.valid_from.is_none_or(|from| at >= from);
922        let before_end = self.valid_until.is_none_or(|until| at <= until);
923        after_start && before_end
924    }
925}
926
927fn confidence_stars(confidence: f32) -> &'static str {
928    if confidence >= 0.95 {
929        "★★★★★"
930    } else if confidence >= 0.85 {
931        "★★★★"
932    } else if confidence >= 0.7 {
933        "★★★"
934    } else if confidence >= 0.5 {
935        "★★"
936    } else {
937        "★"
938    }
939}
940
941fn string_similarity(a: &str, b: &str) -> f32 {
942    let a_lower = a.to_lowercase();
943    let b_lower = b.to_lowercase();
944    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
945    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
946
947    if a_words.is_empty() && b_words.is_empty() {
948        return 1.0;
949    }
950
951    let intersection = a_words.intersection(&b_words).count();
952    let union = a_words.union(&b_words).count();
953
954    if union == 0 {
955        return 0.0;
956    }
957
958    intersection as f32 / union as f32
959}
960
961fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
962    Ok(crate::core::data_dir::lean_ctx_data_dir()?
963        .join("knowledge")
964        .join(project_hash))
965}
966
967fn sort_fact_for_output(a: &KnowledgeFact, b: &KnowledgeFact) -> std::cmp::Ordering {
968    salience_score(b)
969        .cmp(&salience_score(a))
970        .then_with(|| {
971            b.quality_score()
972                .partial_cmp(&a.quality_score())
973                .unwrap_or(std::cmp::Ordering::Equal)
974        })
975        .then_with(|| {
976            b.confidence
977                .partial_cmp(&a.confidence)
978                .unwrap_or(std::cmp::Ordering::Equal)
979        })
980        .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
981        .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
982        .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
983        .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
984        .then_with(|| a.category.cmp(&b.category))
985        .then_with(|| a.key.cmp(&b.key))
986        .then_with(|| a.value.cmp(&b.value))
987}
988
989/// Salience-based ranking for fact output ordering.
990///
991/// Unlike `quality_score()` (which is a stable, intrinsic measure of fact
992/// reliability based on confidence, confirmations, and feedback), salience
993/// combines category priority, quality, recency, and retrieval frequency
994/// into a single sort key for _display_ ordering. Salience is volatile and
995/// changes on every access; quality_score is deterministic and stable.
996fn salience_score(f: &KnowledgeFact) -> u32 {
997    let cat = f.category.to_lowercase();
998    let base: u32 = match cat.as_str() {
999        "decision" => 70,
1000        "gotcha" => 75,
1001        "architecture" | "arch" => 60,
1002        "security" => 65,
1003        "testing" | "tests" | "deployment" | "deploy" => 55,
1004        "conventions" | "convention" => 45,
1005        "finding" => 40,
1006        _ => 30,
1007    };
1008
1009    let quality_bonus = (f.quality_score() * 60.0) as u32;
1010
1011    let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
1012        let days = Utc::now().signed_duration_since(t).num_days();
1013        if days <= 7 {
1014            10u32
1015        } else if days <= 30 {
1016            5u32
1017        } else {
1018            0u32
1019        }
1020    });
1021
1022    base + quality_bonus + recency_bonus
1023}
1024
1025fn hash_project_root(root: &str) -> String {
1026    crate::core::project_hash::hash_project_root(root)
1027}
1028
1029fn tokenize_lower(s: &str) -> impl Iterator<Item = String> + '_ {
1030    s.to_lowercase()
1031        .split(|c: char| c.is_whitespace() || c == '-' || c == '_' || c == '/' || c == '.')
1032        .filter(|t| !t.is_empty())
1033        .map(String::from)
1034        .collect::<Vec<_>>()
1035        .into_iter()
1036}
1037
1038fn build_token_index(
1039    facts: &[KnowledgeFact],
1040    include_session: bool,
1041) -> std::collections::HashMap<String, Vec<usize>> {
1042    let mut index: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
1043    for (i, f) in facts.iter().enumerate() {
1044        for token in tokenize_lower(&f.category) {
1045            index.entry(token).or_default().push(i);
1046        }
1047        for token in tokenize_lower(&f.key) {
1048            index.entry(token).or_default().push(i);
1049        }
1050        for token in tokenize_lower(&f.value) {
1051            index.entry(token).or_default().push(i);
1052        }
1053        if include_session {
1054            for token in tokenize_lower(&f.source_session) {
1055                index.entry(token).or_default().push(i);
1056            }
1057        }
1058    }
1059    for indices in index.values_mut() {
1060        indices.sort_unstable();
1061        indices.dedup();
1062    }
1063    index
1064}
1065
1066fn fact_version_id_v1(f: &KnowledgeFact) -> String {
1067    use md5::{Digest, Md5};
1068    let mut hasher = Md5::new();
1069    hasher.update(f.category.as_bytes());
1070    hasher.update(b"\n");
1071    hasher.update(f.key.as_bytes());
1072    hasher.update(b"\n");
1073    hasher.update(f.value.as_bytes());
1074    hasher.update(b"\n");
1075    hasher.update(f.source_session.as_bytes());
1076    hasher.update(b"\n");
1077    hasher.update(f.created_at.to_rfc3339().as_bytes());
1078    format!("{:x}", hasher.finalize())
1079}
1080
1081// ─── Import / Export types ───────────────────────────────────────────────────
1082
1083#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1084pub enum ImportMerge {
1085    Replace,
1086    Append,
1087    SkipExisting,
1088}
1089
1090impl ImportMerge {
1091    pub fn parse(s: &str) -> Option<Self> {
1092        match s.to_lowercase().as_str() {
1093            "replace" => Some(Self::Replace),
1094            "append" => Some(Self::Append),
1095            "skip-existing" | "skip_existing" | "skip" => Some(Self::SkipExisting),
1096            _ => None,
1097        }
1098    }
1099}
1100
1101#[derive(Debug, Clone)]
1102pub struct ImportResult {
1103    pub added: u32,
1104    pub skipped: u32,
1105    pub replaced: u32,
1106}
1107
1108/// Community-compatible simple fact format for import/export interop.
1109#[derive(Debug, Clone, Serialize, Deserialize)]
1110pub struct SimpleFactEntry {
1111    pub category: String,
1112    pub key: String,
1113    pub value: String,
1114    #[serde(default)]
1115    pub confidence: Option<f32>,
1116    #[serde(default)]
1117    pub source: Option<String>,
1118    #[serde(default)]
1119    pub timestamp: Option<String>,
1120}
1121
1122/// Parse import data: tries native `ProjectKnowledge` first, then simple `[{...}]` array.
1123pub fn parse_import_data(data: &str) -> Result<Vec<KnowledgeFact>, String> {
1124    if let Ok(pk) = serde_json::from_str::<ProjectKnowledge>(data) {
1125        return Ok(pk.facts);
1126    }
1127
1128    if let Ok(entries) = serde_json::from_str::<Vec<SimpleFactEntry>>(data) {
1129        let now = Utc::now();
1130        let facts = entries
1131            .into_iter()
1132            .map(|e| KnowledgeFact {
1133                category: e.category,
1134                key: e.key,
1135                value: e.value,
1136                source_session: e.source.unwrap_or_else(|| "import".to_string()),
1137                confidence: e.confidence.unwrap_or(0.8),
1138                created_at: now,
1139                last_confirmed: now,
1140                retrieval_count: 0,
1141                last_retrieved: None,
1142                valid_from: Some(now),
1143                valid_until: None,
1144                supersedes: None,
1145                confirmation_count: 1,
1146                feedback_up: 0,
1147                feedback_down: 0,
1148                last_feedback: None,
1149                privacy: FactPrivacy::default(),
1150                imported_from: None,
1151            })
1152            .collect();
1153        return Ok(facts);
1154    }
1155
1156    // Try JSONL (one JSON object per line)
1157    let mut facts = Vec::new();
1158    for line in data.lines() {
1159        let line = line.trim();
1160        if line.is_empty() {
1161            continue;
1162        }
1163        if let Ok(entry) = serde_json::from_str::<SimpleFactEntry>(line) {
1164            let now = Utc::now();
1165            facts.push(KnowledgeFact {
1166                category: entry.category,
1167                key: entry.key,
1168                value: entry.value,
1169                source_session: entry.source.unwrap_or_else(|| "import".to_string()),
1170                confidence: entry.confidence.unwrap_or(0.8),
1171                created_at: now,
1172                last_confirmed: now,
1173                retrieval_count: 0,
1174                last_retrieved: None,
1175                valid_from: Some(now),
1176                valid_until: None,
1177                supersedes: None,
1178                confirmation_count: 1,
1179                feedback_up: 0,
1180                feedback_down: 0,
1181                last_feedback: None,
1182                privacy: FactPrivacy::default(),
1183                imported_from: None,
1184            });
1185        } else {
1186            return Err(format!(
1187                "Invalid JSONL line: {}",
1188                &line[..line.len().min(80)]
1189            ));
1190        }
1191    }
1192
1193    if facts.is_empty() {
1194        return Err("No facts found. Expected: native JSON, simple JSON array, or JSONL.".into());
1195    }
1196    Ok(facts)
1197}
1198
1199fn imported_fact(source: &KnowledgeFact, session_id: &str) -> KnowledgeFact {
1200    let now = Utc::now();
1201    KnowledgeFact {
1202        category: source.category.clone(),
1203        key: source.key.clone(),
1204        value: source.value.clone(),
1205        source_session: session_id.to_string(),
1206        confidence: source.confidence,
1207        created_at: now,
1208        last_confirmed: now,
1209        retrieval_count: 0,
1210        last_retrieved: None,
1211        valid_from: Some(now),
1212        valid_until: None,
1213        supersedes: None,
1214        confirmation_count: 1,
1215        feedback_up: 0,
1216        feedback_down: 0,
1217        last_feedback: None,
1218        privacy: source.privacy,
1219        imported_from: source.imported_from.clone(),
1220    }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    fn default_policy() -> MemoryPolicy {
1228        MemoryPolicy::default()
1229    }
1230
1231    #[test]
1232    fn remember_and_recall() {
1233        let policy = default_policy();
1234        let mut k = ProjectKnowledge::new("/tmp/test-project");
1235        k.remember(
1236            "architecture",
1237            "auth",
1238            "JWT RS256",
1239            "session-1",
1240            0.9,
1241            &policy,
1242        );
1243        k.remember("api", "rate-limit", "100/min", "session-1", 0.8, &policy);
1244
1245        let results = k.recall("auth");
1246        assert_eq!(results.len(), 1);
1247        assert_eq!(results[0].value, "JWT RS256");
1248
1249        let results = k.recall("api rate");
1250        assert_eq!(results.len(), 1);
1251        assert_eq!(results[0].key, "rate-limit");
1252    }
1253
1254    #[test]
1255    fn upsert_existing_fact() {
1256        let policy = default_policy();
1257        let mut k = ProjectKnowledge::new("/tmp/test");
1258        k.remember("arch", "db", "PostgreSQL", "s1", 0.7, &policy);
1259        k.remember(
1260            "arch",
1261            "db",
1262            "PostgreSQL 16 with pgvector",
1263            "s2",
1264            0.95,
1265            &policy,
1266        );
1267
1268        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
1269        assert_eq!(current.len(), 1);
1270        assert_eq!(current[0].value, "PostgreSQL 16 with pgvector");
1271    }
1272
1273    #[test]
1274    fn contradiction_detection() {
1275        let policy = default_policy();
1276        let mut k = ProjectKnowledge::new("/tmp/test");
1277        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1278        k.facts[0].confirmation_count = 3;
1279
1280        let contradiction = k.check_contradiction("arch", "db", "MySQL", &policy);
1281        assert!(contradiction.is_some());
1282        let c = contradiction.unwrap();
1283        assert_eq!(c.severity, ContradictionSeverity::High);
1284    }
1285
1286    #[test]
1287    fn temporal_validity() {
1288        let policy = default_policy();
1289        let mut k = ProjectKnowledge::new("/tmp/test");
1290        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1291        k.facts[0].confirmation_count = 3;
1292
1293        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
1294
1295        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
1296        assert_eq!(current.len(), 1);
1297        assert_eq!(current[0].value, "MySQL");
1298
1299        let all_db: Vec<_> = k.facts.iter().filter(|f| f.key == "db").collect();
1300        assert_eq!(all_db.len(), 2);
1301    }
1302
1303    #[test]
1304    fn confirmation_count() {
1305        let policy = default_policy();
1306        let mut k = ProjectKnowledge::new("/tmp/test");
1307        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
1308        assert_eq!(k.facts[0].confirmation_count, 1);
1309
1310        k.remember("arch", "db", "PostgreSQL", "s2", 0.9, &policy);
1311        assert_eq!(k.facts[0].confirmation_count, 2);
1312    }
1313
1314    #[test]
1315    fn remove_fact() {
1316        let policy = default_policy();
1317        let mut k = ProjectKnowledge::new("/tmp/test");
1318        k.remember("arch", "db", "PostgreSQL", "s1", 0.9, &policy);
1319        assert!(k.remove_fact("arch", "db"));
1320        assert!(k.facts.is_empty());
1321        assert!(!k.remove_fact("arch", "db"));
1322    }
1323
1324    #[test]
1325    fn list_rooms() {
1326        let policy = default_policy();
1327        let mut k = ProjectKnowledge::new("/tmp/test");
1328        k.remember("architecture", "auth", "JWT", "s1", 0.9, &policy);
1329        k.remember("architecture", "db", "PG", "s1", 0.9, &policy);
1330        k.remember("deploy", "host", "AWS", "s1", 0.8, &policy);
1331
1332        let rooms = k.list_rooms();
1333        assert_eq!(rooms.len(), 2);
1334    }
1335
1336    #[test]
1337    fn aaak_format() {
1338        let policy = default_policy();
1339        let mut k = ProjectKnowledge::new("/tmp/test");
1340        k.remember("architecture", "auth", "JWT RS256", "s1", 0.95, &policy);
1341        k.remember("architecture", "db", "PostgreSQL", "s1", 0.7, &policy);
1342
1343        let aaak = k.format_aaak();
1344        assert!(aaak.contains("ARCHITECTURE:"));
1345        assert!(aaak.contains("auth=JWT RS256"));
1346    }
1347
1348    #[test]
1349    fn consolidate_history() {
1350        let policy = default_policy();
1351        let mut k = ProjectKnowledge::new("/tmp/test");
1352        k.consolidate(
1353            "Migrated from REST to GraphQL",
1354            vec!["s1".into(), "s2".into()],
1355            &policy,
1356        );
1357        assert_eq!(k.history.len(), 1);
1358        assert_eq!(k.history[0].from_sessions.len(), 2);
1359    }
1360
1361    #[test]
1362    fn format_summary_output() {
1363        let policy = default_policy();
1364        let mut k = ProjectKnowledge::new("/tmp/test");
1365        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9, &policy);
1366        k.add_pattern(
1367            "naming",
1368            "snake_case for functions",
1369            vec!["get_user()".into()],
1370            "s1",
1371            &policy,
1372        );
1373        let summary = k.format_summary();
1374        assert!(summary.contains("PROJECT KNOWLEDGE:"));
1375        assert!(summary.contains("auth: JWT RS256"));
1376        assert!(summary.contains("PROJECT PATTERNS:"));
1377    }
1378
1379    #[test]
1380    fn temporal_recall_at_time() {
1381        let policy = default_policy();
1382        let mut k = ProjectKnowledge::new("/tmp/test");
1383        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1384        k.facts[0].confirmation_count = 3;
1385
1386        let before_change = Utc::now();
1387        std::thread::sleep(std::time::Duration::from_millis(10));
1388
1389        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
1390
1391        let results = k.recall_at_time("db", before_change);
1392        assert_eq!(results.len(), 1);
1393        assert_eq!(results[0].value, "PostgreSQL");
1394
1395        let results_now = k.recall_at_time("db", Utc::now());
1396        assert_eq!(results_now.len(), 1);
1397        assert_eq!(results_now[0].value, "MySQL");
1398    }
1399
1400    #[test]
1401    fn timeline_shows_history() {
1402        let policy = default_policy();
1403        let mut k = ProjectKnowledge::new("/tmp/test");
1404        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1405        k.facts[0].confirmation_count = 3;
1406        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
1407
1408        let timeline = k.timeline("arch");
1409        assert_eq!(timeline.len(), 2);
1410        assert!(!timeline[0].is_current());
1411        assert!(timeline[1].is_current());
1412    }
1413
1414    #[test]
1415    fn wakeup_format() {
1416        let policy = default_policy();
1417        let mut k = ProjectKnowledge::new("/tmp/test");
1418        k.remember("arch", "auth", "JWT", "s1", 0.95, &policy);
1419        k.remember("arch", "db", "PG", "s1", 0.8, &policy);
1420
1421        let wakeup = k.format_wakeup();
1422        assert!(wakeup.contains("FACTS:"));
1423        assert!(wakeup.contains("arch/auth=JWT"));
1424        assert!(wakeup.contains("arch/db=PG"));
1425    }
1426
1427    #[test]
1428    fn salience_prioritizes_decisions_over_findings_at_similar_confidence() {
1429        let policy = default_policy();
1430        let mut k = ProjectKnowledge::new("/tmp/test");
1431        k.remember("finding", "f1", "some thing", "s1", 0.9, &policy);
1432        k.remember("decision", "d1", "important", "s1", 0.85, &policy);
1433
1434        let wakeup = k.format_wakeup();
1435        let items = wakeup
1436            .strip_prefix("FACTS:")
1437            .unwrap_or(&wakeup)
1438            .split('|')
1439            .collect::<Vec<_>>();
1440        assert!(
1441            items
1442                .first()
1443                .is_some_and(|s| s.contains("decision/d1=important")),
1444            "expected decision first in wakeup: {wakeup}"
1445        );
1446    }
1447
1448    #[test]
1449    fn low_confidence_contradiction() {
1450        let policy = default_policy();
1451        let mut k = ProjectKnowledge::new("/tmp/test");
1452        k.remember("arch", "db", "PostgreSQL", "s1", 0.4, &policy);
1453
1454        let c = k.check_contradiction("arch", "db", "MySQL", &policy);
1455        assert!(c.is_some());
1456        assert_eq!(c.unwrap().severity, ContradictionSeverity::Low);
1457    }
1458
1459    #[test]
1460    fn no_contradiction_for_same_value() {
1461        let policy = default_policy();
1462        let mut k = ProjectKnowledge::new("/tmp/test");
1463        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1464
1465        let c = k.check_contradiction("arch", "db", "PostgreSQL", &policy);
1466        assert!(c.is_none());
1467    }
1468
1469    #[test]
1470    fn no_contradiction_for_similar_values() {
1471        let policy = default_policy();
1472        let mut k = ProjectKnowledge::new("/tmp/test");
1473        k.remember(
1474            "arch",
1475            "db",
1476            "PostgreSQL 16 production database server",
1477            "s1",
1478            0.95,
1479            &policy,
1480        );
1481
1482        let c = k.check_contradiction(
1483            "arch",
1484            "db",
1485            "PostgreSQL 16 production database server config",
1486            &policy,
1487        );
1488        assert!(c.is_none());
1489    }
1490
1491    #[test]
1492    fn import_skip_existing() {
1493        let policy = default_policy();
1494        let mut k = ProjectKnowledge::new("/tmp/test");
1495        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1496
1497        let incoming = vec![KnowledgeFact {
1498            category: "arch".into(),
1499            key: "db".into(),
1500            value: "MySQL".into(),
1501            source_session: "import".into(),
1502            confidence: 0.8,
1503            created_at: Utc::now(),
1504            last_confirmed: Utc::now(),
1505            retrieval_count: 0,
1506            last_retrieved: None,
1507            valid_from: Some(Utc::now()),
1508            valid_until: None,
1509            supersedes: None,
1510            confirmation_count: 1,
1511            feedback_up: 0,
1512            feedback_down: 0,
1513            last_feedback: None,
1514            privacy: FactPrivacy::default(),
1515            imported_from: None,
1516        }];
1517
1518        let result = k.import_facts(incoming, ImportMerge::SkipExisting, "imp-1", &policy);
1519        assert_eq!(result.skipped, 1);
1520        assert_eq!(result.added, 0);
1521        assert_eq!(k.facts.iter().filter(|f| f.is_current()).count(), 1);
1522    }
1523
1524    #[test]
1525    fn import_replace_existing() {
1526        let policy = default_policy();
1527        let mut k = ProjectKnowledge::new("/tmp/test");
1528        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1529
1530        let incoming = vec![KnowledgeFact {
1531            category: "arch".into(),
1532            key: "db".into(),
1533            value: "MySQL".into(),
1534            source_session: "import".into(),
1535            confidence: 0.8,
1536            created_at: Utc::now(),
1537            last_confirmed: Utc::now(),
1538            retrieval_count: 0,
1539            last_retrieved: None,
1540            valid_from: Some(Utc::now()),
1541            valid_until: None,
1542            supersedes: None,
1543            confirmation_count: 1,
1544            feedback_up: 0,
1545            feedback_down: 0,
1546            last_feedback: None,
1547            privacy: FactPrivacy::default(),
1548            imported_from: None,
1549        }];
1550
1551        let result = k.import_facts(incoming, ImportMerge::Replace, "imp-1", &policy);
1552        assert_eq!(result.replaced, 1);
1553        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
1554        assert_eq!(current.len(), 1);
1555        assert_eq!(current[0].value, "MySQL");
1556    }
1557
1558    #[test]
1559    fn import_adds_new_facts() {
1560        let policy = default_policy();
1561        let mut k = ProjectKnowledge::new("/tmp/test");
1562        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1563
1564        let incoming = vec![KnowledgeFact {
1565            category: "security".into(),
1566            key: "auth".into(),
1567            value: "JWT".into(),
1568            source_session: "import".into(),
1569            confidence: 0.9,
1570            created_at: Utc::now(),
1571            last_confirmed: Utc::now(),
1572            retrieval_count: 0,
1573            last_retrieved: None,
1574            valid_from: Some(Utc::now()),
1575            valid_until: None,
1576            supersedes: None,
1577            confirmation_count: 1,
1578            feedback_up: 0,
1579            feedback_down: 0,
1580            last_feedback: None,
1581            privacy: FactPrivacy::default(),
1582            imported_from: None,
1583        }];
1584
1585        let result = k.import_facts(incoming, ImportMerge::SkipExisting, "imp-1", &policy);
1586        assert_eq!(result.added, 1);
1587        assert_eq!(k.facts.iter().filter(|f| f.is_current()).count(), 2);
1588    }
1589
1590    #[test]
1591    fn parse_simple_json_array() {
1592        let data = r#"[
1593            {"category": "arch", "key": "db", "value": "PostgreSQL"},
1594            {"category": "security", "key": "auth", "value": "JWT", "confidence": 0.9}
1595        ]"#;
1596        let facts = parse_import_data(data).unwrap();
1597        assert_eq!(facts.len(), 2);
1598        assert_eq!(facts[0].category, "arch");
1599        assert_eq!(facts[1].confidence, 0.9);
1600    }
1601
1602    #[test]
1603    fn parse_jsonl_format() {
1604        let data = "{\"category\":\"arch\",\"key\":\"db\",\"value\":\"PG\"}\n\
1605                    {\"category\":\"security\",\"key\":\"auth\",\"value\":\"JWT\"}";
1606        let facts = parse_import_data(data).unwrap();
1607        assert_eq!(facts.len(), 2);
1608    }
1609
1610    #[test]
1611    fn export_simple_only_current() {
1612        let policy = default_policy();
1613        let mut k = ProjectKnowledge::new("/tmp/test");
1614        k.remember("arch", "db", "PostgreSQL", "s1", 0.95, &policy);
1615        k.remember("arch", "db", "MySQL", "s2", 0.9, &policy);
1616
1617        let exported = k.export_simple();
1618        assert_eq!(exported.len(), 1);
1619        assert_eq!(exported[0].value, "MySQL");
1620    }
1621
1622    #[test]
1623    fn import_merge_parse() {
1624        assert_eq!(ImportMerge::parse("replace"), Some(ImportMerge::Replace));
1625        assert_eq!(ImportMerge::parse("append"), Some(ImportMerge::Append));
1626        assert_eq!(
1627            ImportMerge::parse("skip-existing"),
1628            Some(ImportMerge::SkipExisting)
1629        );
1630        assert_eq!(
1631            ImportMerge::parse("skip_existing"),
1632            Some(ImportMerge::SkipExisting)
1633        );
1634        assert_eq!(ImportMerge::parse("skip"), Some(ImportMerge::SkipExisting));
1635        assert!(ImportMerge::parse("invalid").is_none());
1636    }
1637}