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;
21/// Observation tier (#802): additive boost for a synthesized entity-summary in the
22/// semantic recall paths, mirroring the lexical `recall_for_output` boost so the
23/// tier is honoured regardless of whether embeddings are active. Calibrated for the
24/// [0,1] semantic score — a balanced nudge that lifts a relevant summary above
25/// incidental matches yet stays below an exact match (which scores 1.0), so a stale
26/// summary can never bury a precise raw fact.
27const OBSERVATION_TIER_BOOST: f32 = 0.15;
28const MAX_RECENCY_DAYS: f32 = 90.0;
29
30/// Cosine threshold above which a freshly-remembered fact is treated as a
31/// semantic near-duplicate of an existing one. Deliberately conservative — only
32/// genuine paraphrases ("DB is Postgres" / "we persist to PostgreSQL") clear it,
33/// so the advisory stays signal, not noise. Non-destructive: it nudges the agent
34/// to `judge`, never auto-merges (distinct facts can be near in embedding space,
35/// e.g. "Postgres 14" vs "Postgres 15").
36pub 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    /// Legacy full-precision vector (indices written before int8 quantization).
43    /// Migrated to `quant` transparently on load and then emptied, so it only
44    /// appears in files written by older binaries.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub embedding: Vec<f32>,
47    /// int8-quantized representation (turbovec-derived) — 4× smaller on disk and
48    /// the canonical storage for every entry written by current binaries.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub quant: Option<QuantizedVector>,
51}
52
53impl FactEmbedding {
54    /// Similarity against a full-precision (L2-normalized) query. Scores directly
55    /// against the int8 codes when available; falls back to the legacy f32 vector
56    /// for not-yet-migrated entries.
57    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    /// Upgrades any legacy full-precision entries to int8 in place. Returns true
99    /// if anything changed (so the caller can persist the smaller form once).
100    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        // Pay the one-time int8 migration cost on first load by an upgraded binary,
155        // then persist so subsequent loads read the 4×-smaller form.
156        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        // Atomic write (temp + rename) so a concurrent, lock-free reader in
167        // `recall` (which loads the index without taking the per-project lock)
168        // never observes a half-written file — it sees either the old or the new
169        // complete index, never trailing garbage (issue #412).
170        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            // Observation tier (#802): honour synthesized entity-summaries in the
217            // semantic path too, not just lexical recall_for_output. Balanced nudge.
218            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            // Observation tier (#802): honour synthesized entity-summaries in the
297            // semantic path too, not just lexical recall_for_output. Balanced nudge.
298            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
405/// Add the observation-tier boost (#802) to a base recall score iff `fact` is a
406/// synthesized entity-summary. Pure + deterministic so the tier policy can be
407/// unit-tested without spinning up the embedding engine.
408fn apply_observation_tier(base: f32, fact: &KnowledgeFact) -> f32 {
409    if fact.is_synthesized_observation() {
410        base + OBSERVATION_TIER_BOOST
411    } else {
412        base
413    }
414}
415
416#[cfg(feature = "embeddings")]
417pub fn embed_and_store(
418    index: &mut KnowledgeEmbeddingIndex,
419    engine: &EmbeddingEngine,
420    category: &str,
421    key: &str,
422    value: &str,
423) -> Result<(), String> {
424    let text = format!("{category} {key}: {value}");
425    let embedding = engine.embed(&text).map_err(|e| format!("{e}"))?;
426    index.upsert(category, key, &embedding);
427    Ok(())
428}
429
430/// Embedding-based near-duplicate detection for `remember`. Mirrors the lexical
431/// [`find_cross_key_similar`] but scores cosine similarity, so paraphrases that
432/// share few tokens are still caught. Read-only against the *pre-upsert* index,
433/// so the incoming fact never matches itself. Returns advisory hits for the
434/// agent to resolve via `judge` — it never mutates or merges facts.
435///
436/// [`find_cross_key_similar`]: crate::core::knowledge::find_cross_key_similar
437#[cfg(feature = "embeddings")]
438pub fn find_semantic_duplicates(
439    index: &KnowledgeEmbeddingIndex,
440    engine: &EmbeddingEngine,
441    knowledge: &ProjectKnowledge,
442    new_category: &str,
443    new_key: &str,
444    new_value: &str,
445    threshold: f32,
446    limit: usize,
447) -> Vec<crate::core::knowledge::SimilarFact> {
448    // Cheap static-model embed (microseconds); kept separate from the storage
449    // embed in `embed_and_store` so its well-tested side-car path is untouched.
450    let text = format!("{new_category} {new_key}: {new_value}");
451    let Ok(query) = engine.embed(&text) else {
452        return Vec::new();
453    };
454    semantic_duplicates_from_query(
455        index,
456        knowledge,
457        new_category,
458        new_key,
459        &query,
460        threshold,
461        limit,
462    )
463}
464
465/// Engine-free core of [`find_semantic_duplicates`]: scans an in-memory index
466/// for entries whose cosine similarity to a precomputed query embedding clears
467/// `threshold`, maps them back to current facts, and excludes the incoming
468/// fact's own key, non-current facts, and pairs the agent already judged. Pure
469/// and deterministic so the dedup logic is unit-testable with raw vectors.
470fn semantic_duplicates_from_query(
471    index: &KnowledgeEmbeddingIndex,
472    knowledge: &ProjectKnowledge,
473    new_category: &str,
474    new_key: &str,
475    query: &[f32],
476    threshold: f32,
477    limit: usize,
478) -> Vec<crate::core::knowledge::SimilarFact> {
479    use crate::core::knowledge::SimilarFact;
480
481    let composite_key = format!("{new_category}/{new_key}");
482
483    let mut scored: Vec<(&FactEmbedding, f32)> = index
484        .entries
485        .iter()
486        .filter(|e| !(e.category == new_category && e.key == new_key))
487        .map(|e| (e, e.similarity(query)))
488        .filter(|(_, sim)| *sim >= threshold)
489        .collect();
490    scored.sort_by(|a, b| {
491        b.1.partial_cmp(&a.1)
492            .unwrap_or(std::cmp::Ordering::Equal)
493            .then_with(|| a.0.category.cmp(&b.0.category))
494            .then_with(|| a.0.key.cmp(&b.0.key))
495    });
496
497    let mut out: Vec<SimilarFact> = Vec::new();
498    for (entry, sim) in scored {
499        let other_key = format!("{}/{}", entry.category, entry.key);
500        let already_judged = knowledge.judged_pairs.iter().any(|jp| {
501            (jp.key_a == composite_key && jp.key_b == other_key)
502                || (jp.key_a == other_key && jp.key_b == composite_key)
503        });
504        if already_judged {
505            continue;
506        }
507        let Some(fact) = knowledge
508            .facts
509            .iter()
510            .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
511        else {
512            continue;
513        };
514        let preview = if fact.value.len() > 60 {
515            format!("{}...", &fact.value[..fact.value.floor_char_boundary(57)])
516        } else {
517            fact.value.clone()
518        };
519        out.push(SimilarFact {
520            category: fact.category.clone(),
521            key: fact.key.clone(),
522            value_preview: preview,
523            similarity: sim,
524        });
525        if out.len() >= limit {
526            break;
527        }
528    }
529    out
530}
531
532pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
533    if results.is_empty() {
534        return "No matching facts found.".to_string();
535    }
536
537    let mut output = String::new();
538    for (i, scored) in results.iter().enumerate() {
539        let f = scored.fact;
540        let stars = if f.confidence >= 0.9 {
541            "★★★★"
542        } else if f.confidence >= 0.7 {
543            "★★★"
544        } else if f.confidence >= 0.5 {
545            "★★"
546        } else {
547            "★"
548        };
549
550        if i > 0 {
551            output.push('|');
552        }
553        output.push_str(&format!(
554            "{}:{}={}{} [s:{:.0}%]",
555            f.category,
556            f.key,
557            f.value,
558            stars,
559            scored.score * 100.0
560        ));
561    }
562    output
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::core::knowledge::KnowledgeArchetype;
569
570    fn fact_with(category: &str, key: &str, source: &str) -> KnowledgeFact {
571        let now = chrono::Utc::now();
572        KnowledgeFact {
573            category: category.to_string(),
574            key: key.to_string(),
575            value: "v".to_string(),
576            source_session: source.to_string(),
577            confidence: 0.8,
578            created_at: now,
579            last_confirmed: now,
580            retrieval_count: 0,
581            last_retrieved: None,
582            valid_from: None,
583            valid_until: None,
584            supersedes: None,
585            confirmation_count: 0,
586            feedback_up: 0,
587            feedback_down: 0,
588            last_feedback: None,
589            privacy: crate::core::memory_boundary::FactPrivacy::default(),
590            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
591            imported_from: None,
592            archetype: KnowledgeArchetype::infer_from_category(category),
593            fidelity: None,
594            revision_count: 0,
595        }
596    }
597
598    #[test]
599    fn observation_tier_boosts_only_synthesized_summaries() {
600        use crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE;
601        // A synthesized observation (cognition-synthesis source) earns the tier boost.
602        let obs = fact_with(
603            "observation",
604            "src/auth/session.rs",
605            COGNITION_SYNTHESIS_SOURCE,
606        );
607        // A user finding (also Observation archetype) is NOT synthesized → no boost.
608        let user_finding = fact_with("observation", "src/auth/session.rs:1", "session-7");
609        // A raw evidence fact → no boost.
610        let raw = fact_with("gotcha", "src/auth/session.rs:2", "session-7");
611
612        assert!((apply_observation_tier(0.5, &raw) - 0.5).abs() < 1e-6);
613        assert!((apply_observation_tier(0.5, &user_finding) - 0.5).abs() < 1e-6);
614        assert!((apply_observation_tier(0.5, &obs) - (0.5 + OBSERVATION_TIER_BOOST)).abs() < 1e-6);
615        // The boost lifts a synthesized summary above an equal-base raw fact, yet is
616        // bounded so a boosted summary stays below an exact match (which scores 1.0).
617        assert!(apply_observation_tier(0.5, &obs) > apply_observation_tier(0.5, &raw));
618        assert!(apply_observation_tier(0.8, &obs) < 1.0);
619    }
620
621    #[test]
622    fn reset_removes_index_file() {
623        let _lock = crate::core::data_dir::test_env_lock();
624        let tmp = tempfile::tempdir().expect("tempdir");
625        crate::test_env::set_var(
626            "LEAN_CTX_DATA_DIR",
627            tmp.path().to_string_lossy().to_string(),
628        );
629
630        let idx = KnowledgeEmbeddingIndex {
631            project_hash: "projhash".to_string(),
632            entries: vec![FactEmbedding {
633                category: "arch".to_string(),
634                key: "db".to_string(),
635                embedding: vec![1.0, 0.0, 0.0],
636                quant: None,
637            }],
638        };
639        idx.save().expect("save");
640        assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
641
642        reset("projhash").expect("reset");
643        assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
644
645        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
646    }
647
648    #[test]
649    fn concurrent_remember_keeps_all_embeddings() {
650        // #412: the embedding-index read-modify-write must be serialized under
651        // the per-project lock and compacted against fresh on-disk knowledge.
652        // The old lock-free + stale-snapshot path let parallel writers clobber
653        // each other's vectors and prune just-stored ones. This mirrors
654        // `handle_remember`'s locked path (raw vectors, so no embedding engine
655        // is needed) and asserts every concurrently-stored embedding survives.
656        let _lock = crate::core::data_dir::test_env_lock();
657        let tmp = tempfile::tempdir().expect("tempdir");
658        crate::test_env::set_var(
659            "LEAN_CTX_DATA_DIR",
660            tmp.path().to_string_lossy().to_string(),
661        );
662
663        let project = tmp.path().join("proj");
664        std::fs::create_dir_all(&project).expect("mkdir");
665        let project_root = project.to_string_lossy().to_string();
666
667        const N: usize = 16;
668        let mut handles = Vec::with_capacity(N);
669        for i in 0..N {
670            let root = project_root.clone();
671            handles.push(std::thread::spawn(move || {
672                let policy = MemoryPolicy::default();
673                let cat = "arch";
674                let key = format!("k{i}");
675                // 1) Commit the fact under the lock (as handle_remember does).
676                let (knowledge, ()) = ProjectKnowledge::mutate_locked(&root, |kn| {
677                    kn.remember(cat, &key, "v", "s", 0.9, &policy);
678                })
679                .expect("commit fact");
680                // 2) Embedding side-car under the SAME lock + fresh-knowledge
681                //    compaction — exactly the fixed handle_remember path.
682                ProjectKnowledge::with_project_lock(&root, || {
683                    let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
684                        .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
685                    idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
686                    let fresh = ProjectKnowledge::load(&root);
687                    let kref = fresh.as_ref().unwrap_or(&knowledge);
688                    compact_against_knowledge(&mut idx, kref, &policy);
689                    idx.save().expect("save index");
690                });
691            }));
692        }
693        for h in handles {
694            h.join().expect("thread join");
695        }
696
697        let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
698        let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
699        assert_eq!(current, N, "all {N} facts must be committed");
700
701        let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
702        assert_eq!(
703            idx.entries.len(),
704            N,
705            "every concurrently-stored embedding must survive (got {})",
706            idx.entries.len()
707        );
708
709        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
710    }
711
712    #[test]
713    fn compact_drops_missing_or_archived_facts() {
714        let mut knowledge = ProjectKnowledge::new("/tmp/project");
715        let now = chrono::Utc::now();
716        knowledge.facts.push(KnowledgeFact {
717            category: "arch".to_string(),
718            key: "db".to_string(),
719            value: "Postgres".to_string(),
720            source_session: "s".to_string(),
721            confidence: 0.9,
722            created_at: now,
723            last_confirmed: now,
724            retrieval_count: 5,
725            last_retrieved: None,
726            valid_from: None,
727            valid_until: None,
728            supersedes: None,
729            confirmation_count: 1,
730            feedback_up: 0,
731            feedback_down: 0,
732            last_feedback: None,
733            privacy: crate::core::memory_boundary::FactPrivacy::default(),
734            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
735            imported_from: None,
736            archetype: KnowledgeArchetype::default(),
737            fidelity: None,
738            revision_count: 0,
739        });
740        knowledge.facts.push(KnowledgeFact {
741            category: "arch".to_string(),
742            key: "old".to_string(),
743            value: "Old".to_string(),
744            source_session: "s".to_string(),
745            confidence: 0.9,
746            created_at: now,
747            last_confirmed: now,
748            retrieval_count: 0,
749            last_retrieved: None,
750            valid_from: None,
751            valid_until: Some(now),
752            supersedes: None,
753            confirmation_count: 1,
754            feedback_up: 0,
755            feedback_down: 0,
756            last_feedback: None,
757            privacy: crate::core::memory_boundary::FactPrivacy::default(),
758            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
759            imported_from: None,
760            archetype: KnowledgeArchetype::default(),
761            fidelity: None,
762            revision_count: 0,
763        });
764
765        let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
766        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
767        idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
768        idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
769
770        compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
771        assert_eq!(idx.entries.len(), 1);
772        assert_eq!(idx.entries[0].category, "arch");
773        assert_eq!(idx.entries[0].key, "db");
774    }
775
776    #[test]
777    fn index_upsert_and_remove() {
778        let mut idx = KnowledgeEmbeddingIndex::new("test");
779        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
780        assert_eq!(idx.entries.len(), 1);
781
782        idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
783        assert_eq!(idx.entries.len(), 1);
784        // Stored quantized now: the dominant axis reconstructs to ~1.0.
785        let recon = idx.entries[0]
786            .quant
787            .as_ref()
788            .expect("quantized")
789            .dequantize();
790        assert!((recon[1] - 1.0).abs() < 1e-6);
791
792        idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
793        assert_eq!(idx.entries.len(), 2);
794
795        idx.remove("arch", "db");
796        assert_eq!(idx.entries.len(), 1);
797        assert_eq!(idx.entries[0].key, "cache");
798    }
799
800    #[test]
801    fn recency_decay_recent() {
802        let fact = KnowledgeFact {
803            category: "test".to_string(),
804            key: "k".to_string(),
805            value: "v".to_string(),
806            source_session: "s".to_string(),
807            confidence: 0.9,
808            created_at: chrono::Utc::now(),
809            last_confirmed: chrono::Utc::now(),
810            retrieval_count: 0,
811            last_retrieved: None,
812            valid_from: None,
813            valid_until: None,
814            supersedes: None,
815            confirmation_count: 1,
816            feedback_up: 0,
817            feedback_down: 0,
818            last_feedback: None,
819            privacy: crate::core::memory_boundary::FactPrivacy::default(),
820            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
821            imported_from: None,
822            archetype: KnowledgeArchetype::default(),
823            fidelity: None,
824            revision_count: 0,
825        };
826        let decay = recency_decay(&fact);
827        assert!(
828            decay > 0.95,
829            "Recent fact should have high recency: {decay}"
830        );
831    }
832
833    #[test]
834    fn recency_decay_old() {
835        let old_date = chrono::Utc::now() - chrono::Duration::days(100);
836        let fact = KnowledgeFact {
837            category: "test".to_string(),
838            key: "k".to_string(),
839            value: "v".to_string(),
840            source_session: "s".to_string(),
841            confidence: 0.5,
842            created_at: old_date,
843            last_confirmed: old_date,
844            retrieval_count: 0,
845            last_retrieved: None,
846            valid_from: None,
847            valid_until: None,
848            supersedes: None,
849            confirmation_count: 1,
850            feedback_up: 0,
851            feedback_down: 0,
852            last_feedback: None,
853            privacy: crate::core::memory_boundary::FactPrivacy::default(),
854            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
855            imported_from: None,
856            archetype: KnowledgeArchetype::default(),
857            fidelity: None,
858            revision_count: 0,
859        };
860        let decay = recency_decay(&fact);
861        assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
862    }
863
864    #[cfg(feature = "embeddings")]
865    #[test]
866    fn semantic_search_ranking() {
867        let mut idx = KnowledgeEmbeddingIndex::new("test");
868        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
869        idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
870        idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
871
872        let query = vec![1.0, 0.0, 0.0];
873        let results = idx.semantic_search(&query, 2);
874        assert_eq!(results.len(), 2);
875        assert_eq!(results[0].0.key, "db");
876    }
877
878    #[test]
879    fn format_scored_empty() {
880        assert_eq!(format_scored_facts(&[]), "No matching facts found.");
881    }
882
883    #[test]
884    fn format_scored_output() {
885        let fact = KnowledgeFact {
886            category: "arch".to_string(),
887            key: "db".to_string(),
888            value: "PostgreSQL".to_string(),
889            source_session: "s1".to_string(),
890            confidence: 0.95,
891            created_at: chrono::Utc::now(),
892            last_confirmed: chrono::Utc::now(),
893            retrieval_count: 0,
894            last_retrieved: None,
895            valid_from: None,
896            valid_until: None,
897            supersedes: None,
898            confirmation_count: 3,
899            feedback_up: 0,
900            feedback_down: 0,
901            last_feedback: None,
902            privacy: crate::core::memory_boundary::FactPrivacy::default(),
903            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
904            imported_from: None,
905            archetype: KnowledgeArchetype::default(),
906            fidelity: None,
907            revision_count: 0,
908        };
909        let scored = vec![ScoredFact {
910            fact: &fact,
911            score: 0.85,
912            semantic_score: 0.9,
913            confidence_score: 0.95,
914            recency_score: 1.0,
915        }];
916        let output = format_scored_facts(&scored);
917        assert!(output.contains("arch:db=PostgreSQL"));
918        assert!(output.contains("★★★★"));
919        assert!(output.contains("[s:85%]"));
920    }
921
922    #[test]
923    fn semantic_dup_flags_high_cosine_other_key() {
924        let policy = MemoryPolicy::default();
925        let mut kn = ProjectKnowledge::new("/tmp/semdup-1");
926        kn.remember(
927            "arch",
928            "db",
929            "PostgreSQL is the primary database",
930            "s",
931            0.9,
932            &policy,
933        );
934        kn.remember(
935            "arch",
936            "cache",
937            "Redis is the cache layer",
938            "s",
939            0.9,
940            &policy,
941        );
942
943        let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
944        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
945        idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
946
947        // Query near-identical to the "db" entry, remembering a *different* key.
948        let query = [1.0, 0.0, 0.0];
949        let dups = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
950        assert_eq!(
951            dups.len(),
952            1,
953            "only the near-identical entry clears threshold"
954        );
955        assert_eq!(dups[0].key, "db");
956        assert!(dups[0].similarity >= 0.86);
957    }
958
959    #[test]
960    fn semantic_dup_excludes_self_and_judged() {
961        let policy = MemoryPolicy::default();
962        let mut kn = ProjectKnowledge::new("/tmp/semdup-2");
963        kn.remember(
964            "arch",
965            "db",
966            "PostgreSQL primary database",
967            "s",
968            0.9,
969            &policy,
970        );
971
972        let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
973        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
974        let query = [1.0, 0.0, 0.0];
975
976        // Self: remembering the same key must never flag itself.
977        let self_hits = semantic_duplicates_from_query(&idx, &kn, "arch", "db", &query, 0.86, 3);
978        assert!(self_hits.is_empty(), "a fact is never its own duplicate");
979
980        // A different key matches — until the pair has been judged.
981        let other = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
982        assert_eq!(other.len(), 1);
983
984        kn.judged_pairs.push(crate::core::knowledge::JudgedPair {
985            key_a: "arch/database".to_string(),
986            key_b: "arch/db".to_string(),
987            verdict: "unrelated".to_string(),
988            judged_at: chrono::Utc::now(),
989        });
990        let judged = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
991        assert!(judged.is_empty(), "already-judged pairs are not re-flagged");
992    }
993}