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