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