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