Skip to main content

lean_ctx/core/
knowledge.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_FACTS: usize = 200;
6const MAX_PATTERNS: usize = 50;
7const MAX_HISTORY: usize = 100;
8const CONTRADICTION_THRESHOLD: f32 = 0.5;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ProjectKnowledge {
12    pub project_root: String,
13    pub project_hash: String,
14    pub facts: Vec<KnowledgeFact>,
15    pub patterns: Vec<ProjectPattern>,
16    pub history: Vec<ConsolidatedInsight>,
17    pub updated_at: DateTime<Utc>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct KnowledgeFact {
22    pub category: String,
23    pub key: String,
24    pub value: String,
25    pub source_session: String,
26    pub confidence: f32,
27    pub created_at: DateTime<Utc>,
28    pub last_confirmed: DateTime<Utc>,
29    #[serde(default)]
30    pub valid_from: Option<DateTime<Utc>>,
31    #[serde(default)]
32    pub valid_until: Option<DateTime<Utc>>,
33    #[serde(default)]
34    pub supersedes: Option<String>,
35    #[serde(default)]
36    pub confirmation_count: u32,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Contradiction {
41    pub existing_key: String,
42    pub existing_value: String,
43    pub new_value: String,
44    pub category: String,
45    pub severity: ContradictionSeverity,
46    pub resolution: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub enum ContradictionSeverity {
51    Low,
52    Medium,
53    High,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ProjectPattern {
58    pub pattern_type: String,
59    pub description: String,
60    pub examples: Vec<String>,
61    pub source_session: String,
62    pub created_at: DateTime<Utc>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ConsolidatedInsight {
67    pub summary: String,
68    pub from_sessions: Vec<String>,
69    pub timestamp: DateTime<Utc>,
70}
71
72impl ProjectKnowledge {
73    pub fn new(project_root: &str) -> Self {
74        Self {
75            project_root: project_root.to_string(),
76            project_hash: hash_project_root(project_root),
77            facts: Vec::new(),
78            patterns: Vec::new(),
79            history: Vec::new(),
80            updated_at: Utc::now(),
81        }
82    }
83
84    pub fn check_contradiction(
85        &self,
86        category: &str,
87        key: &str,
88        new_value: &str,
89    ) -> Option<Contradiction> {
90        let existing = self
91            .facts
92            .iter()
93            .find(|f| f.category == category && f.key == key && f.is_current())?;
94
95        if existing.value.to_lowercase() == new_value.to_lowercase() {
96            return None;
97        }
98
99        let similarity = string_similarity(&existing.value, new_value);
100        if similarity > 0.8 {
101            return None;
102        }
103
104        let severity = if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
105            ContradictionSeverity::High
106        } else if existing.confidence >= CONTRADICTION_THRESHOLD {
107            ContradictionSeverity::Medium
108        } else {
109            ContradictionSeverity::Low
110        };
111
112        let resolution = match severity {
113            ContradictionSeverity::High => format!(
114                "High-confidence fact [{category}/{key}] changed: '{}' -> '{new_value}' (was confirmed {}x). Previous value archived.",
115                existing.value, existing.confirmation_count
116            ),
117            ContradictionSeverity::Medium => format!(
118                "Fact [{category}/{key}] updated: '{}' -> '{new_value}'",
119                existing.value
120            ),
121            ContradictionSeverity::Low => format!(
122                "Low-confidence fact [{category}/{key}] replaced: '{}' -> '{new_value}'",
123                existing.value
124            ),
125        };
126
127        Some(Contradiction {
128            existing_key: key.to_string(),
129            existing_value: existing.value.clone(),
130            new_value: new_value.to_string(),
131            category: category.to_string(),
132            severity,
133            resolution,
134        })
135    }
136
137    pub fn remember(
138        &mut self,
139        category: &str,
140        key: &str,
141        value: &str,
142        session_id: &str,
143        confidence: f32,
144    ) -> Option<Contradiction> {
145        let contradiction = self.check_contradiction(category, key, value);
146
147        if let Some(existing) = self
148            .facts
149            .iter_mut()
150            .find(|f| f.category == category && f.key == key && f.is_current())
151        {
152            if existing.value != value {
153                if existing.confidence >= 0.9 && existing.confirmation_count >= 2 {
154                    existing.valid_until = Some(Utc::now());
155                    let superseded_id = format!("{}/{}", existing.category, existing.key);
156                    let now = Utc::now();
157                    self.facts.push(KnowledgeFact {
158                        category: category.to_string(),
159                        key: key.to_string(),
160                        value: value.to_string(),
161                        source_session: session_id.to_string(),
162                        confidence,
163                        created_at: now,
164                        last_confirmed: now,
165                        valid_from: Some(now),
166                        valid_until: None,
167                        supersedes: Some(superseded_id),
168                        confirmation_count: 1,
169                    });
170                } else {
171                    existing.value = value.to_string();
172                    existing.confidence = confidence;
173                    existing.last_confirmed = Utc::now();
174                    existing.source_session = session_id.to_string();
175                    existing.valid_from = existing.valid_from.or(Some(existing.created_at));
176                    existing.confirmation_count = 1;
177                }
178            } else {
179                existing.last_confirmed = Utc::now();
180                existing.source_session = session_id.to_string();
181                existing.confidence = (existing.confidence + confidence) / 2.0;
182                existing.confirmation_count += 1;
183            }
184        } else {
185            let now = Utc::now();
186            self.facts.push(KnowledgeFact {
187                category: category.to_string(),
188                key: key.to_string(),
189                value: value.to_string(),
190                source_session: session_id.to_string(),
191                confidence,
192                created_at: now,
193                last_confirmed: now,
194                valid_from: Some(now),
195                valid_until: None,
196                supersedes: None,
197                confirmation_count: 1,
198            });
199        }
200
201        if self.facts.len() > MAX_FACTS {
202            self.facts
203                .sort_by(|a, b| b.last_confirmed.cmp(&a.last_confirmed));
204            self.facts.truncate(MAX_FACTS);
205        }
206
207        self.updated_at = Utc::now();
208
209        let action = if contradiction.is_some() {
210            "contradict"
211        } else {
212            "remember"
213        };
214        crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
215            category: category.to_string(),
216            key: key.to_string(),
217            action: action.to_string(),
218        });
219
220        contradiction
221    }
222
223    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
224        let q = query.to_lowercase();
225        let terms: Vec<&str> = q.split_whitespace().collect();
226
227        let mut results: Vec<(&KnowledgeFact, f32)> = self
228            .facts
229            .iter()
230            .filter(|f| f.is_current())
231            .filter_map(|f| {
232                let searchable = format!(
233                    "{} {} {} {}",
234                    f.category.to_lowercase(),
235                    f.key.to_lowercase(),
236                    f.value.to_lowercase(),
237                    f.source_session
238                );
239                let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
240                if match_count > 0 {
241                    let relevance = (match_count as f32 / terms.len() as f32) * f.confidence;
242                    Some((f, relevance))
243                } else {
244                    None
245                }
246            })
247            .collect();
248
249        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
250        results.into_iter().map(|(f, _)| f).collect()
251    }
252
253    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
254        self.facts
255            .iter()
256            .filter(|f| f.category == category && f.is_current())
257            .collect()
258    }
259
260    pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
261        let q = query.to_lowercase();
262        let terms: Vec<&str> = q.split_whitespace().collect();
263
264        let mut results: Vec<(&KnowledgeFact, f32)> = self
265            .facts
266            .iter()
267            .filter(|f| f.was_valid_at(at))
268            .filter_map(|f| {
269                let searchable = format!(
270                    "{} {} {}",
271                    f.category.to_lowercase(),
272                    f.key.to_lowercase(),
273                    f.value.to_lowercase(),
274                );
275                let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
276                if match_count > 0 {
277                    Some((f, match_count as f32 / terms.len() as f32))
278                } else {
279                    None
280                }
281            })
282            .collect();
283
284        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
285        results.into_iter().map(|(f, _)| f).collect()
286    }
287
288    pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
289        let mut facts: Vec<&KnowledgeFact> = self
290            .facts
291            .iter()
292            .filter(|f| f.category == category)
293            .collect();
294        facts.sort_by(|a, b| a.created_at.cmp(&b.created_at));
295        facts
296    }
297
298    pub fn list_rooms(&self) -> Vec<(String, usize)> {
299        let mut categories: std::collections::BTreeMap<String, usize> =
300            std::collections::BTreeMap::new();
301        for f in &self.facts {
302            if f.is_current() {
303                *categories.entry(f.category.clone()).or_insert(0) += 1;
304            }
305        }
306        categories.into_iter().collect()
307    }
308
309    pub fn add_pattern(
310        &mut self,
311        pattern_type: &str,
312        description: &str,
313        examples: Vec<String>,
314        session_id: &str,
315    ) {
316        if let Some(existing) = self
317            .patterns
318            .iter_mut()
319            .find(|p| p.pattern_type == pattern_type && p.description == description)
320        {
321            for ex in &examples {
322                if !existing.examples.contains(ex) {
323                    existing.examples.push(ex.clone());
324                }
325            }
326            return;
327        }
328
329        self.patterns.push(ProjectPattern {
330            pattern_type: pattern_type.to_string(),
331            description: description.to_string(),
332            examples,
333            source_session: session_id.to_string(),
334            created_at: Utc::now(),
335        });
336
337        if self.patterns.len() > MAX_PATTERNS {
338            self.patterns.truncate(MAX_PATTERNS);
339        }
340        self.updated_at = Utc::now();
341    }
342
343    pub fn consolidate(&mut self, summary: &str, session_ids: Vec<String>) {
344        self.history.push(ConsolidatedInsight {
345            summary: summary.to_string(),
346            from_sessions: session_ids,
347            timestamp: Utc::now(),
348        });
349
350        if self.history.len() > MAX_HISTORY {
351            self.history.drain(0..self.history.len() - MAX_HISTORY);
352        }
353        self.updated_at = Utc::now();
354    }
355
356    pub fn remove_fact(&mut self, category: &str, key: &str) -> bool {
357        let before = self.facts.len();
358        self.facts
359            .retain(|f| !(f.category == category && f.key == key));
360        let removed = self.facts.len() < before;
361        if removed {
362            self.updated_at = Utc::now();
363        }
364        removed
365    }
366
367    pub fn format_summary(&self) -> String {
368        let mut out = String::new();
369        let current_facts: Vec<&KnowledgeFact> =
370            self.facts.iter().filter(|f| f.is_current()).collect();
371
372        if !current_facts.is_empty() {
373            out.push_str("PROJECT KNOWLEDGE:\n");
374            let mut categories: Vec<&str> =
375                current_facts.iter().map(|f| f.category.as_str()).collect();
376            categories.sort();
377            categories.dedup();
378
379            for cat in categories {
380                out.push_str(&format!("  [{cat}]\n"));
381                for f in current_facts.iter().filter(|f| f.category == cat) {
382                    out.push_str(&format!(
383                        "    {}: {} (confidence: {:.0}%)\n",
384                        f.key,
385                        f.value,
386                        f.confidence * 100.0
387                    ));
388                }
389            }
390        }
391
392        if !self.patterns.is_empty() {
393            out.push_str("PROJECT PATTERNS:\n");
394            for p in &self.patterns {
395                out.push_str(&format!("  [{}] {}\n", p.pattern_type, p.description));
396            }
397        }
398
399        out
400    }
401
402    pub fn format_aaak(&self) -> String {
403        let current_facts: Vec<&KnowledgeFact> =
404            self.facts.iter().filter(|f| f.is_current()).collect();
405
406        if current_facts.is_empty() && self.patterns.is_empty() {
407            return String::new();
408        }
409
410        let mut out = String::new();
411        let mut categories: Vec<&str> = current_facts.iter().map(|f| f.category.as_str()).collect();
412        categories.sort();
413        categories.dedup();
414
415        for cat in categories {
416            let facts_in_cat: Vec<&&KnowledgeFact> =
417                current_facts.iter().filter(|f| f.category == cat).collect();
418            let items: Vec<String> = facts_in_cat
419                .iter()
420                .map(|f| {
421                    let stars = confidence_stars(f.confidence);
422                    format!("{}={}{}", f.key, f.value, stars)
423                })
424                .collect();
425            out.push_str(&format!("{}:{}\n", cat.to_uppercase(), items.join("|")));
426        }
427
428        if !self.patterns.is_empty() {
429            let pat_items: Vec<String> = self
430                .patterns
431                .iter()
432                .map(|p| format!("{}.{}", p.pattern_type, p.description))
433                .collect();
434            out.push_str(&format!("PAT:{}\n", pat_items.join("|")));
435        }
436
437        out
438    }
439
440    pub fn format_wakeup(&self) -> String {
441        let current_facts: Vec<&KnowledgeFact> = self
442            .facts
443            .iter()
444            .filter(|f| f.is_current() && f.confidence >= 0.7)
445            .collect();
446
447        if current_facts.is_empty() {
448            return String::new();
449        }
450
451        let mut top_facts: Vec<&KnowledgeFact> = current_facts;
452        top_facts.sort_by(|a, b| {
453            b.confidence
454                .partial_cmp(&a.confidence)
455                .unwrap_or(std::cmp::Ordering::Equal)
456                .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
457        });
458        top_facts.truncate(10);
459
460        let items: Vec<String> = top_facts
461            .iter()
462            .map(|f| format!("{}/{}={}", f.category, f.key, f.value))
463            .collect();
464
465        format!("FACTS:{}", items.join("|"))
466    }
467
468    pub fn save(&self) -> Result<(), String> {
469        let dir = knowledge_dir(&self.project_hash)?;
470        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
471
472        let path = dir.join("knowledge.json");
473        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
474        std::fs::write(&path, json).map_err(|e| e.to_string())
475    }
476
477    pub fn load(project_root: &str) -> Option<Self> {
478        let hash = hash_project_root(project_root);
479        let dir = knowledge_dir(&hash).ok()?;
480        let path = dir.join("knowledge.json");
481
482        let content = std::fs::read_to_string(&path).ok()?;
483        serde_json::from_str(&content).ok()
484    }
485
486    pub fn load_or_create(project_root: &str) -> Self {
487        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
488    }
489}
490
491impl KnowledgeFact {
492    pub fn is_current(&self) -> bool {
493        self.valid_until.is_none()
494    }
495
496    pub fn was_valid_at(&self, at: DateTime<Utc>) -> bool {
497        let after_start = self.valid_from.is_none_or(|from| at >= from);
498        let before_end = self.valid_until.is_none_or(|until| at <= until);
499        after_start && before_end
500    }
501}
502
503fn confidence_stars(confidence: f32) -> &'static str {
504    if confidence >= 0.95 {
505        "★★★★★"
506    } else if confidence >= 0.85 {
507        "★★★★"
508    } else if confidence >= 0.7 {
509        "★★★"
510    } else if confidence >= 0.5 {
511        "★★"
512    } else {
513        "★"
514    }
515}
516
517fn string_similarity(a: &str, b: &str) -> f32 {
518    let a_lower = a.to_lowercase();
519    let b_lower = b.to_lowercase();
520    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
521    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
522
523    if a_words.is_empty() && b_words.is_empty() {
524        return 1.0;
525    }
526
527    let intersection = a_words.intersection(&b_words).count();
528    let union = a_words.union(&b_words).count();
529
530    if union == 0 {
531        return 0.0;
532    }
533
534    intersection as f32 / union as f32
535}
536
537fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
538    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
539    Ok(home.join(".lean-ctx").join("knowledge").join(project_hash))
540}
541
542fn hash_project_root(root: &str) -> String {
543    use std::collections::hash_map::DefaultHasher;
544    use std::hash::{Hash, Hasher};
545
546    let mut hasher = DefaultHasher::new();
547    root.hash(&mut hasher);
548    format!("{:016x}", hasher.finish())
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn remember_and_recall() {
557        let mut k = ProjectKnowledge::new("/tmp/test-project");
558        k.remember("architecture", "auth", "JWT RS256", "session-1", 0.9);
559        k.remember("api", "rate-limit", "100/min", "session-1", 0.8);
560
561        let results = k.recall("auth");
562        assert_eq!(results.len(), 1);
563        assert_eq!(results[0].value, "JWT RS256");
564
565        let results = k.recall("api rate");
566        assert_eq!(results.len(), 1);
567        assert_eq!(results[0].key, "rate-limit");
568    }
569
570    #[test]
571    fn upsert_existing_fact() {
572        let mut k = ProjectKnowledge::new("/tmp/test");
573        k.remember("arch", "db", "PostgreSQL", "s1", 0.7);
574        k.remember("arch", "db", "PostgreSQL 16 with pgvector", "s2", 0.95);
575
576        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
577        assert_eq!(current.len(), 1);
578        assert_eq!(current[0].value, "PostgreSQL 16 with pgvector");
579    }
580
581    #[test]
582    fn contradiction_detection() {
583        let mut k = ProjectKnowledge::new("/tmp/test");
584        k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
585        k.facts[0].confirmation_count = 3;
586
587        let contradiction = k.check_contradiction("arch", "db", "MySQL");
588        assert!(contradiction.is_some());
589        let c = contradiction.unwrap();
590        assert_eq!(c.severity, ContradictionSeverity::High);
591    }
592
593    #[test]
594    fn temporal_validity() {
595        let mut k = ProjectKnowledge::new("/tmp/test");
596        k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
597        k.facts[0].confirmation_count = 3;
598
599        k.remember("arch", "db", "MySQL", "s2", 0.9);
600
601        let current: Vec<_> = k.facts.iter().filter(|f| f.is_current()).collect();
602        assert_eq!(current.len(), 1);
603        assert_eq!(current[0].value, "MySQL");
604
605        let all_db: Vec<_> = k.facts.iter().filter(|f| f.key == "db").collect();
606        assert_eq!(all_db.len(), 2);
607    }
608
609    #[test]
610    fn confirmation_count() {
611        let mut k = ProjectKnowledge::new("/tmp/test");
612        k.remember("arch", "db", "PostgreSQL", "s1", 0.9);
613        assert_eq!(k.facts[0].confirmation_count, 1);
614
615        k.remember("arch", "db", "PostgreSQL", "s2", 0.9);
616        assert_eq!(k.facts[0].confirmation_count, 2);
617    }
618
619    #[test]
620    fn remove_fact() {
621        let mut k = ProjectKnowledge::new("/tmp/test");
622        k.remember("arch", "db", "PostgreSQL", "s1", 0.9);
623        assert!(k.remove_fact("arch", "db"));
624        assert!(k.facts.is_empty());
625        assert!(!k.remove_fact("arch", "db"));
626    }
627
628    #[test]
629    fn list_rooms() {
630        let mut k = ProjectKnowledge::new("/tmp/test");
631        k.remember("architecture", "auth", "JWT", "s1", 0.9);
632        k.remember("architecture", "db", "PG", "s1", 0.9);
633        k.remember("deploy", "host", "AWS", "s1", 0.8);
634
635        let rooms = k.list_rooms();
636        assert_eq!(rooms.len(), 2);
637    }
638
639    #[test]
640    fn aaak_format() {
641        let mut k = ProjectKnowledge::new("/tmp/test");
642        k.remember("architecture", "auth", "JWT RS256", "s1", 0.95);
643        k.remember("architecture", "db", "PostgreSQL", "s1", 0.7);
644
645        let aaak = k.format_aaak();
646        assert!(aaak.contains("ARCHITECTURE:"));
647        assert!(aaak.contains("auth=JWT RS256"));
648    }
649
650    #[test]
651    fn consolidate_history() {
652        let mut k = ProjectKnowledge::new("/tmp/test");
653        k.consolidate(
654            "Migrated from REST to GraphQL",
655            vec!["s1".into(), "s2".into()],
656        );
657        assert_eq!(k.history.len(), 1);
658        assert_eq!(k.history[0].from_sessions.len(), 2);
659    }
660
661    #[test]
662    fn format_summary_output() {
663        let mut k = ProjectKnowledge::new("/tmp/test");
664        k.remember("architecture", "auth", "JWT RS256", "s1", 0.9);
665        k.add_pattern(
666            "naming",
667            "snake_case for functions",
668            vec!["get_user()".into()],
669            "s1",
670        );
671        let summary = k.format_summary();
672        assert!(summary.contains("PROJECT KNOWLEDGE:"));
673        assert!(summary.contains("auth: JWT RS256"));
674        assert!(summary.contains("PROJECT PATTERNS:"));
675    }
676
677    #[test]
678    fn temporal_recall_at_time() {
679        let mut k = ProjectKnowledge::new("/tmp/test");
680        k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
681        k.facts[0].confirmation_count = 3;
682
683        let before_change = Utc::now();
684        std::thread::sleep(std::time::Duration::from_millis(10));
685
686        k.remember("arch", "db", "MySQL", "s2", 0.9);
687
688        let results = k.recall_at_time("db", before_change);
689        assert_eq!(results.len(), 1);
690        assert_eq!(results[0].value, "PostgreSQL");
691
692        let results_now = k.recall_at_time("db", Utc::now());
693        assert_eq!(results_now.len(), 1);
694        assert_eq!(results_now[0].value, "MySQL");
695    }
696
697    #[test]
698    fn timeline_shows_history() {
699        let mut k = ProjectKnowledge::new("/tmp/test");
700        k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
701        k.facts[0].confirmation_count = 3;
702        k.remember("arch", "db", "MySQL", "s2", 0.9);
703
704        let timeline = k.timeline("arch");
705        assert_eq!(timeline.len(), 2);
706        assert!(!timeline[0].is_current());
707        assert!(timeline[1].is_current());
708    }
709
710    #[test]
711    fn wakeup_format() {
712        let mut k = ProjectKnowledge::new("/tmp/test");
713        k.remember("arch", "auth", "JWT", "s1", 0.95);
714        k.remember("arch", "db", "PG", "s1", 0.8);
715
716        let wakeup = k.format_wakeup();
717        assert!(wakeup.contains("FACTS:"));
718        assert!(wakeup.contains("arch/auth=JWT"));
719        assert!(wakeup.contains("arch/db=PG"));
720    }
721
722    #[test]
723    fn low_confidence_contradiction() {
724        let mut k = ProjectKnowledge::new("/tmp/test");
725        k.remember("arch", "db", "PostgreSQL", "s1", 0.4);
726
727        let c = k.check_contradiction("arch", "db", "MySQL");
728        assert!(c.is_some());
729        assert_eq!(c.unwrap().severity, ContradictionSeverity::Low);
730    }
731
732    #[test]
733    fn no_contradiction_for_same_value() {
734        let mut k = ProjectKnowledge::new("/tmp/test");
735        k.remember("arch", "db", "PostgreSQL", "s1", 0.95);
736
737        let c = k.check_contradiction("arch", "db", "PostgreSQL");
738        assert!(c.is_none());
739    }
740
741    #[test]
742    fn no_contradiction_for_similar_values() {
743        let mut k = ProjectKnowledge::new("/tmp/test");
744        k.remember(
745            "arch",
746            "db",
747            "PostgreSQL 16 production database server",
748            "s1",
749            0.95,
750        );
751
752        let c = k.check_contradiction(
753            "arch",
754            "db",
755            "PostgreSQL 16 production database server config",
756        );
757        assert!(c.is_none());
758    }
759}