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
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FactEmbedding {
25 pub category: String,
26 pub key: String,
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub embedding: Vec<f32>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub quant: Option<QuantizedVector>,
36}
37
38impl FactEmbedding {
39 fn similarity(&self, query: &[f32]) -> f32 {
43 match &self.quant {
44 Some(q) => embedding_quant::dot_quant(query, q),
45 None => embedding_quant::dot_f32(query, &self.embedding),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct KnowledgeEmbeddingIndex {
52 pub project_hash: String,
53 pub entries: Vec<FactEmbedding>,
54}
55
56impl KnowledgeEmbeddingIndex {
57 pub fn new(project_hash: &str) -> Self {
58 Self {
59 project_hash: project_hash.to_string(),
60 entries: Vec::new(),
61 }
62 }
63
64 pub fn upsert(&mut self, category: &str, key: &str, embedding: &[f32]) {
65 let quant = Some(embedding_quant::quantize(embedding));
66 if let Some(existing) = self
67 .entries
68 .iter_mut()
69 .find(|e| e.category == category && e.key == key)
70 {
71 existing.quant = quant;
72 existing.embedding = Vec::new();
73 } else {
74 self.entries.push(FactEmbedding {
75 category: category.to_string(),
76 key: key.to_string(),
77 embedding: Vec::new(),
78 quant,
79 });
80 }
81 }
82
83 fn migrate_legacy_entries(&mut self) -> bool {
86 let mut changed = false;
87 for e in &mut self.entries {
88 if e.quant.is_none() && !e.embedding.is_empty() {
89 e.quant = Some(embedding_quant::quantize(&e.embedding));
90 e.embedding = Vec::new();
91 changed = true;
92 }
93 }
94 changed
95 }
96
97 pub fn remove(&mut self, category: &str, key: &str) {
98 self.entries
99 .retain(|e| !(e.category == category && e.key == key));
100 }
101
102 #[cfg(feature = "embeddings")]
103 pub fn semantic_search(
104 &self,
105 query_embedding: &[f32],
106 top_k: usize,
107 ) -> Vec<(&FactEmbedding, f32)> {
108 let mut scored: Vec<(&FactEmbedding, f32)> = self
109 .entries
110 .iter()
111 .map(|e| {
112 let sim = e.similarity(query_embedding);
113 (e, sim)
114 })
115 .collect();
116
117 scored.sort_by(|a, b| {
118 b.1.partial_cmp(&a.1)
119 .unwrap_or(std::cmp::Ordering::Equal)
120 .then_with(|| a.0.category.cmp(&b.0.category))
121 .then_with(|| a.0.key.cmp(&b.0.key))
122 });
123 scored.truncate(top_k);
124 scored
125 }
126
127 fn index_path(project_hash: &str) -> Option<PathBuf> {
128 let dir = crate::core::data_dir::lean_ctx_data_dir()
129 .ok()?
130 .join("knowledge")
131 .join(project_hash);
132 Some(dir.join("embeddings.json"))
133 }
134
135 pub fn load(project_hash: &str) -> Option<Self> {
136 let path = Self::index_path(project_hash)?;
137 let data = std::fs::read_to_string(path).ok()?;
138 let mut index: Self = serde_json::from_str(&data).ok()?;
139 if index.migrate_legacy_entries() {
142 let _ = index.save();
143 }
144 Some(index)
145 }
146
147 pub fn save(&self) -> Result<(), String> {
148 let path = Self::index_path(&self.project_hash)
149 .ok_or_else(|| "Cannot determine data directory".to_string())?;
150 let json = serde_json::to_string(self).map_err(|e| format!("{e}"))?;
151 crate::config_io::write_atomic(&path, &json)
156 }
157}
158
159pub fn reset(project_hash: &str) -> Result<(), String> {
160 let path = KnowledgeEmbeddingIndex::index_path(project_hash)
161 .ok_or_else(|| "Cannot determine data directory".to_string())?;
162 if path.exists() {
163 std::fs::remove_file(&path).map_err(|e| format!("{e}"))?;
164 }
165 Ok(())
166}
167
168#[derive(Debug)]
169pub struct ScoredFact<'a> {
170 pub fact: &'a KnowledgeFact,
171 pub score: f32,
172 pub semantic_score: f32,
173 pub confidence_score: f32,
174 pub recency_score: f32,
175}
176
177#[cfg(feature = "embeddings")]
178pub fn semantic_recall<'a>(
179 knowledge: &'a ProjectKnowledge,
180 index: &KnowledgeEmbeddingIndex,
181 engine: &EmbeddingEngine,
182 query: &str,
183 top_k: usize,
184) -> Vec<ScoredFact<'a>> {
185 let Ok(query_embedding) = engine.embed_query(query) else {
186 return lexical_fallback(knowledge, query, top_k);
187 };
188
189 let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
190
191 let mut results: Vec<ScoredFact<'a>> = Vec::new();
192
193 for (entry, sim) in &semantic_hits {
194 if let Some(fact) = knowledge
195 .facts
196 .iter()
197 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
198 {
199 let confidence_score = fact.quality_score();
200 let recency_score = recency_decay(fact);
201 let score = ALPHA_SEMANTIC * sim
202 + BETA_CONFIDENCE * confidence_score
203 + GAMMA_RECENCY * recency_score;
204
205 results.push(ScoredFact {
206 fact,
207 score,
208 semantic_score: *sim,
209 confidence_score,
210 recency_score,
211 });
212 }
213 }
214
215 let exact_matches = knowledge.recall(query);
216 for fact in exact_matches {
217 let already_included = results
218 .iter()
219 .any(|r| r.fact.category == fact.category && r.fact.key == fact.key);
220 if !already_included {
221 results.push(ScoredFact {
222 fact,
223 score: 1.0,
224 semantic_score: 1.0,
225 confidence_score: fact.quality_score(),
226 recency_score: recency_decay(fact),
227 });
228 }
229 }
230
231 results.sort_by(|a, b| {
232 b.score
233 .partial_cmp(&a.score)
234 .unwrap_or(std::cmp::Ordering::Equal)
235 .then_with(|| {
236 b.confidence_score
237 .partial_cmp(&a.confidence_score)
238 .unwrap_or(std::cmp::Ordering::Equal)
239 })
240 .then_with(|| {
241 b.recency_score
242 .partial_cmp(&a.recency_score)
243 .unwrap_or(std::cmp::Ordering::Equal)
244 })
245 .then_with(|| a.fact.category.cmp(&b.fact.category))
246 .then_with(|| a.fact.key.cmp(&b.fact.key))
247 .then_with(|| a.fact.value.cmp(&b.fact.value))
248 });
249 results.truncate(top_k);
250 results
251}
252
253#[cfg(feature = "embeddings")]
254pub fn semantic_recall_semantic_only<'a>(
255 knowledge: &'a ProjectKnowledge,
256 index: &KnowledgeEmbeddingIndex,
257 engine: &EmbeddingEngine,
258 query: &str,
259 top_k: usize,
260) -> Vec<ScoredFact<'a>> {
261 let Ok(query_embedding) = engine.embed_query(query) else {
262 return Vec::new();
263 };
264
265 let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
266 let mut results: Vec<ScoredFact<'a>> = Vec::new();
267
268 for (entry, sim) in &semantic_hits {
269 if let Some(fact) = knowledge
270 .facts
271 .iter()
272 .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
273 {
274 let confidence_score = fact.quality_score();
275 let recency_score = recency_decay(fact);
276 let score = ALPHA_SEMANTIC * sim
277 + BETA_CONFIDENCE * confidence_score
278 + GAMMA_RECENCY * recency_score;
279
280 results.push(ScoredFact {
281 fact,
282 score,
283 semantic_score: *sim,
284 confidence_score,
285 recency_score,
286 });
287 }
288 }
289
290 results.sort_by(|a, b| {
291 b.score
292 .partial_cmp(&a.score)
293 .unwrap_or(std::cmp::Ordering::Equal)
294 .then_with(|| {
295 b.confidence_score
296 .partial_cmp(&a.confidence_score)
297 .unwrap_or(std::cmp::Ordering::Equal)
298 })
299 .then_with(|| {
300 b.recency_score
301 .partial_cmp(&a.recency_score)
302 .unwrap_or(std::cmp::Ordering::Equal)
303 })
304 .then_with(|| a.fact.category.cmp(&b.fact.category))
305 .then_with(|| a.fact.key.cmp(&b.fact.key))
306 .then_with(|| a.fact.value.cmp(&b.fact.value))
307 });
308 results.truncate(top_k);
309 results
310}
311
312pub fn compact_against_knowledge(
313 index: &mut KnowledgeEmbeddingIndex,
314 knowledge: &ProjectKnowledge,
315 policy: &MemoryPolicy,
316) {
317 use std::collections::HashMap;
318
319 let mut current: HashMap<(&str, &str), &KnowledgeFact> = HashMap::new();
320 for f in &knowledge.facts {
321 if f.is_current() {
322 current.insert((f.category.as_str(), f.key.as_str()), f);
323 }
324 }
325
326 let mut kept: Vec<(FactEmbedding, &KnowledgeFact)> = index
327 .entries
328 .iter()
329 .filter_map(|e| {
330 current
331 .get(&(e.category.as_str(), e.key.as_str()))
332 .map(|f| (e.clone(), *f))
333 })
334 .collect();
335
336 kept.sort_by(|(ea, fa), (eb, fb)| {
337 fb.confidence
338 .partial_cmp(&fa.confidence)
339 .unwrap_or(std::cmp::Ordering::Equal)
340 .then_with(|| fb.last_confirmed.cmp(&fa.last_confirmed))
341 .then_with(|| fb.retrieval_count.cmp(&fa.retrieval_count))
342 .then_with(|| ea.category.cmp(&eb.category))
343 .then_with(|| ea.key.cmp(&eb.key))
344 });
345
346 let max = policy.embeddings.max_facts;
347 if kept.len() > max {
348 kept.truncate(max);
349 }
350
351 index.entries = kept.into_iter().map(|(e, _)| e).collect();
352}
353
354fn lexical_fallback<'a>(
355 knowledge: &'a ProjectKnowledge,
356 query: &str,
357 top_k: usize,
358) -> Vec<ScoredFact<'a>> {
359 knowledge
360 .recall(query)
361 .into_iter()
362 .take(top_k)
363 .map(|fact| ScoredFact {
364 fact,
365 score: fact.confidence,
366 semantic_score: 0.0,
367 confidence_score: fact.confidence,
368 recency_score: recency_decay(fact),
369 })
370 .collect()
371}
372
373fn recency_decay(fact: &KnowledgeFact) -> f32 {
374 let days_old = chrono::Utc::now()
375 .signed_duration_since(fact.last_confirmed)
376 .num_days() as f32;
377 (1.0 - days_old / MAX_RECENCY_DAYS).max(0.0)
378}
379
380#[cfg(feature = "embeddings")]
381pub fn embed_and_store(
382 index: &mut KnowledgeEmbeddingIndex,
383 engine: &EmbeddingEngine,
384 category: &str,
385 key: &str,
386 value: &str,
387) -> Result<(), String> {
388 let text = format!("{category} {key}: {value}");
389 let embedding = engine.embed(&text).map_err(|e| format!("{e}"))?;
390 index.upsert(category, key, &embedding);
391 Ok(())
392}
393
394pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
395 if results.is_empty() {
396 return "No matching facts found.".to_string();
397 }
398
399 let mut output = String::new();
400 for (i, scored) in results.iter().enumerate() {
401 let f = scored.fact;
402 let stars = if f.confidence >= 0.9 {
403 "★★★★"
404 } else if f.confidence >= 0.7 {
405 "★★★"
406 } else if f.confidence >= 0.5 {
407 "★★"
408 } else {
409 "★"
410 };
411
412 if i > 0 {
413 output.push('|');
414 }
415 output.push_str(&format!(
416 "{}:{}={}{} [s:{:.0}%]",
417 f.category,
418 f.key,
419 f.value,
420 stars,
421 scored.score * 100.0
422 ));
423 }
424 output
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::core::knowledge::KnowledgeArchetype;
431
432 #[test]
433 fn reset_removes_index_file() {
434 let _lock = crate::core::data_dir::test_env_lock();
435 let tmp = tempfile::tempdir().expect("tempdir");
436 std::env::set_var(
437 "LEAN_CTX_DATA_DIR",
438 tmp.path().to_string_lossy().to_string(),
439 );
440
441 let idx = KnowledgeEmbeddingIndex {
442 project_hash: "projhash".to_string(),
443 entries: vec![FactEmbedding {
444 category: "arch".to_string(),
445 key: "db".to_string(),
446 embedding: vec![1.0, 0.0, 0.0],
447 quant: None,
448 }],
449 };
450 idx.save().expect("save");
451 assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
452
453 reset("projhash").expect("reset");
454 assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
455
456 std::env::remove_var("LEAN_CTX_DATA_DIR");
457 }
458
459 #[test]
460 fn concurrent_remember_keeps_all_embeddings() {
461 let _lock = crate::core::data_dir::test_env_lock();
468 let tmp = tempfile::tempdir().expect("tempdir");
469 std::env::set_var(
470 "LEAN_CTX_DATA_DIR",
471 tmp.path().to_string_lossy().to_string(),
472 );
473
474 let project = tmp.path().join("proj");
475 std::fs::create_dir_all(&project).expect("mkdir");
476 let project_root = project.to_string_lossy().to_string();
477
478 const N: usize = 16;
479 let mut handles = Vec::with_capacity(N);
480 for i in 0..N {
481 let root = project_root.clone();
482 handles.push(std::thread::spawn(move || {
483 let policy = MemoryPolicy::default();
484 let cat = "arch";
485 let key = format!("k{i}");
486 let (knowledge, ()) = ProjectKnowledge::mutate_locked(&root, |kn| {
488 kn.remember(cat, &key, "v", "s", 0.9, &policy);
489 })
490 .expect("commit fact");
491 ProjectKnowledge::with_project_lock(&root, || {
494 let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
495 .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
496 idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
497 let fresh = ProjectKnowledge::load(&root);
498 let kref = fresh.as_ref().unwrap_or(&knowledge);
499 compact_against_knowledge(&mut idx, kref, &policy);
500 idx.save().expect("save index");
501 });
502 }));
503 }
504 for h in handles {
505 h.join().expect("thread join");
506 }
507
508 let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
509 let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
510 assert_eq!(current, N, "all {N} facts must be committed");
511
512 let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
513 assert_eq!(
514 idx.entries.len(),
515 N,
516 "every concurrently-stored embedding must survive (got {})",
517 idx.entries.len()
518 );
519
520 std::env::remove_var("LEAN_CTX_DATA_DIR");
521 }
522
523 #[test]
524 fn compact_drops_missing_or_archived_facts() {
525 let mut knowledge = ProjectKnowledge::new("/tmp/project");
526 let now = chrono::Utc::now();
527 knowledge.facts.push(KnowledgeFact {
528 category: "arch".to_string(),
529 key: "db".to_string(),
530 value: "Postgres".to_string(),
531 source_session: "s".to_string(),
532 confidence: 0.9,
533 created_at: now,
534 last_confirmed: now,
535 retrieval_count: 5,
536 last_retrieved: None,
537 valid_from: None,
538 valid_until: None,
539 supersedes: None,
540 confirmation_count: 1,
541 feedback_up: 0,
542 feedback_down: 0,
543 last_feedback: None,
544 privacy: crate::core::memory_boundary::FactPrivacy::default(),
545 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
546 imported_from: None,
547 archetype: KnowledgeArchetype::default(),
548 fidelity: None,
549 revision_count: 0,
550 });
551 knowledge.facts.push(KnowledgeFact {
552 category: "arch".to_string(),
553 key: "old".to_string(),
554 value: "Old".to_string(),
555 source_session: "s".to_string(),
556 confidence: 0.9,
557 created_at: now,
558 last_confirmed: now,
559 retrieval_count: 0,
560 last_retrieved: None,
561 valid_from: None,
562 valid_until: Some(now),
563 supersedes: None,
564 confirmation_count: 1,
565 feedback_up: 0,
566 feedback_down: 0,
567 last_feedback: None,
568 privacy: crate::core::memory_boundary::FactPrivacy::default(),
569 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
570 imported_from: None,
571 archetype: KnowledgeArchetype::default(),
572 fidelity: None,
573 revision_count: 0,
574 });
575
576 let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
577 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
578 idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
579 idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
580
581 compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
582 assert_eq!(idx.entries.len(), 1);
583 assert_eq!(idx.entries[0].category, "arch");
584 assert_eq!(idx.entries[0].key, "db");
585 }
586
587 #[test]
588 fn index_upsert_and_remove() {
589 let mut idx = KnowledgeEmbeddingIndex::new("test");
590 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
591 assert_eq!(idx.entries.len(), 1);
592
593 idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
594 assert_eq!(idx.entries.len(), 1);
595 let recon = idx.entries[0]
597 .quant
598 .as_ref()
599 .expect("quantized")
600 .dequantize();
601 assert!((recon[1] - 1.0).abs() < 1e-6);
602
603 idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
604 assert_eq!(idx.entries.len(), 2);
605
606 idx.remove("arch", "db");
607 assert_eq!(idx.entries.len(), 1);
608 assert_eq!(idx.entries[0].key, "cache");
609 }
610
611 #[test]
612 fn recency_decay_recent() {
613 let fact = KnowledgeFact {
614 category: "test".to_string(),
615 key: "k".to_string(),
616 value: "v".to_string(),
617 source_session: "s".to_string(),
618 confidence: 0.9,
619 created_at: chrono::Utc::now(),
620 last_confirmed: chrono::Utc::now(),
621 retrieval_count: 0,
622 last_retrieved: None,
623 valid_from: None,
624 valid_until: None,
625 supersedes: None,
626 confirmation_count: 1,
627 feedback_up: 0,
628 feedback_down: 0,
629 last_feedback: None,
630 privacy: crate::core::memory_boundary::FactPrivacy::default(),
631 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
632 imported_from: None,
633 archetype: KnowledgeArchetype::default(),
634 fidelity: None,
635 revision_count: 0,
636 };
637 let decay = recency_decay(&fact);
638 assert!(
639 decay > 0.95,
640 "Recent fact should have high recency: {decay}"
641 );
642 }
643
644 #[test]
645 fn recency_decay_old() {
646 let old_date = chrono::Utc::now() - chrono::Duration::days(100);
647 let fact = KnowledgeFact {
648 category: "test".to_string(),
649 key: "k".to_string(),
650 value: "v".to_string(),
651 source_session: "s".to_string(),
652 confidence: 0.5,
653 created_at: old_date,
654 last_confirmed: old_date,
655 retrieval_count: 0,
656 last_retrieved: None,
657 valid_from: None,
658 valid_until: None,
659 supersedes: None,
660 confirmation_count: 1,
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::default(),
668 fidelity: None,
669 revision_count: 0,
670 };
671 let decay = recency_decay(&fact);
672 assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
673 }
674
675 #[cfg(feature = "embeddings")]
676 #[test]
677 fn semantic_search_ranking() {
678 let mut idx = KnowledgeEmbeddingIndex::new("test");
679 idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
680 idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
681 idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
682
683 let query = vec![1.0, 0.0, 0.0];
684 let results = idx.semantic_search(&query, 2);
685 assert_eq!(results.len(), 2);
686 assert_eq!(results[0].0.key, "db");
687 }
688
689 #[test]
690 fn format_scored_empty() {
691 assert_eq!(format_scored_facts(&[]), "No matching facts found.");
692 }
693
694 #[test]
695 fn format_scored_output() {
696 let fact = KnowledgeFact {
697 category: "arch".to_string(),
698 key: "db".to_string(),
699 value: "PostgreSQL".to_string(),
700 source_session: "s1".to_string(),
701 confidence: 0.95,
702 created_at: chrono::Utc::now(),
703 last_confirmed: chrono::Utc::now(),
704 retrieval_count: 0,
705 last_retrieved: None,
706 valid_from: None,
707 valid_until: None,
708 supersedes: None,
709 confirmation_count: 3,
710 feedback_up: 0,
711 feedback_down: 0,
712 last_feedback: None,
713 privacy: crate::core::memory_boundary::FactPrivacy::default(),
714 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
715 imported_from: None,
716 archetype: KnowledgeArchetype::default(),
717 fidelity: None,
718 revision_count: 0,
719 };
720 let scored = vec![ScoredFact {
721 fact: &fact,
722 score: 0.85,
723 semantic_score: 0.9,
724 confidence_score: 0.95,
725 recency_score: 1.0,
726 }];
727 let output = format_scored_facts(&scored);
728 assert!(output.contains("arch:db=PostgreSQL"));
729 assert!(output.contains("★★★★"));
730 assert!(output.contains("[s:85%]"));
731 }
732}