1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::knowledge::{KnowledgeFact, ProjectKnowledge};
12use crate::core::embedding_quant::{self, QuantizedVector};
13use crate::core::memory_policy::MemoryPolicy;
14
15#[cfg(feature = "embeddings")]
16use super::embeddings::EmbeddingEngine;
17
18const ALPHA_SEMANTIC: f32 = 0.6;
19const BETA_CONFIDENCE: f32 = 0.25;
20const GAMMA_RECENCY: f32 = 0.15;
21const MAX_RECENCY_DAYS: f32 = 90.0;
22
23pub const SEMANTIC_DUP_THRESHOLD: f32 = 0.86;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct FactEmbedding {
33 pub category: String,
34 pub key: String,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub embedding: Vec<f32>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub quant: Option<QuantizedVector>,
44}
45
46impl FactEmbedding {
47 fn similarity(&self, query: &[f32]) -> f32 {
51 match &self.quant {
52 Some(q) => embedding_quant::dot_quant(query, q),
53 None => embedding_quant::dot_f32(query, &self.embedding),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct KnowledgeEmbeddingIndex {
60 pub project_hash: String,
61 pub entries: Vec<FactEmbedding>,
62}
63
64impl KnowledgeEmbeddingIndex {
65 pub fn new(project_hash: &str) -> Self {
66 Self {
67 project_hash: project_hash.to_string(),
68 entries: Vec::new(),
69 }
70 }
71
72 pub fn upsert(&mut self, category: &str, key: &str, embedding: &[f32]) {
73 let quant = Some(embedding_quant::quantize(embedding));
74 if let Some(existing) = self
75 .entries
76 .iter_mut()
77 .find(|e| e.category == category && e.key == key)
78 {
79 existing.quant = quant;
80 existing.embedding = Vec::new();
81 } else {
82 self.entries.push(FactEmbedding {
83 category: category.to_string(),
84 key: key.to_string(),
85 embedding: Vec::new(),
86 quant,
87 });
88 }
89 }
90
91 fn migrate_legacy_entries(&mut self) -> bool {
94 let mut changed = false;
95 for e in &mut self.entries {
96 if e.quant.is_none() && !e.embedding.is_empty() {
97 e.quant = Some(embedding_quant::quantize(&e.embedding));
98 e.embedding = Vec::new();
99 changed = true;
100 }
101 }
102 changed
103 }
104
105 pub fn remove(&mut self, category: &str, key: &str) {
106 self.entries
107 .retain(|e| !(e.category == category && e.key == key));
108 }
109
110 #[cfg(feature = "embeddings")]
111 pub fn semantic_search(
112 &self,
113 query_embedding: &[f32],
114 top_k: usize,
115 ) -> Vec<(&FactEmbedding, f32)> {
116 let mut scored: Vec<(&FactEmbedding, f32)> = self
117 .entries
118 .iter()
119 .map(|e| {
120 let sim = e.similarity(query_embedding);
121 (e, sim)
122 })
123 .collect();
124
125 scored.sort_by(|a, b| {
126 b.1.partial_cmp(&a.1)
127 .unwrap_or(std::cmp::Ordering::Equal)
128 .then_with(|| a.0.category.cmp(&b.0.category))
129 .then_with(|| a.0.key.cmp(&b.0.key))
130 });
131 scored.truncate(top_k);
132 scored
133 }
134
135 fn index_path(project_hash: &str) -> Option<PathBuf> {
136 let dir = crate::core::data_dir::lean_ctx_data_dir()
137 .ok()?
138 .join("knowledge")
139 .join(project_hash);
140 Some(dir.join("embeddings.json"))
141 }
142
143 pub fn load(project_hash: &str) -> Option<Self> {
144 let path = Self::index_path(project_hash)?;
145 let data = std::fs::read_to_string(path).ok()?;
146 let mut index: Self = serde_json::from_str(&data).ok()?;
147 if index.migrate_legacy_entries() {
150 let _ = index.save();
151 }
152 Some(index)
153 }
154
155 pub fn save(&self) -> Result<(), String> {
156 let path = Self::index_path(&self.project_hash)
157 .ok_or_else(|| "Cannot determine data directory".to_string())?;
158 let json = serde_json::to_string(self).map_err(|e| format!("{e}"))?;
159 crate::config_io::write_atomic(&path, &json)
164 }
165}
166
167pub fn reset(project_hash: &str) -> Result<(), String> {
168 let path = KnowledgeEmbeddingIndex::index_path(project_hash)
169 .ok_or_else(|| "Cannot determine data directory".to_string())?;
170 if path.exists() {
171 std::fs::remove_file(&path).map_err(|e| format!("{e}"))?;
172 }
173 Ok(())
174}
175
176#[derive(Debug)]
177pub struct ScoredFact<'a> {
178 pub fact: &'a KnowledgeFact,
179 pub score: f32,
180 pub semantic_score: f32,
181 pub confidence_score: f32,
182 pub recency_score: f32,
183}
184
185#[cfg(feature = "embeddings")]
186pub fn semantic_recall<'a>(
187 knowledge: &'a ProjectKnowledge,
188 index: &KnowledgeEmbeddingIndex,
189 engine: &EmbeddingEngine,
190 query: &str,
191 top_k: usize,
192) -> Vec<ScoredFact<'a>> {
193 let Ok(query_embedding) = engine.embed_query(query) else {
194 return lexical_fallback(knowledge, query, top_k);
195 };
196
197 let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
198
199 let mut results: Vec<ScoredFact<'a>> = Vec::new();
200
201 for (entry, sim) in &semantic_hits {
202 if let Some(fact) = knowledge
203 .facts
204 .iter()
205 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
206 {
207 let confidence_score = fact.quality_score();
208 let recency_score = recency_decay(fact);
209 let score = ALPHA_SEMANTIC * sim
210 + BETA_CONFIDENCE * confidence_score
211 + GAMMA_RECENCY * recency_score;
212
213 results.push(ScoredFact {
214 fact,
215 score,
216 semantic_score: *sim,
217 confidence_score,
218 recency_score,
219 });
220 }
221 }
222
223 let exact_matches = knowledge.recall(query);
224 for fact in exact_matches {
225 let already_included = results
226 .iter()
227 .any(|r| r.fact.category == fact.category && r.fact.key == fact.key);
228 if !already_included {
229 results.push(ScoredFact {
230 fact,
231 score: 1.0,
232 semantic_score: 1.0,
233 confidence_score: fact.quality_score(),
234 recency_score: recency_decay(fact),
235 });
236 }
237 }
238
239 results.sort_by(|a, b| {
240 b.score
241 .partial_cmp(&a.score)
242 .unwrap_or(std::cmp::Ordering::Equal)
243 .then_with(|| {
244 b.confidence_score
245 .partial_cmp(&a.confidence_score)
246 .unwrap_or(std::cmp::Ordering::Equal)
247 })
248 .then_with(|| {
249 b.recency_score
250 .partial_cmp(&a.recency_score)
251 .unwrap_or(std::cmp::Ordering::Equal)
252 })
253 .then_with(|| a.fact.category.cmp(&b.fact.category))
254 .then_with(|| a.fact.key.cmp(&b.fact.key))
255 .then_with(|| a.fact.value.cmp(&b.fact.value))
256 });
257 results.truncate(top_k);
258 results
259}
260
261#[cfg(feature = "embeddings")]
262pub fn semantic_recall_semantic_only<'a>(
263 knowledge: &'a ProjectKnowledge,
264 index: &KnowledgeEmbeddingIndex,
265 engine: &EmbeddingEngine,
266 query: &str,
267 top_k: usize,
268) -> Vec<ScoredFact<'a>> {
269 let Ok(query_embedding) = engine.embed_query(query) else {
270 return Vec::new();
271 };
272
273 let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
274 let mut results: Vec<ScoredFact<'a>> = Vec::new();
275
276 for (entry, sim) in &semantic_hits {
277 if let Some(fact) = knowledge
278 .facts
279 .iter()
280 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
281 {
282 let confidence_score = fact.quality_score();
283 let recency_score = recency_decay(fact);
284 let score = ALPHA_SEMANTIC * sim
285 + BETA_CONFIDENCE * confidence_score
286 + GAMMA_RECENCY * recency_score;
287
288 results.push(ScoredFact {
289 fact,
290 score,
291 semantic_score: *sim,
292 confidence_score,
293 recency_score,
294 });
295 }
296 }
297
298 results.sort_by(|a, b| {
299 b.score
300 .partial_cmp(&a.score)
301 .unwrap_or(std::cmp::Ordering::Equal)
302 .then_with(|| {
303 b.confidence_score
304 .partial_cmp(&a.confidence_score)
305 .unwrap_or(std::cmp::Ordering::Equal)
306 })
307 .then_with(|| {
308 b.recency_score
309 .partial_cmp(&a.recency_score)
310 .unwrap_or(std::cmp::Ordering::Equal)
311 })
312 .then_with(|| a.fact.category.cmp(&b.fact.category))
313 .then_with(|| a.fact.key.cmp(&b.fact.key))
314 .then_with(|| a.fact.value.cmp(&b.fact.value))
315 });
316 results.truncate(top_k);
317 results
318}
319
320pub fn compact_against_knowledge(
321 index: &mut KnowledgeEmbeddingIndex,
322 knowledge: &ProjectKnowledge,
323 policy: &MemoryPolicy,
324) {
325 use std::collections::HashMap;
326
327 let mut current: HashMap<(&str, &str), &KnowledgeFact> = HashMap::new();
328 for f in &knowledge.facts {
329 if f.is_current() {
330 current.insert((f.category.as_str(), f.key.as_str()), f);
331 }
332 }
333
334 let mut kept: Vec<(FactEmbedding, &KnowledgeFact)> = index
335 .entries
336 .iter()
337 .filter_map(|e| {
338 current
339 .get(&(e.category.as_str(), e.key.as_str()))
340 .map(|f| (e.clone(), *f))
341 })
342 .collect();
343
344 kept.sort_by(|(ea, fa), (eb, fb)| {
345 fb.confidence
346 .partial_cmp(&fa.confidence)
347 .unwrap_or(std::cmp::Ordering::Equal)
348 .then_with(|| fb.last_confirmed.cmp(&fa.last_confirmed))
349 .then_with(|| fb.retrieval_count.cmp(&fa.retrieval_count))
350 .then_with(|| ea.category.cmp(&eb.category))
351 .then_with(|| ea.key.cmp(&eb.key))
352 });
353
354 let max = policy.embeddings.max_facts;
355 if kept.len() > max {
356 kept.truncate(max);
357 }
358
359 index.entries = kept.into_iter().map(|(e, _)| e).collect();
360}
361
362fn lexical_fallback<'a>(
363 knowledge: &'a ProjectKnowledge,
364 query: &str,
365 top_k: usize,
366) -> Vec<ScoredFact<'a>> {
367 knowledge
368 .recall(query)
369 .into_iter()
370 .take(top_k)
371 .map(|fact| ScoredFact {
372 fact,
373 score: fact.confidence,
374 semantic_score: 0.0,
375 confidence_score: fact.confidence,
376 recency_score: recency_decay(fact),
377 })
378 .collect()
379}
380
381fn recency_decay(fact: &KnowledgeFact) -> f32 {
382 let days_old = chrono::Utc::now()
383 .signed_duration_since(fact.last_confirmed)
384 .num_days() as f32;
385 (1.0 - days_old / MAX_RECENCY_DAYS).max(0.0)
386}
387
388#[cfg(feature = "embeddings")]
389pub fn embed_and_store(
390 index: &mut KnowledgeEmbeddingIndex,
391 engine: &EmbeddingEngine,
392 category: &str,
393 key: &str,
394 value: &str,
395) -> Result<(), String> {
396 let text = format!("{category} {key}: {value}");
397 let embedding = engine.embed(&text).map_err(|e| format!("{e}"))?;
398 index.upsert(category, key, &embedding);
399 Ok(())
400}
401
402#[cfg(feature = "embeddings")]
410pub fn find_semantic_duplicates(
411 index: &KnowledgeEmbeddingIndex,
412 engine: &EmbeddingEngine,
413 knowledge: &ProjectKnowledge,
414 new_category: &str,
415 new_key: &str,
416 new_value: &str,
417 threshold: f32,
418 limit: usize,
419) -> Vec<crate::core::knowledge::SimilarFact> {
420 let text = format!("{new_category} {new_key}: {new_value}");
423 let Ok(query) = engine.embed(&text) else {
424 return Vec::new();
425 };
426 semantic_duplicates_from_query(
427 index,
428 knowledge,
429 new_category,
430 new_key,
431 &query,
432 threshold,
433 limit,
434 )
435}
436
437fn semantic_duplicates_from_query(
443 index: &KnowledgeEmbeddingIndex,
444 knowledge: &ProjectKnowledge,
445 new_category: &str,
446 new_key: &str,
447 query: &[f32],
448 threshold: f32,
449 limit: usize,
450) -> Vec<crate::core::knowledge::SimilarFact> {
451 use crate::core::knowledge::SimilarFact;
452
453 let composite_key = format!("{new_category}/{new_key}");
454
455 let mut scored: Vec<(&FactEmbedding, f32)> = index
456 .entries
457 .iter()
458 .filter(|e| !(e.category == new_category && e.key == new_key))
459 .map(|e| (e, e.similarity(query)))
460 .filter(|(_, sim)| *sim >= threshold)
461 .collect();
462 scored.sort_by(|a, b| {
463 b.1.partial_cmp(&a.1)
464 .unwrap_or(std::cmp::Ordering::Equal)
465 .then_with(|| a.0.category.cmp(&b.0.category))
466 .then_with(|| a.0.key.cmp(&b.0.key))
467 });
468
469 let mut out: Vec<SimilarFact> = Vec::new();
470 for (entry, sim) in scored {
471 let other_key = format!("{}/{}", entry.category, entry.key);
472 let already_judged = knowledge.judged_pairs.iter().any(|jp| {
473 (jp.key_a == composite_key && jp.key_b == other_key)
474 || (jp.key_a == other_key && jp.key_b == composite_key)
475 });
476 if already_judged {
477 continue;
478 }
479 let Some(fact) = knowledge
480 .facts
481 .iter()
482 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
483 else {
484 continue;
485 };
486 let preview = if fact.value.len() > 60 {
487 format!("{}...", &fact.value[..fact.value.floor_char_boundary(57)])
488 } else {
489 fact.value.clone()
490 };
491 out.push(SimilarFact {
492 category: fact.category.clone(),
493 key: fact.key.clone(),
494 value_preview: preview,
495 similarity: sim,
496 });
497 if out.len() >= limit {
498 break;
499 }
500 }
501 out
502}
503
504pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
505 if results.is_empty() {
506 return "No matching facts found.".to_string();
507 }
508
509 let mut output = String::new();
510 for (i, scored) in results.iter().enumerate() {
511 let f = scored.fact;
512 let stars = if f.confidence >= 0.9 {
513 "★★★★"
514 } else if f.confidence >= 0.7 {
515 "★★★"
516 } else if f.confidence >= 0.5 {
517 "★★"
518 } else {
519 "★"
520 };
521
522 if i > 0 {
523 output.push('|');
524 }
525 output.push_str(&format!(
526 "{}:{}={}{} [s:{:.0}%]",
527 f.category,
528 f.key,
529 f.value,
530 stars,
531 scored.score * 100.0
532 ));
533 }
534 output
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::core::knowledge::KnowledgeArchetype;
541
542 #[test]
543 fn reset_removes_index_file() {
544 let _lock = crate::core::data_dir::test_env_lock();
545 let tmp = tempfile::tempdir().expect("tempdir");
546 crate::test_env::set_var(
547 "LEAN_CTX_DATA_DIR",
548 tmp.path().to_string_lossy().to_string(),
549 );
550
551 let idx = KnowledgeEmbeddingIndex {
552 project_hash: "projhash".to_string(),
553 entries: vec![FactEmbedding {
554 category: "arch".to_string(),
555 key: "db".to_string(),
556 embedding: vec![1.0, 0.0, 0.0],
557 quant: None,
558 }],
559 };
560 idx.save().expect("save");
561 assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
562
563 reset("projhash").expect("reset");
564 assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
565
566 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
567 }
568
569 #[test]
570 fn concurrent_remember_keeps_all_embeddings() {
571 let _lock = crate::core::data_dir::test_env_lock();
578 let tmp = tempfile::tempdir().expect("tempdir");
579 crate::test_env::set_var(
580 "LEAN_CTX_DATA_DIR",
581 tmp.path().to_string_lossy().to_string(),
582 );
583
584 let project = tmp.path().join("proj");
585 std::fs::create_dir_all(&project).expect("mkdir");
586 let project_root = project.to_string_lossy().to_string();
587
588 const N: usize = 16;
589 let mut handles = Vec::with_capacity(N);
590 for i in 0..N {
591 let root = project_root.clone();
592 handles.push(std::thread::spawn(move || {
593 let policy = MemoryPolicy::default();
594 let cat = "arch";
595 let key = format!("k{i}");
596 let (knowledge, ()) = ProjectKnowledge::mutate_locked(&root, |kn| {
598 kn.remember(cat, &key, "v", "s", 0.9, &policy);
599 })
600 .expect("commit fact");
601 ProjectKnowledge::with_project_lock(&root, || {
604 let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
605 .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
606 idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
607 let fresh = ProjectKnowledge::load(&root);
608 let kref = fresh.as_ref().unwrap_or(&knowledge);
609 compact_against_knowledge(&mut idx, kref, &policy);
610 idx.save().expect("save index");
611 });
612 }));
613 }
614 for h in handles {
615 h.join().expect("thread join");
616 }
617
618 let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
619 let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
620 assert_eq!(current, N, "all {N} facts must be committed");
621
622 let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
623 assert_eq!(
624 idx.entries.len(),
625 N,
626 "every concurrently-stored embedding must survive (got {})",
627 idx.entries.len()
628 );
629
630 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
631 }
632
633 #[test]
634 fn compact_drops_missing_or_archived_facts() {
635 let mut knowledge = ProjectKnowledge::new("/tmp/project");
636 let now = chrono::Utc::now();
637 knowledge.facts.push(KnowledgeFact {
638 category: "arch".to_string(),
639 key: "db".to_string(),
640 value: "Postgres".to_string(),
641 source_session: "s".to_string(),
642 confidence: 0.9,
643 created_at: now,
644 last_confirmed: now,
645 retrieval_count: 5,
646 last_retrieved: None,
647 valid_from: None,
648 valid_until: None,
649 supersedes: None,
650 confirmation_count: 1,
651 feedback_up: 0,
652 feedback_down: 0,
653 last_feedback: None,
654 privacy: crate::core::memory_boundary::FactPrivacy::default(),
655 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
656 imported_from: None,
657 archetype: KnowledgeArchetype::default(),
658 fidelity: None,
659 revision_count: 0,
660 });
661 knowledge.facts.push(KnowledgeFact {
662 category: "arch".to_string(),
663 key: "old".to_string(),
664 value: "Old".to_string(),
665 source_session: "s".to_string(),
666 confidence: 0.9,
667 created_at: now,
668 last_confirmed: now,
669 retrieval_count: 0,
670 last_retrieved: None,
671 valid_from: None,
672 valid_until: Some(now),
673 supersedes: None,
674 confirmation_count: 1,
675 feedback_up: 0,
676 feedback_down: 0,
677 last_feedback: None,
678 privacy: crate::core::memory_boundary::FactPrivacy::default(),
679 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
680 imported_from: None,
681 archetype: KnowledgeArchetype::default(),
682 fidelity: None,
683 revision_count: 0,
684 });
685
686 let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
687 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
688 idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
689 idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
690
691 compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
692 assert_eq!(idx.entries.len(), 1);
693 assert_eq!(idx.entries[0].category, "arch");
694 assert_eq!(idx.entries[0].key, "db");
695 }
696
697 #[test]
698 fn index_upsert_and_remove() {
699 let mut idx = KnowledgeEmbeddingIndex::new("test");
700 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
701 assert_eq!(idx.entries.len(), 1);
702
703 idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
704 assert_eq!(idx.entries.len(), 1);
705 let recon = idx.entries[0]
707 .quant
708 .as_ref()
709 .expect("quantized")
710 .dequantize();
711 assert!((recon[1] - 1.0).abs() < 1e-6);
712
713 idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
714 assert_eq!(idx.entries.len(), 2);
715
716 idx.remove("arch", "db");
717 assert_eq!(idx.entries.len(), 1);
718 assert_eq!(idx.entries[0].key, "cache");
719 }
720
721 #[test]
722 fn recency_decay_recent() {
723 let fact = KnowledgeFact {
724 category: "test".to_string(),
725 key: "k".to_string(),
726 value: "v".to_string(),
727 source_session: "s".to_string(),
728 confidence: 0.9,
729 created_at: chrono::Utc::now(),
730 last_confirmed: chrono::Utc::now(),
731 retrieval_count: 0,
732 last_retrieved: None,
733 valid_from: None,
734 valid_until: None,
735 supersedes: None,
736 confirmation_count: 1,
737 feedback_up: 0,
738 feedback_down: 0,
739 last_feedback: None,
740 privacy: crate::core::memory_boundary::FactPrivacy::default(),
741 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
742 imported_from: None,
743 archetype: KnowledgeArchetype::default(),
744 fidelity: None,
745 revision_count: 0,
746 };
747 let decay = recency_decay(&fact);
748 assert!(
749 decay > 0.95,
750 "Recent fact should have high recency: {decay}"
751 );
752 }
753
754 #[test]
755 fn recency_decay_old() {
756 let old_date = chrono::Utc::now() - chrono::Duration::days(100);
757 let fact = KnowledgeFact {
758 category: "test".to_string(),
759 key: "k".to_string(),
760 value: "v".to_string(),
761 source_session: "s".to_string(),
762 confidence: 0.5,
763 created_at: old_date,
764 last_confirmed: old_date,
765 retrieval_count: 0,
766 last_retrieved: None,
767 valid_from: None,
768 valid_until: None,
769 supersedes: None,
770 confirmation_count: 1,
771 feedback_up: 0,
772 feedback_down: 0,
773 last_feedback: None,
774 privacy: crate::core::memory_boundary::FactPrivacy::default(),
775 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
776 imported_from: None,
777 archetype: KnowledgeArchetype::default(),
778 fidelity: None,
779 revision_count: 0,
780 };
781 let decay = recency_decay(&fact);
782 assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
783 }
784
785 #[cfg(feature = "embeddings")]
786 #[test]
787 fn semantic_search_ranking() {
788 let mut idx = KnowledgeEmbeddingIndex::new("test");
789 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
790 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
791 idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
792
793 let query = vec![1.0, 0.0, 0.0];
794 let results = idx.semantic_search(&query, 2);
795 assert_eq!(results.len(), 2);
796 assert_eq!(results[0].0.key, "db");
797 }
798
799 #[test]
800 fn format_scored_empty() {
801 assert_eq!(format_scored_facts(&[]), "No matching facts found.");
802 }
803
804 #[test]
805 fn format_scored_output() {
806 let fact = KnowledgeFact {
807 category: "arch".to_string(),
808 key: "db".to_string(),
809 value: "PostgreSQL".to_string(),
810 source_session: "s1".to_string(),
811 confidence: 0.95,
812 created_at: chrono::Utc::now(),
813 last_confirmed: chrono::Utc::now(),
814 retrieval_count: 0,
815 last_retrieved: None,
816 valid_from: None,
817 valid_until: None,
818 supersedes: None,
819 confirmation_count: 3,
820 feedback_up: 0,
821 feedback_down: 0,
822 last_feedback: None,
823 privacy: crate::core::memory_boundary::FactPrivacy::default(),
824 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
825 imported_from: None,
826 archetype: KnowledgeArchetype::default(),
827 fidelity: None,
828 revision_count: 0,
829 };
830 let scored = vec![ScoredFact {
831 fact: &fact,
832 score: 0.85,
833 semantic_score: 0.9,
834 confidence_score: 0.95,
835 recency_score: 1.0,
836 }];
837 let output = format_scored_facts(&scored);
838 assert!(output.contains("arch:db=PostgreSQL"));
839 assert!(output.contains("★★★★"));
840 assert!(output.contains("[s:85%]"));
841 }
842
843 #[test]
844 fn semantic_dup_flags_high_cosine_other_key() {
845 let policy = MemoryPolicy::default();
846 let mut kn = ProjectKnowledge::new("/tmp/semdup-1");
847 kn.remember(
848 "arch",
849 "db",
850 "PostgreSQL is the primary database",
851 "s",
852 0.9,
853 &policy,
854 );
855 kn.remember(
856 "arch",
857 "cache",
858 "Redis is the cache layer",
859 "s",
860 0.9,
861 &policy,
862 );
863
864 let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
865 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
866 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
867
868 let query = [1.0, 0.0, 0.0];
870 let dups = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
871 assert_eq!(
872 dups.len(),
873 1,
874 "only the near-identical entry clears threshold"
875 );
876 assert_eq!(dups[0].key, "db");
877 assert!(dups[0].similarity >= 0.86);
878 }
879
880 #[test]
881 fn semantic_dup_excludes_self_and_judged() {
882 let policy = MemoryPolicy::default();
883 let mut kn = ProjectKnowledge::new("/tmp/semdup-2");
884 kn.remember(
885 "arch",
886 "db",
887 "PostgreSQL primary database",
888 "s",
889 0.9,
890 &policy,
891 );
892
893 let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
894 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
895 let query = [1.0, 0.0, 0.0];
896
897 let self_hits = semantic_duplicates_from_query(&idx, &kn, "arch", "db", &query, 0.86, 3);
899 assert!(self_hits.is_empty(), "a fact is never its own duplicate");
900
901 let other = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
903 assert_eq!(other.len(), 1);
904
905 kn.judged_pairs.push(crate::core::knowledge::JudgedPair {
906 key_a: "arch/database".to_string(),
907 key_b: "arch/db".to_string(),
908 verdict: "unrelated".to_string(),
909 judged_at: chrono::Utc::now(),
910 });
911 let judged = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
912 assert!(judged.is_empty(), "already-judged pairs are not re-flagged");
913 }
914}