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}