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
18#[cfg(feature = "embeddings")]
19const ALPHA_SEMANTIC: f32 = 0.6;
20#[cfg(feature = "embeddings")]
21const BETA_CONFIDENCE: f32 = 0.25;
22#[cfg(feature = "embeddings")]
23const GAMMA_RECENCY: f32 = 0.15;
24#[cfg(feature = "embeddings")]
31const OBSERVATION_TIER_BOOST: f32 = 0.15;
32#[cfg(feature = "embeddings")]
33const MAX_RECENCY_DAYS: f32 = 90.0;
34
35pub const SEMANTIC_DUP_THRESHOLD: f32 = 0.86;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FactEmbedding {
45 pub category: String,
46 pub key: String,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub embedding: Vec<f32>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub quant: Option<QuantizedVector>,
56}
57
58impl FactEmbedding {
59 #[cfg(feature = "embeddings")]
63 fn similarity(&self, query: &[f32]) -> f32 {
64 match &self.quant {
65 Some(q) => embedding_quant::dot_quant(query, q),
66 None => embedding_quant::dot_f32(query, &self.embedding),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct KnowledgeEmbeddingIndex {
73 pub project_hash: String,
74 pub entries: Vec<FactEmbedding>,
75}
76
77impl KnowledgeEmbeddingIndex {
78 pub fn new(project_hash: &str) -> Self {
79 Self {
80 project_hash: project_hash.to_string(),
81 entries: Vec::new(),
82 }
83 }
84
85 pub fn upsert(&mut self, category: &str, key: &str, embedding: &[f32]) {
86 let quant = Some(embedding_quant::quantize(embedding));
87 if let Some(existing) = self
88 .entries
89 .iter_mut()
90 .find(|e| e.category == category && e.key == key)
91 {
92 existing.quant = quant;
93 existing.embedding = Vec::new();
94 } else {
95 self.entries.push(FactEmbedding {
96 category: category.to_string(),
97 key: key.to_string(),
98 embedding: Vec::new(),
99 quant,
100 });
101 }
102 }
103
104 fn migrate_legacy_entries(&mut self) -> bool {
107 let mut changed = false;
108 for e in &mut self.entries {
109 if e.quant.is_none() && !e.embedding.is_empty() {
110 e.quant = Some(embedding_quant::quantize(&e.embedding));
111 e.embedding = Vec::new();
112 changed = true;
113 }
114 }
115 changed
116 }
117
118 pub fn remove(&mut self, category: &str, key: &str) {
119 self.entries
120 .retain(|e| !(e.category == category && e.key == key));
121 }
122
123 #[cfg(feature = "embeddings")]
124 pub fn semantic_search(
125 &self,
126 query_embedding: &[f32],
127 top_k: usize,
128 ) -> Vec<(&FactEmbedding, f32)> {
129 let mut scored: Vec<(&FactEmbedding, f32)> = self
130 .entries
131 .iter()
132 .map(|e| {
133 let sim = e.similarity(query_embedding);
134 (e, sim)
135 })
136 .collect();
137
138 scored.sort_by(|a, b| {
139 b.1.partial_cmp(&a.1)
140 .unwrap_or(std::cmp::Ordering::Equal)
141 .then_with(|| a.0.category.cmp(&b.0.category))
142 .then_with(|| a.0.key.cmp(&b.0.key))
143 });
144 scored.truncate(top_k);
145 scored
146 }
147
148 fn index_path(project_hash: &str) -> Option<PathBuf> {
149 let dir = crate::core::data_dir::lean_ctx_data_dir()
150 .ok()?
151 .join("knowledge")
152 .join(project_hash);
153 Some(dir.join("embeddings.json"))
154 }
155
156 pub fn load(project_hash: &str) -> Option<Self> {
157 let path = Self::index_path(project_hash)?;
158 let data = std::fs::read_to_string(path).ok()?;
159 let mut index: Self = serde_json::from_str(&data).ok()?;
160 if index.migrate_legacy_entries() {
163 let _ = index.save();
164 }
165 Some(index)
166 }
167
168 pub fn save(&self) -> Result<(), String> {
169 let path = Self::index_path(&self.project_hash)
170 .ok_or_else(|| "Cannot determine data directory".to_string())?;
171 let json = serde_json::to_string(self).map_err(|e| format!("{e}"))?;
172 crate::config_io::write_atomic(&path, &json)
177 }
178}
179
180pub fn reset(project_hash: &str) -> Result<(), String> {
181 let path = KnowledgeEmbeddingIndex::index_path(project_hash)
182 .ok_or_else(|| "Cannot determine data directory".to_string())?;
183 if path.exists() {
184 std::fs::remove_file(&path).map_err(|e| format!("{e}"))?;
185 }
186 Ok(())
187}
188
189#[derive(Debug)]
190pub struct ScoredFact<'a> {
191 pub fact: &'a KnowledgeFact,
192 pub score: f32,
193 pub semantic_score: f32,
194 pub confidence_score: f32,
195 pub recency_score: f32,
196}
197
198#[cfg(feature = "embeddings")]
199pub fn semantic_recall<'a>(
200 knowledge: &'a ProjectKnowledge,
201 index: &KnowledgeEmbeddingIndex,
202 engine: &EmbeddingEngine,
203 query: &str,
204 top_k: usize,
205) -> Vec<ScoredFact<'a>> {
206 let Ok(query_embedding) = engine.embed_query(query) else {
207 return lexical_fallback(knowledge, query, top_k);
208 };
209
210 let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
211
212 let mut results: Vec<ScoredFact<'a>> = Vec::new();
213
214 for (entry, sim) in &semantic_hits {
215 if let Some(fact) = knowledge
216 .facts
217 .iter()
218 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
219 {
220 let confidence_score = fact.quality_score();
221 let recency_score = recency_decay(fact);
222 let score = apply_observation_tier(
225 ALPHA_SEMANTIC * sim
226 + BETA_CONFIDENCE * confidence_score
227 + GAMMA_RECENCY * recency_score,
228 fact,
229 );
230
231 results.push(ScoredFact {
232 fact,
233 score,
234 semantic_score: *sim,
235 confidence_score,
236 recency_score,
237 });
238 }
239 }
240
241 let exact_matches = knowledge.recall(query);
242 for fact in exact_matches {
243 let already_included = results
244 .iter()
245 .any(|r| r.fact.category == fact.category && r.fact.key == fact.key);
246 if !already_included {
247 results.push(ScoredFact {
248 fact,
249 score: 1.0,
250 semantic_score: 1.0,
251 confidence_score: fact.quality_score(),
252 recency_score: recency_decay(fact),
253 });
254 }
255 }
256
257 results.sort_by(|a, b| {
258 b.score
259 .partial_cmp(&a.score)
260 .unwrap_or(std::cmp::Ordering::Equal)
261 .then_with(|| {
262 b.confidence_score
263 .partial_cmp(&a.confidence_score)
264 .unwrap_or(std::cmp::Ordering::Equal)
265 })
266 .then_with(|| {
267 b.recency_score
268 .partial_cmp(&a.recency_score)
269 .unwrap_or(std::cmp::Ordering::Equal)
270 })
271 .then_with(|| a.fact.category.cmp(&b.fact.category))
272 .then_with(|| a.fact.key.cmp(&b.fact.key))
273 .then_with(|| a.fact.value.cmp(&b.fact.value))
274 });
275 results.truncate(top_k);
276 results
277}
278
279#[cfg(feature = "embeddings")]
280pub fn semantic_recall_semantic_only<'a>(
281 knowledge: &'a ProjectKnowledge,
282 index: &KnowledgeEmbeddingIndex,
283 engine: &EmbeddingEngine,
284 query: &str,
285 top_k: usize,
286) -> Vec<ScoredFact<'a>> {
287 let Ok(query_embedding) = engine.embed_query(query) else {
288 return Vec::new();
289 };
290
291 let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
292 let mut results: Vec<ScoredFact<'a>> = Vec::new();
293
294 for (entry, sim) in &semantic_hits {
295 if let Some(fact) = knowledge
296 .facts
297 .iter()
298 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
299 {
300 let confidence_score = fact.quality_score();
301 let recency_score = recency_decay(fact);
302 let score = apply_observation_tier(
305 ALPHA_SEMANTIC * sim
306 + BETA_CONFIDENCE * confidence_score
307 + GAMMA_RECENCY * recency_score,
308 fact,
309 );
310
311 results.push(ScoredFact {
312 fact,
313 score,
314 semantic_score: *sim,
315 confidence_score,
316 recency_score,
317 });
318 }
319 }
320
321 results.sort_by(|a, b| {
322 b.score
323 .partial_cmp(&a.score)
324 .unwrap_or(std::cmp::Ordering::Equal)
325 .then_with(|| {
326 b.confidence_score
327 .partial_cmp(&a.confidence_score)
328 .unwrap_or(std::cmp::Ordering::Equal)
329 })
330 .then_with(|| {
331 b.recency_score
332 .partial_cmp(&a.recency_score)
333 .unwrap_or(std::cmp::Ordering::Equal)
334 })
335 .then_with(|| a.fact.category.cmp(&b.fact.category))
336 .then_with(|| a.fact.key.cmp(&b.fact.key))
337 .then_with(|| a.fact.value.cmp(&b.fact.value))
338 });
339 results.truncate(top_k);
340 results
341}
342
343pub fn compact_against_knowledge(
344 index: &mut KnowledgeEmbeddingIndex,
345 knowledge: &ProjectKnowledge,
346 policy: &MemoryPolicy,
347) {
348 use std::collections::HashMap;
349
350 let mut current: HashMap<(&str, &str), &KnowledgeFact> = HashMap::new();
351 for f in &knowledge.facts {
352 if f.is_current() {
353 current.insert((f.category.as_str(), f.key.as_str()), f);
354 }
355 }
356
357 let mut kept: Vec<(FactEmbedding, &KnowledgeFact)> = index
358 .entries
359 .iter()
360 .filter_map(|e| {
361 current
362 .get(&(e.category.as_str(), e.key.as_str()))
363 .map(|f| (e.clone(), *f))
364 })
365 .collect();
366
367 kept.sort_by(|(ea, fa), (eb, fb)| {
368 fb.confidence
369 .partial_cmp(&fa.confidence)
370 .unwrap_or(std::cmp::Ordering::Equal)
371 .then_with(|| fb.last_confirmed.cmp(&fa.last_confirmed))
372 .then_with(|| fb.retrieval_count.cmp(&fa.retrieval_count))
373 .then_with(|| ea.category.cmp(&eb.category))
374 .then_with(|| ea.key.cmp(&eb.key))
375 });
376
377 let max = policy.embeddings.max_facts;
378 if kept.len() > max {
379 kept.truncate(max);
380 }
381
382 index.entries = kept.into_iter().map(|(e, _)| e).collect();
383}
384
385#[cfg(feature = "embeddings")]
386fn lexical_fallback<'a>(
387 knowledge: &'a ProjectKnowledge,
388 query: &str,
389 top_k: usize,
390) -> Vec<ScoredFact<'a>> {
391 knowledge
392 .recall(query)
393 .into_iter()
394 .take(top_k)
395 .map(|fact| ScoredFact {
396 fact,
397 score: fact.confidence,
398 semantic_score: 0.0,
399 confidence_score: fact.confidence,
400 recency_score: recency_decay(fact),
401 })
402 .collect()
403}
404
405#[cfg(feature = "embeddings")]
406fn recency_decay(fact: &KnowledgeFact) -> f32 {
407 let days_old = chrono::Utc::now()
408 .signed_duration_since(fact.last_confirmed)
409 .num_days() as f32;
410 (1.0 - days_old / MAX_RECENCY_DAYS).max(0.0)
411}
412
413#[cfg(feature = "embeddings")]
417fn apply_observation_tier(base: f32, fact: &KnowledgeFact) -> f32 {
418 if fact.is_synthesized_observation() {
419 base + OBSERVATION_TIER_BOOST
420 } else {
421 base
422 }
423}
424
425#[cfg(feature = "embeddings")]
426pub fn embed_and_store(
427 index: &mut KnowledgeEmbeddingIndex,
428 engine: &EmbeddingEngine,
429 category: &str,
430 key: &str,
431 value: &str,
432) -> Result<(), String> {
433 let text = format!("{category} {key}: {value}");
434 let embedding = engine.embed(&text).map_err(|e| format!("{e}"))?;
435 index.upsert(category, key, &embedding);
436 Ok(())
437}
438
439pub const BACKFILL_PER_REMEMBER: usize = 32;
443
444pub fn missing_current_facts<'a>(
448 index: &KnowledgeEmbeddingIndex,
449 knowledge: &'a ProjectKnowledge,
450 cap: usize,
451) -> Vec<&'a KnowledgeFact> {
452 use std::collections::HashSet;
453
454 let have: HashSet<(&str, &str)> = index
455 .entries
456 .iter()
457 .map(|e| (e.category.as_str(), e.key.as_str()))
458 .collect();
459
460 let deliverable = knowledge.delivery_filter();
463 let mut missing: Vec<&KnowledgeFact> = knowledge
464 .facts
465 .iter()
466 .filter(|&f| {
467 f.is_current()
468 && !have.contains(&(f.category.as_str(), f.key.as_str()))
469 && deliverable(f)
470 })
471 .collect();
472 missing.sort_by(|a, b| {
473 b.confidence
474 .partial_cmp(&a.confidence)
475 .unwrap_or(std::cmp::Ordering::Equal)
476 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
477 .then_with(|| a.category.cmp(&b.category))
478 .then_with(|| a.key.cmp(&b.key))
479 });
480 missing.truncate(cap);
481 missing
482}
483
484#[cfg(feature = "embeddings")]
493pub fn backfill_missing(
494 index: &mut KnowledgeEmbeddingIndex,
495 engine: &EmbeddingEngine,
496 knowledge: &ProjectKnowledge,
497 cap: usize,
498) -> usize {
499 let missing = missing_current_facts(index, knowledge, cap);
500 if missing.is_empty() {
501 return 0;
502 }
503
504 let texts: Vec<String> = missing
505 .iter()
506 .map(|f| format!("{} {}: {}", f.category, f.key, f.value))
507 .collect();
508 let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
509 let Ok(vectors) = engine.embed_batch(&refs) else {
510 return 0;
511 };
512
513 let mut stored = 0usize;
514 for (fact, vector) in missing.iter().zip(vectors) {
515 index.upsert(&fact.category, &fact.key, &vector);
516 stored += 1;
517 }
518 stored
519}
520
521#[cfg(feature = "embeddings")]
529pub fn find_semantic_duplicates(
530 index: &KnowledgeEmbeddingIndex,
531 engine: &EmbeddingEngine,
532 knowledge: &ProjectKnowledge,
533 new_category: &str,
534 new_key: &str,
535 new_value: &str,
536 threshold: f32,
537 limit: usize,
538) -> Vec<crate::core::knowledge::SimilarFact> {
539 let text = format!("{new_category} {new_key}: {new_value}");
542 let Ok(query) = engine.embed(&text) else {
543 return Vec::new();
544 };
545 semantic_duplicates_from_query(
546 index,
547 knowledge,
548 new_category,
549 new_key,
550 &query,
551 threshold,
552 limit,
553 )
554}
555
556#[cfg(feature = "embeddings")]
562fn semantic_duplicates_from_query(
563 index: &KnowledgeEmbeddingIndex,
564 knowledge: &ProjectKnowledge,
565 new_category: &str,
566 new_key: &str,
567 query: &[f32],
568 threshold: f32,
569 limit: usize,
570) -> Vec<crate::core::knowledge::SimilarFact> {
571 use crate::core::knowledge::SimilarFact;
572
573 let composite_key = format!("{new_category}/{new_key}");
574
575 let mut scored: Vec<(&FactEmbedding, f32)> = index
576 .entries
577 .iter()
578 .filter(|e| !(e.category == new_category && e.key == new_key))
579 .map(|e| (e, e.similarity(query)))
580 .filter(|(_, sim)| *sim >= threshold)
581 .collect();
582 scored.sort_by(|a, b| {
583 b.1.partial_cmp(&a.1)
584 .unwrap_or(std::cmp::Ordering::Equal)
585 .then_with(|| a.0.category.cmp(&b.0.category))
586 .then_with(|| a.0.key.cmp(&b.0.key))
587 });
588
589 let mut out: Vec<SimilarFact> = Vec::new();
590 for (entry, sim) in scored {
591 let other_key = format!("{}/{}", entry.category, entry.key);
592 let already_judged = knowledge.judged_pairs.iter().any(|jp| {
593 (jp.key_a == composite_key && jp.key_b == other_key)
594 || (jp.key_a == other_key && jp.key_b == composite_key)
595 });
596 if already_judged {
597 continue;
598 }
599 let Some(fact) = knowledge
600 .facts
601 .iter()
602 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
603 else {
604 continue;
605 };
606 let preview = if fact.value.len() > 60 {
607 format!("{}...", &fact.value[..fact.value.floor_char_boundary(57)])
608 } else {
609 fact.value.clone()
610 };
611 out.push(SimilarFact {
612 category: fact.category.clone(),
613 key: fact.key.clone(),
614 value_preview: preview,
615 similarity: sim,
616 });
617 if out.len() >= limit {
618 break;
619 }
620 }
621 out
622}
623
624pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
625 if results.is_empty() {
626 return "No matching facts found.".to_string();
627 }
628
629 let mut output = String::new();
630 for (i, scored) in results.iter().enumerate() {
631 let f = scored.fact;
632 let stars = if f.confidence >= 0.9 {
633 "★★★★"
634 } else if f.confidence >= 0.7 {
635 "★★★"
636 } else if f.confidence >= 0.5 {
637 "★★"
638 } else {
639 "★"
640 };
641
642 if i > 0 {
643 output.push('|');
644 }
645 output.push_str(&format!(
646 "{}:{}={}{} [s:{:.0}%]",
647 f.category,
648 f.key,
649 f.value,
650 stars,
651 scored.score * 100.0
652 ));
653 }
654 output
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::core::knowledge::KnowledgeArchetype;
661
662 fn fact_with(category: &str, key: &str, source: &str) -> KnowledgeFact {
663 let now = chrono::Utc::now();
664 KnowledgeFact {
665 anchors: Vec::new(),
666 review: None,
667 capture: Default::default(),
668 origin: crate::core::knowledge::FactOrigin::Local,
669 category: category.to_string(),
670 key: key.to_string(),
671 value: "v".to_string(),
672 source_session: source.to_string(),
673 confidence: 0.8,
674 created_at: now,
675 last_confirmed: now,
676 retrieval_count: 0,
677 last_retrieved: None,
678 valid_from: None,
679 valid_until: None,
680 supersedes: None,
681 confirmation_count: 0,
682 feedback_up: 0,
683 feedback_down: 0,
684 last_feedback: None,
685 privacy: crate::core::memory_boundary::FactPrivacy::default(),
686 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
687 imported_from: None,
688 archetype: KnowledgeArchetype::infer_from_category(category),
689 fidelity: None,
690 revision_count: 0,
691 status: None,
692 validity: None,
693 }
694 }
695
696 #[cfg(feature = "embeddings")]
697 #[test]
698 fn observation_tier_boosts_only_synthesized_summaries() {
699 use crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE;
700 let obs = fact_with(
702 "observation",
703 "src/auth/session.rs",
704 COGNITION_SYNTHESIS_SOURCE,
705 );
706 let user_finding = fact_with("observation", "src/auth/session.rs:1", "session-7");
708 let raw = fact_with("gotcha", "src/auth/session.rs:2", "session-7");
710
711 assert!((apply_observation_tier(0.5, &raw) - 0.5).abs() < 1e-6);
712 assert!((apply_observation_tier(0.5, &user_finding) - 0.5).abs() < 1e-6);
713 assert!((apply_observation_tier(0.5, &obs) - (0.5 + OBSERVATION_TIER_BOOST)).abs() < 1e-6);
714 assert!(apply_observation_tier(0.5, &obs) > apply_observation_tier(0.5, &raw));
717 assert!(apply_observation_tier(0.8, &obs) < 1.0);
718 }
719
720 #[test]
721 fn reset_removes_index_file() {
722 let _lock = crate::core::data_dir::test_env_lock();
723 let tmp = tempfile::tempdir().expect("tempdir");
724 crate::test_env::set_var(
725 "LEAN_CTX_DATA_DIR",
726 tmp.path().to_string_lossy().to_string(),
727 );
728
729 let idx = KnowledgeEmbeddingIndex {
730 project_hash: "projhash".to_string(),
731 entries: vec![FactEmbedding {
732 category: "arch".to_string(),
733 key: "db".to_string(),
734 embedding: vec![1.0, 0.0, 0.0],
735 quant: None,
736 }],
737 };
738 idx.save().expect("save");
739 assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
740
741 reset("projhash").expect("reset");
742 assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
743
744 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
745 }
746
747 #[cfg(feature = "embeddings")]
748 #[test]
749 fn concurrent_remember_keeps_all_embeddings() {
750 let _lock = crate::core::data_dir::test_env_lock();
757 let tmp = tempfile::tempdir().expect("tempdir");
758 crate::test_env::set_var(
759 "LEAN_CTX_DATA_DIR",
760 tmp.path().to_string_lossy().to_string(),
761 );
762
763 let project = tmp.path().join("proj");
764 std::fs::create_dir_all(&project).expect("mkdir");
765 let project_root = project.to_string_lossy().to_string();
766
767 const N: usize = 16;
768 let mut handles = Vec::with_capacity(N);
769 for i in 0..N {
770 let root = project_root.clone();
771 handles.push(std::thread::spawn(move || {
772 let policy = MemoryPolicy::default();
773 let cat = "arch";
774 let key = format!("k{i}");
775 let mut attempts = 0;
781 let (knowledge, ()) = loop {
782 match ProjectKnowledge::mutate_locked(&root, |kn| {
783 kn.remember(cat, &key, "v", "s", 0.9, &policy);
784 }) {
785 Ok(committed) => break committed,
786 Err(error) if error.contains("lock unavailable") && attempts < 20 => {
787 attempts += 1;
788 }
789 Err(error) => panic!("commit fact: {error:?}"),
790 }
791 };
792 ProjectKnowledge::with_project_lock(&root, || {
795 let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
796 .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
797 idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
798 let fresh = ProjectKnowledge::load(&root);
799 let kref = fresh.as_ref().unwrap_or(&knowledge);
800 compact_against_knowledge(&mut idx, kref, &policy);
801 idx.save().expect("save index");
802 });
803 }));
804 }
805 for h in handles {
806 h.join().expect("thread join");
807 }
808
809 let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
810 let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
811 assert_eq!(current, N, "all {N} facts must be committed");
812
813 let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
814 assert_eq!(
815 idx.entries.len(),
816 N,
817 "every concurrently-stored embedding must survive (got {})",
818 idx.entries.len()
819 );
820
821 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
822 }
823
824 #[test]
825 fn compact_drops_missing_or_archived_facts() {
826 let knowledge_root = tempfile::tempdir().unwrap();
827 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
828 let now = chrono::Utc::now();
829 knowledge.facts.push(KnowledgeFact {
830 anchors: Vec::new(),
831 review: None,
832 capture: Default::default(),
833 origin: crate::core::knowledge::FactOrigin::Local,
834 category: "arch".to_string(),
835 key: "db".to_string(),
836 value: "Postgres".to_string(),
837 source_session: "s".to_string(),
838 confidence: 0.9,
839 created_at: now,
840 last_confirmed: now,
841 retrieval_count: 5,
842 last_retrieved: None,
843 valid_from: None,
844 valid_until: None,
845 supersedes: None,
846 confirmation_count: 1,
847 feedback_up: 0,
848 feedback_down: 0,
849 last_feedback: None,
850 privacy: crate::core::memory_boundary::FactPrivacy::default(),
851 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
852 imported_from: None,
853 archetype: KnowledgeArchetype::default(),
854 fidelity: None,
855 revision_count: 0,
856 status: None,
857 validity: None,
858 });
859 knowledge.facts.push(KnowledgeFact {
860 anchors: Vec::new(),
861 review: None,
862 capture: Default::default(),
863 origin: crate::core::knowledge::FactOrigin::Local,
864 category: "arch".to_string(),
865 key: "old".to_string(),
866 value: "Old".to_string(),
867 source_session: "s".to_string(),
868 confidence: 0.9,
869 created_at: now,
870 last_confirmed: now,
871 retrieval_count: 0,
872 last_retrieved: None,
873 valid_from: None,
874 valid_until: Some(now),
875 supersedes: None,
876 confirmation_count: 1,
877 feedback_up: 0,
878 feedback_down: 0,
879 last_feedback: None,
880 privacy: crate::core::memory_boundary::FactPrivacy::default(),
881 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
882 imported_from: None,
883 archetype: KnowledgeArchetype::default(),
884 fidelity: None,
885 revision_count: 0,
886 status: None,
887 validity: None,
888 });
889
890 let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
891 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
892 idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
893 idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
894
895 compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
896 assert_eq!(idx.entries.len(), 1);
897 assert_eq!(idx.entries[0].category, "arch");
898 assert_eq!(idx.entries[0].key, "db");
899 }
900
901 #[test]
902 fn missing_current_facts_selects_unindexed_by_confidence_capped() {
903 let knowledge_root = tempfile::tempdir().unwrap();
904 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
905
906 let indexed = fact_with("arch", "indexed", "s");
907 let mut high = fact_with("arch", "high", "s");
908 high.confidence = 0.95;
909 let mut low = fact_with("arch", "low", "s");
910 low.confidence = 0.5;
911 let mut archived = fact_with("arch", "archived", "s");
912 archived.valid_until = Some(chrono::Utc::now());
913 knowledge.facts.extend([indexed, high, low, archived]);
914
915 let mut idx = KnowledgeEmbeddingIndex::new("test");
916 idx.upsert("arch", "indexed", &[1.0, 0.0, 0.0]);
917
918 let missing = missing_current_facts(&idx, &knowledge, 10);
921 let keys: Vec<&str> = missing.iter().map(|f| f.key.as_str()).collect();
922 assert_eq!(keys, vec!["high", "low"]);
923
924 let capped = missing_current_facts(&idx, &knowledge, 1);
926 assert_eq!(capped.len(), 1);
927 assert_eq!(capped[0].key, "high");
928
929 idx.upsert("arch", "high", &[0.0, 1.0, 0.0]);
931 idx.upsert("arch", "low", &[0.0, 0.0, 1.0]);
932 assert!(missing_current_facts(&idx, &knowledge, 10).is_empty());
933 }
934
935 #[test]
936 fn index_upsert_and_remove() {
937 let mut idx = KnowledgeEmbeddingIndex::new("test");
938 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
939 assert_eq!(idx.entries.len(), 1);
940
941 idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
942 assert_eq!(idx.entries.len(), 1);
943 let recon = idx.entries[0]
945 .quant
946 .as_ref()
947 .expect("quantized")
948 .dequantize();
949 assert!((recon[1] - 1.0).abs() < 1e-6);
950
951 idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
952 assert_eq!(idx.entries.len(), 2);
953
954 idx.remove("arch", "db");
955 assert_eq!(idx.entries.len(), 1);
956 assert_eq!(idx.entries[0].key, "cache");
957 }
958
959 #[cfg(feature = "embeddings")]
960 #[test]
961 fn recency_decay_recent() {
962 let fact = KnowledgeFact {
963 anchors: Vec::new(),
964 review: None,
965 capture: Default::default(),
966 origin: crate::core::knowledge::FactOrigin::Local,
967 category: "test".to_string(),
968 key: "k".to_string(),
969 value: "v".to_string(),
970 source_session: "s".to_string(),
971 confidence: 0.9,
972 created_at: chrono::Utc::now(),
973 last_confirmed: chrono::Utc::now(),
974 retrieval_count: 0,
975 last_retrieved: None,
976 valid_from: None,
977 valid_until: None,
978 supersedes: None,
979 confirmation_count: 1,
980 feedback_up: 0,
981 feedback_down: 0,
982 last_feedback: None,
983 privacy: crate::core::memory_boundary::FactPrivacy::default(),
984 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
985 imported_from: None,
986 archetype: KnowledgeArchetype::default(),
987 fidelity: None,
988 revision_count: 0,
989 status: None,
990 validity: None,
991 };
992 let decay = recency_decay(&fact);
993 assert!(
994 decay > 0.95,
995 "Recent fact should have high recency: {decay}"
996 );
997 }
998
999 #[cfg(feature = "embeddings")]
1000 #[test]
1001 fn recency_decay_old() {
1002 let old_date = chrono::Utc::now() - chrono::Duration::days(100);
1003 let fact = KnowledgeFact {
1004 anchors: Vec::new(),
1005 review: None,
1006 capture: Default::default(),
1007 origin: crate::core::knowledge::FactOrigin::Local,
1008 category: "test".to_string(),
1009 key: "k".to_string(),
1010 value: "v".to_string(),
1011 source_session: "s".to_string(),
1012 confidence: 0.5,
1013 created_at: old_date,
1014 last_confirmed: old_date,
1015 retrieval_count: 0,
1016 last_retrieved: None,
1017 valid_from: None,
1018 valid_until: None,
1019 supersedes: None,
1020 confirmation_count: 1,
1021 feedback_up: 0,
1022 feedback_down: 0,
1023 last_feedback: None,
1024 privacy: crate::core::memory_boundary::FactPrivacy::default(),
1025 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
1026 imported_from: None,
1027 archetype: KnowledgeArchetype::default(),
1028 fidelity: None,
1029 revision_count: 0,
1030 status: None,
1031 validity: None,
1032 };
1033 let decay = recency_decay(&fact);
1034 assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
1035 }
1036
1037 #[cfg(feature = "embeddings")]
1038 #[test]
1039 fn semantic_search_ranking() {
1040 let mut idx = KnowledgeEmbeddingIndex::new("test");
1041 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1042 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
1043 idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
1044
1045 let query = vec![1.0, 0.0, 0.0];
1046 let results = idx.semantic_search(&query, 2);
1047 assert_eq!(results.len(), 2);
1048 assert_eq!(results[0].0.key, "db");
1049 }
1050
1051 #[test]
1052 fn format_scored_empty() {
1053 assert_eq!(format_scored_facts(&[]), "No matching facts found.");
1054 }
1055
1056 #[test]
1057 fn format_scored_output() {
1058 let fact = KnowledgeFact {
1059 anchors: Vec::new(),
1060 review: None,
1061 capture: Default::default(),
1062 origin: crate::core::knowledge::FactOrigin::Local,
1063 category: "arch".to_string(),
1064 key: "db".to_string(),
1065 value: "PostgreSQL".to_string(),
1066 source_session: "s1".to_string(),
1067 confidence: 0.95,
1068 created_at: chrono::Utc::now(),
1069 last_confirmed: chrono::Utc::now(),
1070 retrieval_count: 0,
1071 last_retrieved: None,
1072 valid_from: None,
1073 valid_until: None,
1074 supersedes: None,
1075 confirmation_count: 3,
1076 feedback_up: 0,
1077 feedback_down: 0,
1078 last_feedback: None,
1079 privacy: crate::core::memory_boundary::FactPrivacy::default(),
1080 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
1081 imported_from: None,
1082 archetype: KnowledgeArchetype::default(),
1083 fidelity: None,
1084 revision_count: 0,
1085 status: None,
1086 validity: None,
1087 };
1088 let scored = vec![ScoredFact {
1089 fact: &fact,
1090 score: 0.85,
1091 semantic_score: 0.9,
1092 confidence_score: 0.95,
1093 recency_score: 1.0,
1094 }];
1095 let output = format_scored_facts(&scored);
1096 assert!(output.contains("arch:db=PostgreSQL"));
1097 assert!(output.contains("★★★★"));
1098 assert!(output.contains("[s:85%]"));
1099 }
1100
1101 #[cfg(feature = "embeddings")]
1102 #[test]
1103 fn semantic_dup_flags_high_cosine_other_key() {
1104 let _policy = crate::core::policy::runtime::lock_test_override(); let policy = MemoryPolicy::default();
1106 let kn_root = tempfile::tempdir().unwrap();
1107 let mut kn = ProjectKnowledge::new(kn_root.path().to_str().unwrap());
1108 kn.remember(
1109 "arch",
1110 "db",
1111 "PostgreSQL is the primary database",
1112 "s",
1113 0.9,
1114 &policy,
1115 );
1116 kn.remember(
1117 "arch",
1118 "cache",
1119 "Redis is the cache layer",
1120 "s",
1121 0.9,
1122 &policy,
1123 );
1124
1125 let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1126 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1127 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
1128
1129 let query = [1.0, 0.0, 0.0];
1131 let dups = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1132 assert_eq!(
1133 dups.len(),
1134 1,
1135 "only the near-identical entry clears threshold"
1136 );
1137 assert_eq!(dups[0].key, "db");
1138 assert!(dups[0].similarity >= 0.86);
1139 }
1140
1141 #[cfg(feature = "embeddings")]
1142 #[test]
1143 fn semantic_dup_excludes_self_and_judged() {
1144 let _policy = crate::core::policy::runtime::lock_test_override(); let policy = MemoryPolicy::default();
1146 let kn_root = tempfile::tempdir().unwrap();
1147 let mut kn = ProjectKnowledge::new(kn_root.path().to_str().unwrap());
1148 kn.remember(
1149 "arch",
1150 "db",
1151 "PostgreSQL primary database",
1152 "s",
1153 0.9,
1154 &policy,
1155 );
1156
1157 let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1158 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1159 let query = [1.0, 0.0, 0.0];
1160
1161 let self_hits = semantic_duplicates_from_query(&idx, &kn, "arch", "db", &query, 0.86, 3);
1163 assert!(self_hits.is_empty(), "a fact is never its own duplicate");
1164
1165 let other = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1167 assert_eq!(other.len(), 1);
1168
1169 kn.judged_pairs.push(crate::core::knowledge::JudgedPair {
1170 key_a: "arch/database".to_string(),
1171 key_b: "arch/db".to_string(),
1172 verdict: "unrelated".to_string(),
1173 judged_at: chrono::Utc::now(),
1174 });
1175 let judged = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1176 assert!(judged.is_empty(), "already-judged pairs are not re-flagged");
1177 }
1178
1179 #[test]
1182 fn a_fact_that_is_not_served_is_not_selected_for_embedding() {
1183 use crate::core::context_admission::stored::lineage_support::{
1184 governed, project, scoped, two_facts, withdraw_source,
1185 };
1186 let _data = governed();
1187 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1188 let (_directory, root) = project();
1189 two_facts(&root, "architecture");
1190 let index = KnowledgeEmbeddingIndex::new("test");
1191 let selected = || -> Vec<String> {
1192 scoped(&root, || {
1193 let knowledge = ProjectKnowledge::load(&root).expect("knowledge");
1194 let mut keys: Vec<String> = missing_current_facts(&index, &knowledge, 10)
1195 .into_iter()
1196 .map(|fact| fact.key.clone())
1197 .collect();
1198 keys.sort();
1199 keys
1200 })
1201 };
1202 assert_eq!(
1203 selected(),
1204 ["release-duty".to_owned(), "token-check".to_owned()]
1205 );
1206 withdraw_source(&root);
1207 assert_eq!(selected(), ["release-duty".to_owned()]);
1208 }
1209}