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
430pub const BACKFILL_PER_REMEMBER: usize = 32;
434
435pub fn missing_current_facts<'a>(
439 index: &KnowledgeEmbeddingIndex,
440 knowledge: &'a ProjectKnowledge,
441 cap: usize,
442) -> Vec<&'a KnowledgeFact> {
443 use std::collections::HashSet;
444
445 let have: HashSet<(&str, &str)> = index
446 .entries
447 .iter()
448 .map(|e| (e.category.as_str(), e.key.as_str()))
449 .collect();
450
451 let mut missing: Vec<&KnowledgeFact> = knowledge
452 .facts
453 .iter()
454 .filter(|f| f.is_current() && !have.contains(&(f.category.as_str(), f.key.as_str())))
455 .collect();
456 missing.sort_by(|a, b| {
457 b.confidence
458 .partial_cmp(&a.confidence)
459 .unwrap_or(std::cmp::Ordering::Equal)
460 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
461 .then_with(|| a.category.cmp(&b.category))
462 .then_with(|| a.key.cmp(&b.key))
463 });
464 missing.truncate(cap);
465 missing
466}
467
468#[cfg(feature = "embeddings")]
477pub fn backfill_missing(
478 index: &mut KnowledgeEmbeddingIndex,
479 engine: &EmbeddingEngine,
480 knowledge: &ProjectKnowledge,
481 cap: usize,
482) -> usize {
483 let missing = missing_current_facts(index, knowledge, cap);
484 if missing.is_empty() {
485 return 0;
486 }
487
488 let texts: Vec<String> = missing
489 .iter()
490 .map(|f| format!("{} {}: {}", f.category, f.key, f.value))
491 .collect();
492 let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
493 let Ok(vectors) = engine.embed_batch(&refs) else {
494 return 0;
495 };
496
497 let mut stored = 0usize;
498 for (fact, vector) in missing.iter().zip(vectors) {
499 index.upsert(&fact.category, &fact.key, &vector);
500 stored += 1;
501 }
502 stored
503}
504
505#[cfg(feature = "embeddings")]
513pub fn find_semantic_duplicates(
514 index: &KnowledgeEmbeddingIndex,
515 engine: &EmbeddingEngine,
516 knowledge: &ProjectKnowledge,
517 new_category: &str,
518 new_key: &str,
519 new_value: &str,
520 threshold: f32,
521 limit: usize,
522) -> Vec<crate::core::knowledge::SimilarFact> {
523 let text = format!("{new_category} {new_key}: {new_value}");
526 let Ok(query) = engine.embed(&text) else {
527 return Vec::new();
528 };
529 semantic_duplicates_from_query(
530 index,
531 knowledge,
532 new_category,
533 new_key,
534 &query,
535 threshold,
536 limit,
537 )
538}
539
540fn semantic_duplicates_from_query(
546 index: &KnowledgeEmbeddingIndex,
547 knowledge: &ProjectKnowledge,
548 new_category: &str,
549 new_key: &str,
550 query: &[f32],
551 threshold: f32,
552 limit: usize,
553) -> Vec<crate::core::knowledge::SimilarFact> {
554 use crate::core::knowledge::SimilarFact;
555
556 let composite_key = format!("{new_category}/{new_key}");
557
558 let mut scored: Vec<(&FactEmbedding, f32)> = index
559 .entries
560 .iter()
561 .filter(|e| !(e.category == new_category && e.key == new_key))
562 .map(|e| (e, e.similarity(query)))
563 .filter(|(_, sim)| *sim >= threshold)
564 .collect();
565 scored.sort_by(|a, b| {
566 b.1.partial_cmp(&a.1)
567 .unwrap_or(std::cmp::Ordering::Equal)
568 .then_with(|| a.0.category.cmp(&b.0.category))
569 .then_with(|| a.0.key.cmp(&b.0.key))
570 });
571
572 let mut out: Vec<SimilarFact> = Vec::new();
573 for (entry, sim) in scored {
574 let other_key = format!("{}/{}", entry.category, entry.key);
575 let already_judged = knowledge.judged_pairs.iter().any(|jp| {
576 (jp.key_a == composite_key && jp.key_b == other_key)
577 || (jp.key_a == other_key && jp.key_b == composite_key)
578 });
579 if already_judged {
580 continue;
581 }
582 let Some(fact) = knowledge
583 .facts
584 .iter()
585 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
586 else {
587 continue;
588 };
589 let preview = if fact.value.len() > 60 {
590 format!("{}...", &fact.value[..fact.value.floor_char_boundary(57)])
591 } else {
592 fact.value.clone()
593 };
594 out.push(SimilarFact {
595 category: fact.category.clone(),
596 key: fact.key.clone(),
597 value_preview: preview,
598 similarity: sim,
599 });
600 if out.len() >= limit {
601 break;
602 }
603 }
604 out
605}
606
607pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
608 if results.is_empty() {
609 return "No matching facts found.".to_string();
610 }
611
612 let mut output = String::new();
613 for (i, scored) in results.iter().enumerate() {
614 let f = scored.fact;
615 let stars = if f.confidence >= 0.9 {
616 "★★★★"
617 } else if f.confidence >= 0.7 {
618 "★★★"
619 } else if f.confidence >= 0.5 {
620 "★★"
621 } else {
622 "★"
623 };
624
625 if i > 0 {
626 output.push('|');
627 }
628 output.push_str(&format!(
629 "{}:{}={}{} [s:{:.0}%]",
630 f.category,
631 f.key,
632 f.value,
633 stars,
634 scored.score * 100.0
635 ));
636 }
637 output
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use crate::core::knowledge::KnowledgeArchetype;
644
645 fn fact_with(category: &str, key: &str, source: &str) -> KnowledgeFact {
646 let now = chrono::Utc::now();
647 KnowledgeFact {
648 category: category.to_string(),
649 key: key.to_string(),
650 value: "v".to_string(),
651 source_session: source.to_string(),
652 confidence: 0.8,
653 created_at: now,
654 last_confirmed: now,
655 retrieval_count: 0,
656 last_retrieved: None,
657 valid_from: None,
658 valid_until: None,
659 supersedes: None,
660 confirmation_count: 0,
661 feedback_up: 0,
662 feedback_down: 0,
663 last_feedback: None,
664 privacy: crate::core::memory_boundary::FactPrivacy::default(),
665 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
666 imported_from: None,
667 archetype: KnowledgeArchetype::infer_from_category(category),
668 fidelity: None,
669 revision_count: 0,
670 }
671 }
672
673 #[test]
674 fn observation_tier_boosts_only_synthesized_summaries() {
675 use crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE;
676 let obs = fact_with(
678 "observation",
679 "src/auth/session.rs",
680 COGNITION_SYNTHESIS_SOURCE,
681 );
682 let user_finding = fact_with("observation", "src/auth/session.rs:1", "session-7");
684 let raw = fact_with("gotcha", "src/auth/session.rs:2", "session-7");
686
687 assert!((apply_observation_tier(0.5, &raw) - 0.5).abs() < 1e-6);
688 assert!((apply_observation_tier(0.5, &user_finding) - 0.5).abs() < 1e-6);
689 assert!((apply_observation_tier(0.5, &obs) - (0.5 + OBSERVATION_TIER_BOOST)).abs() < 1e-6);
690 assert!(apply_observation_tier(0.5, &obs) > apply_observation_tier(0.5, &raw));
693 assert!(apply_observation_tier(0.8, &obs) < 1.0);
694 }
695
696 #[test]
697 fn reset_removes_index_file() {
698 let _lock = crate::core::data_dir::test_env_lock();
699 let tmp = tempfile::tempdir().expect("tempdir");
700 crate::test_env::set_var(
701 "LEAN_CTX_DATA_DIR",
702 tmp.path().to_string_lossy().to_string(),
703 );
704
705 let idx = KnowledgeEmbeddingIndex {
706 project_hash: "projhash".to_string(),
707 entries: vec![FactEmbedding {
708 category: "arch".to_string(),
709 key: "db".to_string(),
710 embedding: vec![1.0, 0.0, 0.0],
711 quant: None,
712 }],
713 };
714 idx.save().expect("save");
715 assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
716
717 reset("projhash").expect("reset");
718 assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
719
720 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
721 }
722
723 #[test]
724 fn concurrent_remember_keeps_all_embeddings() {
725 let _lock = crate::core::data_dir::test_env_lock();
732 let tmp = tempfile::tempdir().expect("tempdir");
733 crate::test_env::set_var(
734 "LEAN_CTX_DATA_DIR",
735 tmp.path().to_string_lossy().to_string(),
736 );
737
738 let project = tmp.path().join("proj");
739 std::fs::create_dir_all(&project).expect("mkdir");
740 let project_root = project.to_string_lossy().to_string();
741
742 const N: usize = 16;
743 let mut handles = Vec::with_capacity(N);
744 for i in 0..N {
745 let root = project_root.clone();
746 handles.push(std::thread::spawn(move || {
747 let policy = MemoryPolicy::default();
748 let cat = "arch";
749 let key = format!("k{i}");
750 let (knowledge, ()) = ProjectKnowledge::mutate_locked(&root, |kn| {
752 kn.remember(cat, &key, "v", "s", 0.9, &policy);
753 })
754 .expect("commit fact");
755 ProjectKnowledge::with_project_lock(&root, || {
758 let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
759 .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
760 idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
761 let fresh = ProjectKnowledge::load(&root);
762 let kref = fresh.as_ref().unwrap_or(&knowledge);
763 compact_against_knowledge(&mut idx, kref, &policy);
764 idx.save().expect("save index");
765 });
766 }));
767 }
768 for h in handles {
769 h.join().expect("thread join");
770 }
771
772 let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
773 let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
774 assert_eq!(current, N, "all {N} facts must be committed");
775
776 let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
777 assert_eq!(
778 idx.entries.len(),
779 N,
780 "every concurrently-stored embedding must survive (got {})",
781 idx.entries.len()
782 );
783
784 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
785 }
786
787 #[test]
788 fn compact_drops_missing_or_archived_facts() {
789 let mut knowledge = ProjectKnowledge::new("/tmp/project");
790 let now = chrono::Utc::now();
791 knowledge.facts.push(KnowledgeFact {
792 category: "arch".to_string(),
793 key: "db".to_string(),
794 value: "Postgres".to_string(),
795 source_session: "s".to_string(),
796 confidence: 0.9,
797 created_at: now,
798 last_confirmed: now,
799 retrieval_count: 5,
800 last_retrieved: None,
801 valid_from: None,
802 valid_until: None,
803 supersedes: None,
804 confirmation_count: 1,
805 feedback_up: 0,
806 feedback_down: 0,
807 last_feedback: None,
808 privacy: crate::core::memory_boundary::FactPrivacy::default(),
809 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
810 imported_from: None,
811 archetype: KnowledgeArchetype::default(),
812 fidelity: None,
813 revision_count: 0,
814 });
815 knowledge.facts.push(KnowledgeFact {
816 category: "arch".to_string(),
817 key: "old".to_string(),
818 value: "Old".to_string(),
819 source_session: "s".to_string(),
820 confidence: 0.9,
821 created_at: now,
822 last_confirmed: now,
823 retrieval_count: 0,
824 last_retrieved: None,
825 valid_from: None,
826 valid_until: Some(now),
827 supersedes: None,
828 confirmation_count: 1,
829 feedback_up: 0,
830 feedback_down: 0,
831 last_feedback: None,
832 privacy: crate::core::memory_boundary::FactPrivacy::default(),
833 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
834 imported_from: None,
835 archetype: KnowledgeArchetype::default(),
836 fidelity: None,
837 revision_count: 0,
838 });
839
840 let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
841 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
842 idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
843 idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
844
845 compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
846 assert_eq!(idx.entries.len(), 1);
847 assert_eq!(idx.entries[0].category, "arch");
848 assert_eq!(idx.entries[0].key, "db");
849 }
850
851 #[test]
852 fn missing_current_facts_selects_unindexed_by_confidence_capped() {
853 let mut knowledge = ProjectKnowledge::new("/tmp/project");
854
855 let indexed = fact_with("arch", "indexed", "s");
856 let mut high = fact_with("arch", "high", "s");
857 high.confidence = 0.95;
858 let mut low = fact_with("arch", "low", "s");
859 low.confidence = 0.5;
860 let mut archived = fact_with("arch", "archived", "s");
861 archived.valid_until = Some(chrono::Utc::now());
862 knowledge.facts.extend([indexed, high, low, archived]);
863
864 let mut idx = KnowledgeEmbeddingIndex::new("test");
865 idx.upsert("arch", "indexed", &[1.0, 0.0, 0.0]);
866
867 let missing = missing_current_facts(&idx, &knowledge, 10);
870 let keys: Vec<&str> = missing.iter().map(|f| f.key.as_str()).collect();
871 assert_eq!(keys, vec!["high", "low"]);
872
873 let capped = missing_current_facts(&idx, &knowledge, 1);
875 assert_eq!(capped.len(), 1);
876 assert_eq!(capped[0].key, "high");
877
878 idx.upsert("arch", "high", &[0.0, 1.0, 0.0]);
880 idx.upsert("arch", "low", &[0.0, 0.0, 1.0]);
881 assert!(missing_current_facts(&idx, &knowledge, 10).is_empty());
882 }
883
884 #[test]
885 fn index_upsert_and_remove() {
886 let mut idx = KnowledgeEmbeddingIndex::new("test");
887 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
888 assert_eq!(idx.entries.len(), 1);
889
890 idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
891 assert_eq!(idx.entries.len(), 1);
892 let recon = idx.entries[0]
894 .quant
895 .as_ref()
896 .expect("quantized")
897 .dequantize();
898 assert!((recon[1] - 1.0).abs() < 1e-6);
899
900 idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
901 assert_eq!(idx.entries.len(), 2);
902
903 idx.remove("arch", "db");
904 assert_eq!(idx.entries.len(), 1);
905 assert_eq!(idx.entries[0].key, "cache");
906 }
907
908 #[test]
909 fn recency_decay_recent() {
910 let fact = KnowledgeFact {
911 category: "test".to_string(),
912 key: "k".to_string(),
913 value: "v".to_string(),
914 source_session: "s".to_string(),
915 confidence: 0.9,
916 created_at: chrono::Utc::now(),
917 last_confirmed: chrono::Utc::now(),
918 retrieval_count: 0,
919 last_retrieved: None,
920 valid_from: None,
921 valid_until: None,
922 supersedes: None,
923 confirmation_count: 1,
924 feedback_up: 0,
925 feedback_down: 0,
926 last_feedback: None,
927 privacy: crate::core::memory_boundary::FactPrivacy::default(),
928 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
929 imported_from: None,
930 archetype: KnowledgeArchetype::default(),
931 fidelity: None,
932 revision_count: 0,
933 };
934 let decay = recency_decay(&fact);
935 assert!(
936 decay > 0.95,
937 "Recent fact should have high recency: {decay}"
938 );
939 }
940
941 #[test]
942 fn recency_decay_old() {
943 let old_date = chrono::Utc::now() - chrono::Duration::days(100);
944 let fact = KnowledgeFact {
945 category: "test".to_string(),
946 key: "k".to_string(),
947 value: "v".to_string(),
948 source_session: "s".to_string(),
949 confidence: 0.5,
950 created_at: old_date,
951 last_confirmed: old_date,
952 retrieval_count: 0,
953 last_retrieved: None,
954 valid_from: None,
955 valid_until: None,
956 supersedes: None,
957 confirmation_count: 1,
958 feedback_up: 0,
959 feedback_down: 0,
960 last_feedback: None,
961 privacy: crate::core::memory_boundary::FactPrivacy::default(),
962 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
963 imported_from: None,
964 archetype: KnowledgeArchetype::default(),
965 fidelity: None,
966 revision_count: 0,
967 };
968 let decay = recency_decay(&fact);
969 assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
970 }
971
972 #[cfg(feature = "embeddings")]
973 #[test]
974 fn semantic_search_ranking() {
975 let mut idx = KnowledgeEmbeddingIndex::new("test");
976 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
977 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
978 idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
979
980 let query = vec![1.0, 0.0, 0.0];
981 let results = idx.semantic_search(&query, 2);
982 assert_eq!(results.len(), 2);
983 assert_eq!(results[0].0.key, "db");
984 }
985
986 #[test]
987 fn format_scored_empty() {
988 assert_eq!(format_scored_facts(&[]), "No matching facts found.");
989 }
990
991 #[test]
992 fn format_scored_output() {
993 let fact = KnowledgeFact {
994 category: "arch".to_string(),
995 key: "db".to_string(),
996 value: "PostgreSQL".to_string(),
997 source_session: "s1".to_string(),
998 confidence: 0.95,
999 created_at: chrono::Utc::now(),
1000 last_confirmed: chrono::Utc::now(),
1001 retrieval_count: 0,
1002 last_retrieved: None,
1003 valid_from: None,
1004 valid_until: None,
1005 supersedes: None,
1006 confirmation_count: 3,
1007 feedback_up: 0,
1008 feedback_down: 0,
1009 last_feedback: None,
1010 privacy: crate::core::memory_boundary::FactPrivacy::default(),
1011 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
1012 imported_from: None,
1013 archetype: KnowledgeArchetype::default(),
1014 fidelity: None,
1015 revision_count: 0,
1016 };
1017 let scored = vec![ScoredFact {
1018 fact: &fact,
1019 score: 0.85,
1020 semantic_score: 0.9,
1021 confidence_score: 0.95,
1022 recency_score: 1.0,
1023 }];
1024 let output = format_scored_facts(&scored);
1025 assert!(output.contains("arch:db=PostgreSQL"));
1026 assert!(output.contains("★★★★"));
1027 assert!(output.contains("[s:85%]"));
1028 }
1029
1030 #[test]
1031 fn semantic_dup_flags_high_cosine_other_key() {
1032 let policy = MemoryPolicy::default();
1033 let mut kn = ProjectKnowledge::new("/tmp/semdup-1");
1034 kn.remember(
1035 "arch",
1036 "db",
1037 "PostgreSQL is the primary database",
1038 "s",
1039 0.9,
1040 &policy,
1041 );
1042 kn.remember(
1043 "arch",
1044 "cache",
1045 "Redis is the cache layer",
1046 "s",
1047 0.9,
1048 &policy,
1049 );
1050
1051 let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1052 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1053 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
1054
1055 let query = [1.0, 0.0, 0.0];
1057 let dups = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1058 assert_eq!(
1059 dups.len(),
1060 1,
1061 "only the near-identical entry clears threshold"
1062 );
1063 assert_eq!(dups[0].key, "db");
1064 assert!(dups[0].similarity >= 0.86);
1065 }
1066
1067 #[test]
1068 fn semantic_dup_excludes_self_and_judged() {
1069 let policy = MemoryPolicy::default();
1070 let mut kn = ProjectKnowledge::new("/tmp/semdup-2");
1071 kn.remember(
1072 "arch",
1073 "db",
1074 "PostgreSQL primary database",
1075 "s",
1076 0.9,
1077 &policy,
1078 );
1079
1080 let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1081 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1082 let query = [1.0, 0.0, 0.0];
1083
1084 let self_hits = semantic_duplicates_from_query(&idx, &kn, "arch", "db", &query, 0.86, 3);
1086 assert!(self_hits.is_empty(), "a fact is never its own duplicate");
1087
1088 let other = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1090 assert_eq!(other.len(), 1);
1091
1092 kn.judged_pairs.push(crate::core::knowledge::JudgedPair {
1093 key_a: "arch/database".to_string(),
1094 key_b: "arch/db".to_string(),
1095 verdict: "unrelated".to_string(),
1096 judged_at: chrono::Utc::now(),
1097 });
1098 let judged = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1099 assert!(judged.is_empty(), "already-judged pairs are not re-flagged");
1100 }
1101}