Skip to main content

lean_ctx/core/
knowledge_embedding.rs

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