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