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
18#[cfg(feature = "embeddings")]
19const ALPHA_SEMANTIC: f32 = 0.6;
20#[cfg(feature = "embeddings")]
21const BETA_CONFIDENCE: f32 = 0.25;
22#[cfg(feature = "embeddings")]
23const GAMMA_RECENCY: f32 = 0.15;
24/// Observation tier (#802): additive boost for a synthesized entity-summary in the
25/// semantic recall paths, mirroring the lexical `recall_for_output` boost so the
26/// tier is honoured regardless of whether embeddings are active. Calibrated for the
27/// [0,1] semantic score — a balanced nudge that lifts a relevant summary above
28/// incidental matches yet stays below an exact match (which scores 1.0), so a stale
29/// summary can never bury a precise raw fact.
30#[cfg(feature = "embeddings")]
31const OBSERVATION_TIER_BOOST: f32 = 0.15;
32#[cfg(feature = "embeddings")]
33const MAX_RECENCY_DAYS: f32 = 90.0;
34
35/// Cosine threshold above which a freshly-remembered fact is treated as a
36/// semantic near-duplicate of an existing one. Deliberately conservative — only
37/// genuine paraphrases ("DB is Postgres" / "we persist to PostgreSQL") clear it,
38/// so the advisory stays signal, not noise. Non-destructive: it nudges the agent
39/// to `judge`, never auto-merges (distinct facts can be near in embedding space,
40/// e.g. "Postgres 14" vs "Postgres 15").
41pub const SEMANTIC_DUP_THRESHOLD: f32 = 0.86;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FactEmbedding {
45    pub category: String,
46    pub key: String,
47    /// Legacy full-precision vector (indices written before int8 quantization).
48    /// Migrated to `quant` transparently on load and then emptied, so it only
49    /// appears in files written by older binaries.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub embedding: Vec<f32>,
52    /// int8-quantized representation (turbovec-derived) — 4× smaller on disk and
53    /// the canonical storage for every entry written by current binaries.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub quant: Option<QuantizedVector>,
56}
57
58impl FactEmbedding {
59    /// Similarity against a full-precision (L2-normalized) query. Scores directly
60    /// against the int8 codes when available; falls back to the legacy f32 vector
61    /// for not-yet-migrated entries.
62    #[cfg(feature = "embeddings")]
63    fn similarity(&self, query: &[f32]) -> f32 {
64        match &self.quant {
65            Some(q) => embedding_quant::dot_quant(query, q),
66            None => embedding_quant::dot_f32(query, &self.embedding),
67        }
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct KnowledgeEmbeddingIndex {
73    pub project_hash: String,
74    pub entries: Vec<FactEmbedding>,
75}
76
77impl KnowledgeEmbeddingIndex {
78    pub fn new(project_hash: &str) -> Self {
79        Self {
80            project_hash: project_hash.to_string(),
81            entries: Vec::new(),
82        }
83    }
84
85    pub fn upsert(&mut self, category: &str, key: &str, embedding: &[f32]) {
86        let quant = Some(embedding_quant::quantize(embedding));
87        if let Some(existing) = self
88            .entries
89            .iter_mut()
90            .find(|e| e.category == category && e.key == key)
91        {
92            existing.quant = quant;
93            existing.embedding = Vec::new();
94        } else {
95            self.entries.push(FactEmbedding {
96                category: category.to_string(),
97                key: key.to_string(),
98                embedding: Vec::new(),
99                quant,
100            });
101        }
102    }
103
104    /// Upgrades any legacy full-precision entries to int8 in place. Returns true
105    /// if anything changed (so the caller can persist the smaller form once).
106    fn migrate_legacy_entries(&mut self) -> bool {
107        let mut changed = false;
108        for e in &mut self.entries {
109            if e.quant.is_none() && !e.embedding.is_empty() {
110                e.quant = Some(embedding_quant::quantize(&e.embedding));
111                e.embedding = Vec::new();
112                changed = true;
113            }
114        }
115        changed
116    }
117
118    pub fn remove(&mut self, category: &str, key: &str) {
119        self.entries
120            .retain(|e| !(e.category == category && e.key == key));
121    }
122
123    #[cfg(feature = "embeddings")]
124    pub fn semantic_search(
125        &self,
126        query_embedding: &[f32],
127        top_k: usize,
128    ) -> Vec<(&FactEmbedding, f32)> {
129        let mut scored: Vec<(&FactEmbedding, f32)> = self
130            .entries
131            .iter()
132            .map(|e| {
133                let sim = e.similarity(query_embedding);
134                (e, sim)
135            })
136            .collect();
137
138        scored.sort_by(|a, b| {
139            b.1.partial_cmp(&a.1)
140                .unwrap_or(std::cmp::Ordering::Equal)
141                .then_with(|| a.0.category.cmp(&b.0.category))
142                .then_with(|| a.0.key.cmp(&b.0.key))
143        });
144        scored.truncate(top_k);
145        scored
146    }
147
148    fn index_path(project_hash: &str) -> Option<PathBuf> {
149        let dir = crate::core::data_dir::lean_ctx_data_dir()
150            .ok()?
151            .join("knowledge")
152            .join(project_hash);
153        Some(dir.join("embeddings.json"))
154    }
155
156    pub fn load(project_hash: &str) -> Option<Self> {
157        let path = Self::index_path(project_hash)?;
158        let data = std::fs::read_to_string(path).ok()?;
159        let mut index: Self = serde_json::from_str(&data).ok()?;
160        // Pay the one-time int8 migration cost on first load by an upgraded binary,
161        // then persist so subsequent loads read the 4×-smaller form.
162        if index.migrate_legacy_entries() {
163            let _ = index.save();
164        }
165        Some(index)
166    }
167
168    pub fn save(&self) -> Result<(), String> {
169        let path = Self::index_path(&self.project_hash)
170            .ok_or_else(|| "Cannot determine data directory".to_string())?;
171        let json = serde_json::to_string(self).map_err(|e| format!("{e}"))?;
172        // Atomic write (temp + rename) so a concurrent, lock-free reader in
173        // `recall` (which loads the index without taking the per-project lock)
174        // never observes a half-written file — it sees either the old or the new
175        // complete index, never trailing garbage (issue #412).
176        crate::config_io::write_atomic(&path, &json)
177    }
178}
179
180pub fn reset(project_hash: &str) -> Result<(), String> {
181    let path = KnowledgeEmbeddingIndex::index_path(project_hash)
182        .ok_or_else(|| "Cannot determine data directory".to_string())?;
183    if path.exists() {
184        std::fs::remove_file(&path).map_err(|e| format!("{e}"))?;
185    }
186    Ok(())
187}
188
189#[derive(Debug)]
190pub struct ScoredFact<'a> {
191    pub fact: &'a KnowledgeFact,
192    pub score: f32,
193    pub semantic_score: f32,
194    pub confidence_score: f32,
195    pub recency_score: f32,
196}
197
198#[cfg(feature = "embeddings")]
199pub fn semantic_recall<'a>(
200    knowledge: &'a ProjectKnowledge,
201    index: &KnowledgeEmbeddingIndex,
202    engine: &EmbeddingEngine,
203    query: &str,
204    top_k: usize,
205) -> Vec<ScoredFact<'a>> {
206    let Ok(query_embedding) = engine.embed_query(query) else {
207        return lexical_fallback(knowledge, query, top_k);
208    };
209
210    let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
211
212    let mut results: Vec<ScoredFact<'a>> = Vec::new();
213
214    for (entry, sim) in &semantic_hits {
215        if let Some(fact) = knowledge
216            .facts
217            .iter()
218            .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
219        {
220            let confidence_score = fact.quality_score();
221            let recency_score = recency_decay(fact);
222            // Observation tier (#802): honour synthesized entity-summaries in the
223            // semantic path too, not just lexical recall_for_output. Balanced nudge.
224            let score = apply_observation_tier(
225                ALPHA_SEMANTIC * sim
226                    + BETA_CONFIDENCE * confidence_score
227                    + GAMMA_RECENCY * recency_score,
228                fact,
229            );
230
231            results.push(ScoredFact {
232                fact,
233                score,
234                semantic_score: *sim,
235                confidence_score,
236                recency_score,
237            });
238        }
239    }
240
241    let exact_matches = knowledge.recall(query);
242    for fact in exact_matches {
243        let already_included = results
244            .iter()
245            .any(|r| r.fact.category == fact.category && r.fact.key == fact.key);
246        if !already_included {
247            results.push(ScoredFact {
248                fact,
249                score: 1.0,
250                semantic_score: 1.0,
251                confidence_score: fact.quality_score(),
252                recency_score: recency_decay(fact),
253            });
254        }
255    }
256
257    results.sort_by(|a, b| {
258        b.score
259            .partial_cmp(&a.score)
260            .unwrap_or(std::cmp::Ordering::Equal)
261            .then_with(|| {
262                b.confidence_score
263                    .partial_cmp(&a.confidence_score)
264                    .unwrap_or(std::cmp::Ordering::Equal)
265            })
266            .then_with(|| {
267                b.recency_score
268                    .partial_cmp(&a.recency_score)
269                    .unwrap_or(std::cmp::Ordering::Equal)
270            })
271            .then_with(|| a.fact.category.cmp(&b.fact.category))
272            .then_with(|| a.fact.key.cmp(&b.fact.key))
273            .then_with(|| a.fact.value.cmp(&b.fact.value))
274    });
275    results.truncate(top_k);
276    results
277}
278
279#[cfg(feature = "embeddings")]
280pub fn semantic_recall_semantic_only<'a>(
281    knowledge: &'a ProjectKnowledge,
282    index: &KnowledgeEmbeddingIndex,
283    engine: &EmbeddingEngine,
284    query: &str,
285    top_k: usize,
286) -> Vec<ScoredFact<'a>> {
287    let Ok(query_embedding) = engine.embed_query(query) else {
288        return Vec::new();
289    };
290
291    let semantic_hits = index.semantic_search(&query_embedding, top_k * 2);
292    let mut results: Vec<ScoredFact<'a>> = Vec::new();
293
294    for (entry, sim) in &semantic_hits {
295        if let Some(fact) = knowledge
296            .facts
297            .iter()
298            .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
299        {
300            let confidence_score = fact.quality_score();
301            let recency_score = recency_decay(fact);
302            // Observation tier (#802): honour synthesized entity-summaries in the
303            // semantic path too, not just lexical recall_for_output. Balanced nudge.
304            let score = apply_observation_tier(
305                ALPHA_SEMANTIC * sim
306                    + BETA_CONFIDENCE * confidence_score
307                    + GAMMA_RECENCY * recency_score,
308                fact,
309            );
310
311            results.push(ScoredFact {
312                fact,
313                score,
314                semantic_score: *sim,
315                confidence_score,
316                recency_score,
317            });
318        }
319    }
320
321    results.sort_by(|a, b| {
322        b.score
323            .partial_cmp(&a.score)
324            .unwrap_or(std::cmp::Ordering::Equal)
325            .then_with(|| {
326                b.confidence_score
327                    .partial_cmp(&a.confidence_score)
328                    .unwrap_or(std::cmp::Ordering::Equal)
329            })
330            .then_with(|| {
331                b.recency_score
332                    .partial_cmp(&a.recency_score)
333                    .unwrap_or(std::cmp::Ordering::Equal)
334            })
335            .then_with(|| a.fact.category.cmp(&b.fact.category))
336            .then_with(|| a.fact.key.cmp(&b.fact.key))
337            .then_with(|| a.fact.value.cmp(&b.fact.value))
338    });
339    results.truncate(top_k);
340    results
341}
342
343pub fn compact_against_knowledge(
344    index: &mut KnowledgeEmbeddingIndex,
345    knowledge: &ProjectKnowledge,
346    policy: &MemoryPolicy,
347) {
348    use std::collections::HashMap;
349
350    let mut current: HashMap<(&str, &str), &KnowledgeFact> = HashMap::new();
351    for f in &knowledge.facts {
352        if f.is_current() {
353            current.insert((f.category.as_str(), f.key.as_str()), f);
354        }
355    }
356
357    let mut kept: Vec<(FactEmbedding, &KnowledgeFact)> = index
358        .entries
359        .iter()
360        .filter_map(|e| {
361            current
362                .get(&(e.category.as_str(), e.key.as_str()))
363                .map(|f| (e.clone(), *f))
364        })
365        .collect();
366
367    kept.sort_by(|(ea, fa), (eb, fb)| {
368        fb.confidence
369            .partial_cmp(&fa.confidence)
370            .unwrap_or(std::cmp::Ordering::Equal)
371            .then_with(|| fb.last_confirmed.cmp(&fa.last_confirmed))
372            .then_with(|| fb.retrieval_count.cmp(&fa.retrieval_count))
373            .then_with(|| ea.category.cmp(&eb.category))
374            .then_with(|| ea.key.cmp(&eb.key))
375    });
376
377    let max = policy.embeddings.max_facts;
378    if kept.len() > max {
379        kept.truncate(max);
380    }
381
382    index.entries = kept.into_iter().map(|(e, _)| e).collect();
383}
384
385#[cfg(feature = "embeddings")]
386fn lexical_fallback<'a>(
387    knowledge: &'a ProjectKnowledge,
388    query: &str,
389    top_k: usize,
390) -> Vec<ScoredFact<'a>> {
391    knowledge
392        .recall(query)
393        .into_iter()
394        .take(top_k)
395        .map(|fact| ScoredFact {
396            fact,
397            score: fact.confidence,
398            semantic_score: 0.0,
399            confidence_score: fact.confidence,
400            recency_score: recency_decay(fact),
401        })
402        .collect()
403}
404
405#[cfg(feature = "embeddings")]
406fn recency_decay(fact: &KnowledgeFact) -> f32 {
407    let days_old = chrono::Utc::now()
408        .signed_duration_since(fact.last_confirmed)
409        .num_days() as f32;
410    (1.0 - days_old / MAX_RECENCY_DAYS).max(0.0)
411}
412
413/// Add the observation-tier boost (#802) to a base recall score iff `fact` is a
414/// synthesized entity-summary. Pure + deterministic so the tier policy can be
415/// unit-tested without spinning up the embedding engine.
416#[cfg(feature = "embeddings")]
417fn apply_observation_tier(base: f32, fact: &KnowledgeFact) -> f32 {
418    if fact.is_synthesized_observation() {
419        base + OBSERVATION_TIER_BOOST
420    } else {
421        base
422    }
423}
424
425#[cfg(feature = "embeddings")]
426pub fn embed_and_store(
427    index: &mut KnowledgeEmbeddingIndex,
428    engine: &EmbeddingEngine,
429    category: &str,
430    key: &str,
431    value: &str,
432) -> Result<(), String> {
433    let text = format!("{category} {key}: {value}");
434    let embedding = engine.embed(&text).map_err(|e| format!("{e}"))?;
435    index.upsert(category, key, &embedding);
436    Ok(())
437}
438
439/// Per-`remember` cap for [`backfill_missing`]. One MiniLM mini-batch
440/// (`embed_batch` chunks at 64) — bounded latency on the write path while an
441/// actively-used project converges to full vector coverage within a few calls.
442pub const BACKFILL_PER_REMEMBER: usize = 32;
443
444/// Current facts that have no vector in the side-car index, most valuable
445/// first (same ordering as `embeddings_reindex`: confidence, then recency).
446/// Engine-free so the selection is unit-testable with raw indices.
447pub fn missing_current_facts<'a>(
448    index: &KnowledgeEmbeddingIndex,
449    knowledge: &'a ProjectKnowledge,
450    cap: usize,
451) -> Vec<&'a KnowledgeFact> {
452    use std::collections::HashSet;
453
454    let have: HashSet<(&str, &str)> = index
455        .entries
456        .iter()
457        .map(|e| (e.category.as_str(), e.key.as_str()))
458        .collect();
459
460    // A vector is derived from the fact's value: a fact that may not be
461    // delivered now is not embedded (`ProjectKnowledge::delivery_filter`).
462    let deliverable = knowledge.delivery_filter();
463    let mut missing: Vec<&KnowledgeFact> = knowledge
464        .facts
465        .iter()
466        .filter(|&f| {
467            f.is_current()
468                && !have.contains(&(f.category.as_str(), f.key.as_str()))
469                && deliverable(f)
470        })
471        .collect();
472    missing.sort_by(|a, b| {
473        b.confidence
474            .partial_cmp(&a.confidence)
475            .unwrap_or(std::cmp::Ordering::Equal)
476            .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
477            .then_with(|| a.category.cmp(&b.category))
478            .then_with(|| a.key.cmp(&b.key))
479    });
480    missing.truncate(cap);
481    missing
482}
483
484/// Lazy vector backfill: embeds up to `cap` current facts that are missing
485/// from the side-car index. Facts land without vectors on two paths — the
486/// consolidation/ETL writers never embed at all, and a non-blocking `remember`
487/// skips its side-car while the engine is still warming up. Without a healer
488/// those facts stay invisible to `mode=semantic` recall until a *manual*
489/// `embeddings_reindex`. Called from `remember` under the per-project lock
490/// once the engine is warm, so active projects self-heal incrementally.
491/// Returns the number of facts embedded.
492#[cfg(feature = "embeddings")]
493pub fn backfill_missing(
494    index: &mut KnowledgeEmbeddingIndex,
495    engine: &EmbeddingEngine,
496    knowledge: &ProjectKnowledge,
497    cap: usize,
498) -> usize {
499    let missing = missing_current_facts(index, knowledge, cap);
500    if missing.is_empty() {
501        return 0;
502    }
503
504    let texts: Vec<String> = missing
505        .iter()
506        .map(|f| format!("{} {}: {}", f.category, f.key, f.value))
507        .collect();
508    let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
509    let Ok(vectors) = engine.embed_batch(&refs) else {
510        return 0;
511    };
512
513    let mut stored = 0usize;
514    for (fact, vector) in missing.iter().zip(vectors) {
515        index.upsert(&fact.category, &fact.key, &vector);
516        stored += 1;
517    }
518    stored
519}
520
521/// Embedding-based near-duplicate detection for `remember`. Mirrors the lexical
522/// [`find_cross_key_similar`] but scores cosine similarity, so paraphrases that
523/// share few tokens are still caught. Read-only against the *pre-upsert* index,
524/// so the incoming fact never matches itself. Returns advisory hits for the
525/// agent to resolve via `judge` — it never mutates or merges facts.
526///
527/// [`find_cross_key_similar`]: crate::core::knowledge::find_cross_key_similar
528#[cfg(feature = "embeddings")]
529pub fn find_semantic_duplicates(
530    index: &KnowledgeEmbeddingIndex,
531    engine: &EmbeddingEngine,
532    knowledge: &ProjectKnowledge,
533    new_category: &str,
534    new_key: &str,
535    new_value: &str,
536    threshold: f32,
537    limit: usize,
538) -> Vec<crate::core::knowledge::SimilarFact> {
539    // Cheap static-model embed (microseconds); kept separate from the storage
540    // embed in `embed_and_store` so its well-tested side-car path is untouched.
541    let text = format!("{new_category} {new_key}: {new_value}");
542    let Ok(query) = engine.embed(&text) else {
543        return Vec::new();
544    };
545    semantic_duplicates_from_query(
546        index,
547        knowledge,
548        new_category,
549        new_key,
550        &query,
551        threshold,
552        limit,
553    )
554}
555
556/// Engine-free core of [`find_semantic_duplicates`]: scans an in-memory index
557/// for entries whose cosine similarity to a precomputed query embedding clears
558/// `threshold`, maps them back to current facts, and excludes the incoming
559/// fact's own key, non-current facts, and pairs the agent already judged. Pure
560/// and deterministic so the dedup logic is unit-testable with raw vectors.
561#[cfg(feature = "embeddings")]
562fn semantic_duplicates_from_query(
563    index: &KnowledgeEmbeddingIndex,
564    knowledge: &ProjectKnowledge,
565    new_category: &str,
566    new_key: &str,
567    query: &[f32],
568    threshold: f32,
569    limit: usize,
570) -> Vec<crate::core::knowledge::SimilarFact> {
571    use crate::core::knowledge::SimilarFact;
572
573    let composite_key = format!("{new_category}/{new_key}");
574
575    let mut scored: Vec<(&FactEmbedding, f32)> = index
576        .entries
577        .iter()
578        .filter(|e| !(e.category == new_category && e.key == new_key))
579        .map(|e| (e, e.similarity(query)))
580        .filter(|(_, sim)| *sim >= threshold)
581        .collect();
582    scored.sort_by(|a, b| {
583        b.1.partial_cmp(&a.1)
584            .unwrap_or(std::cmp::Ordering::Equal)
585            .then_with(|| a.0.category.cmp(&b.0.category))
586            .then_with(|| a.0.key.cmp(&b.0.key))
587    });
588
589    let mut out: Vec<SimilarFact> = Vec::new();
590    for (entry, sim) in scored {
591        let other_key = format!("{}/{}", entry.category, entry.key);
592        let already_judged = knowledge.judged_pairs.iter().any(|jp| {
593            (jp.key_a == composite_key && jp.key_b == other_key)
594                || (jp.key_a == other_key && jp.key_b == composite_key)
595        });
596        if already_judged {
597            continue;
598        }
599        let Some(fact) = knowledge
600            .facts
601            .iter()
602            .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
603        else {
604            continue;
605        };
606        let preview = if fact.value.len() > 60 {
607            format!("{}...", &fact.value[..fact.value.floor_char_boundary(57)])
608        } else {
609            fact.value.clone()
610        };
611        out.push(SimilarFact {
612            category: fact.category.clone(),
613            key: fact.key.clone(),
614            value_preview: preview,
615            similarity: sim,
616        });
617        if out.len() >= limit {
618            break;
619        }
620    }
621    out
622}
623
624pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
625    if results.is_empty() {
626        return "No matching facts found.".to_string();
627    }
628
629    let mut output = String::new();
630    for (i, scored) in results.iter().enumerate() {
631        let f = scored.fact;
632        let stars = if f.confidence >= 0.9 {
633            "★★★★"
634        } else if f.confidence >= 0.7 {
635            "★★★"
636        } else if f.confidence >= 0.5 {
637            "★★"
638        } else {
639            "★"
640        };
641
642        if i > 0 {
643            output.push('|');
644        }
645        output.push_str(&format!(
646            "{}:{}={}{} [s:{:.0}%]",
647            f.category,
648            f.key,
649            f.value,
650            stars,
651            scored.score * 100.0
652        ));
653    }
654    output
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::core::knowledge::KnowledgeArchetype;
661
662    fn fact_with(category: &str, key: &str, source: &str) -> KnowledgeFact {
663        let now = chrono::Utc::now();
664        KnowledgeFact {
665            anchors: Vec::new(),
666            review: None,
667            capture: Default::default(),
668            origin: crate::core::knowledge::FactOrigin::Local,
669            category: category.to_string(),
670            key: key.to_string(),
671            value: "v".to_string(),
672            source_session: source.to_string(),
673            confidence: 0.8,
674            created_at: now,
675            last_confirmed: now,
676            retrieval_count: 0,
677            last_retrieved: None,
678            valid_from: None,
679            valid_until: None,
680            supersedes: None,
681            confirmation_count: 0,
682            feedback_up: 0,
683            feedback_down: 0,
684            last_feedback: None,
685            privacy: crate::core::memory_boundary::FactPrivacy::default(),
686            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
687            imported_from: None,
688            archetype: KnowledgeArchetype::infer_from_category(category),
689            fidelity: None,
690            revision_count: 0,
691            status: None,
692            validity: None,
693        }
694    }
695
696    #[cfg(feature = "embeddings")]
697    #[test]
698    fn observation_tier_boosts_only_synthesized_summaries() {
699        use crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE;
700        // A synthesized observation (cognition-synthesis source) earns the tier boost.
701        let obs = fact_with(
702            "observation",
703            "src/auth/session.rs",
704            COGNITION_SYNTHESIS_SOURCE,
705        );
706        // A user finding (also Observation archetype) is NOT synthesized → no boost.
707        let user_finding = fact_with("observation", "src/auth/session.rs:1", "session-7");
708        // A raw evidence fact → no boost.
709        let raw = fact_with("gotcha", "src/auth/session.rs:2", "session-7");
710
711        assert!((apply_observation_tier(0.5, &raw) - 0.5).abs() < 1e-6);
712        assert!((apply_observation_tier(0.5, &user_finding) - 0.5).abs() < 1e-6);
713        assert!((apply_observation_tier(0.5, &obs) - (0.5 + OBSERVATION_TIER_BOOST)).abs() < 1e-6);
714        // The boost lifts a synthesized summary above an equal-base raw fact, yet is
715        // bounded so a boosted summary stays below an exact match (which scores 1.0).
716        assert!(apply_observation_tier(0.5, &obs) > apply_observation_tier(0.5, &raw));
717        assert!(apply_observation_tier(0.8, &obs) < 1.0);
718    }
719
720    #[test]
721    fn reset_removes_index_file() {
722        let _lock = crate::core::data_dir::test_env_lock();
723        let tmp = tempfile::tempdir().expect("tempdir");
724        crate::test_env::set_var(
725            "LEAN_CTX_DATA_DIR",
726            tmp.path().to_string_lossy().to_string(),
727        );
728
729        let idx = KnowledgeEmbeddingIndex {
730            project_hash: "projhash".to_string(),
731            entries: vec![FactEmbedding {
732                category: "arch".to_string(),
733                key: "db".to_string(),
734                embedding: vec![1.0, 0.0, 0.0],
735                quant: None,
736            }],
737        };
738        idx.save().expect("save");
739        assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
740
741        reset("projhash").expect("reset");
742        assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
743
744        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
745    }
746
747    #[cfg(feature = "embeddings")]
748    #[test]
749    fn concurrent_remember_keeps_all_embeddings() {
750        // #412: the embedding-index read-modify-write must be serialized under
751        // the per-project lock and compacted against fresh on-disk knowledge.
752        // The old lock-free + stale-snapshot path let parallel writers clobber
753        // each other's vectors and prune just-stored ones. This mirrors
754        // `handle_remember`'s locked path (raw vectors, so no embedding engine
755        // is needed) and asserts every concurrently-stored embedding survives.
756        let _lock = crate::core::data_dir::test_env_lock();
757        let tmp = tempfile::tempdir().expect("tempdir");
758        crate::test_env::set_var(
759            "LEAN_CTX_DATA_DIR",
760            tmp.path().to_string_lossy().to_string(),
761        );
762
763        let project = tmp.path().join("proj");
764        std::fs::create_dir_all(&project).expect("mkdir");
765        let project_root = project.to_string_lossy().to_string();
766
767        const N: usize = 16;
768        let mut handles = Vec::with_capacity(N);
769        for i in 0..N {
770            let root = project_root.clone();
771            handles.push(std::thread::spawn(move || {
772                let policy = MemoryPolicy::default();
773                let cat = "arch";
774                let key = format!("k{i}");
775                // 1) Commit the fact under the lock (as handle_remember does).
776                //    The lock wait is bounded (2 s) by design; sixteen writers
777                //    doing fsync-heavy saves on a slow Windows runner can exceed
778                //    it, and a caller then retries — the property under test is
779                //    that no committed embedding is lost, not the wait bound.
780                let mut attempts = 0;
781                let (knowledge, ()) = loop {
782                    match ProjectKnowledge::mutate_locked(&root, |kn| {
783                        kn.remember(cat, &key, "v", "s", 0.9, &policy);
784                    }) {
785                        Ok(committed) => break committed,
786                        Err(error) if error.contains("lock unavailable") && attempts < 20 => {
787                            attempts += 1;
788                        }
789                        Err(error) => panic!("commit fact: {error:?}"),
790                    }
791                };
792                // 2) Embedding side-car under the SAME lock + fresh-knowledge
793                //    compaction — exactly the fixed handle_remember path.
794                ProjectKnowledge::with_project_lock(&root, || {
795                    let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
796                        .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
797                    idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
798                    let fresh = ProjectKnowledge::load(&root);
799                    let kref = fresh.as_ref().unwrap_or(&knowledge);
800                    compact_against_knowledge(&mut idx, kref, &policy);
801                    idx.save().expect("save index");
802                });
803            }));
804        }
805        for h in handles {
806            h.join().expect("thread join");
807        }
808
809        let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
810        let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
811        assert_eq!(current, N, "all {N} facts must be committed");
812
813        let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
814        assert_eq!(
815            idx.entries.len(),
816            N,
817            "every concurrently-stored embedding must survive (got {})",
818            idx.entries.len()
819        );
820
821        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
822    }
823
824    #[test]
825    fn compact_drops_missing_or_archived_facts() {
826        let knowledge_root = tempfile::tempdir().unwrap();
827        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
828        let now = chrono::Utc::now();
829        knowledge.facts.push(KnowledgeFact {
830            anchors: Vec::new(),
831            review: None,
832            capture: Default::default(),
833            origin: crate::core::knowledge::FactOrigin::Local,
834            category: "arch".to_string(),
835            key: "db".to_string(),
836            value: "Postgres".to_string(),
837            source_session: "s".to_string(),
838            confidence: 0.9,
839            created_at: now,
840            last_confirmed: now,
841            retrieval_count: 5,
842            last_retrieved: None,
843            valid_from: None,
844            valid_until: None,
845            supersedes: None,
846            confirmation_count: 1,
847            feedback_up: 0,
848            feedback_down: 0,
849            last_feedback: None,
850            privacy: crate::core::memory_boundary::FactPrivacy::default(),
851            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
852            imported_from: None,
853            archetype: KnowledgeArchetype::default(),
854            fidelity: None,
855            revision_count: 0,
856            status: None,
857            validity: None,
858        });
859        knowledge.facts.push(KnowledgeFact {
860            anchors: Vec::new(),
861            review: None,
862            capture: Default::default(),
863            origin: crate::core::knowledge::FactOrigin::Local,
864            category: "arch".to_string(),
865            key: "old".to_string(),
866            value: "Old".to_string(),
867            source_session: "s".to_string(),
868            confidence: 0.9,
869            created_at: now,
870            last_confirmed: now,
871            retrieval_count: 0,
872            last_retrieved: None,
873            valid_from: None,
874            valid_until: Some(now),
875            supersedes: None,
876            confirmation_count: 1,
877            feedback_up: 0,
878            feedback_down: 0,
879            last_feedback: None,
880            privacy: crate::core::memory_boundary::FactPrivacy::default(),
881            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
882            imported_from: None,
883            archetype: KnowledgeArchetype::default(),
884            fidelity: None,
885            revision_count: 0,
886            status: None,
887            validity: None,
888        });
889
890        let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
891        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
892        idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
893        idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
894
895        compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
896        assert_eq!(idx.entries.len(), 1);
897        assert_eq!(idx.entries[0].category, "arch");
898        assert_eq!(idx.entries[0].key, "db");
899    }
900
901    #[test]
902    fn missing_current_facts_selects_unindexed_by_confidence_capped() {
903        let knowledge_root = tempfile::tempdir().unwrap();
904        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
905
906        let indexed = fact_with("arch", "indexed", "s");
907        let mut high = fact_with("arch", "high", "s");
908        high.confidence = 0.95;
909        let mut low = fact_with("arch", "low", "s");
910        low.confidence = 0.5;
911        let mut archived = fact_with("arch", "archived", "s");
912        archived.valid_until = Some(chrono::Utc::now());
913        knowledge.facts.extend([indexed, high, low, archived]);
914
915        let mut idx = KnowledgeEmbeddingIndex::new("test");
916        idx.upsert("arch", "indexed", &[1.0, 0.0, 0.0]);
917
918        // Already-indexed and archived facts are excluded; rest ordered by
919        // confidence (the reindex ordering).
920        let missing = missing_current_facts(&idx, &knowledge, 10);
921        let keys: Vec<&str> = missing.iter().map(|f| f.key.as_str()).collect();
922        assert_eq!(keys, vec!["high", "low"]);
923
924        // Cap bounds the batch (most valuable first).
925        let capped = missing_current_facts(&idx, &knowledge, 1);
926        assert_eq!(capped.len(), 1);
927        assert_eq!(capped[0].key, "high");
928
929        // Full coverage → nothing to backfill.
930        idx.upsert("arch", "high", &[0.0, 1.0, 0.0]);
931        idx.upsert("arch", "low", &[0.0, 0.0, 1.0]);
932        assert!(missing_current_facts(&idx, &knowledge, 10).is_empty());
933    }
934
935    #[test]
936    fn index_upsert_and_remove() {
937        let mut idx = KnowledgeEmbeddingIndex::new("test");
938        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
939        assert_eq!(idx.entries.len(), 1);
940
941        idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
942        assert_eq!(idx.entries.len(), 1);
943        // Stored quantized now: the dominant axis reconstructs to ~1.0.
944        let recon = idx.entries[0]
945            .quant
946            .as_ref()
947            .expect("quantized")
948            .dequantize();
949        assert!((recon[1] - 1.0).abs() < 1e-6);
950
951        idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
952        assert_eq!(idx.entries.len(), 2);
953
954        idx.remove("arch", "db");
955        assert_eq!(idx.entries.len(), 1);
956        assert_eq!(idx.entries[0].key, "cache");
957    }
958
959    #[cfg(feature = "embeddings")]
960    #[test]
961    fn recency_decay_recent() {
962        let fact = KnowledgeFact {
963            anchors: Vec::new(),
964            review: None,
965            capture: Default::default(),
966            origin: crate::core::knowledge::FactOrigin::Local,
967            category: "test".to_string(),
968            key: "k".to_string(),
969            value: "v".to_string(),
970            source_session: "s".to_string(),
971            confidence: 0.9,
972            created_at: chrono::Utc::now(),
973            last_confirmed: chrono::Utc::now(),
974            retrieval_count: 0,
975            last_retrieved: None,
976            valid_from: None,
977            valid_until: None,
978            supersedes: None,
979            confirmation_count: 1,
980            feedback_up: 0,
981            feedback_down: 0,
982            last_feedback: None,
983            privacy: crate::core::memory_boundary::FactPrivacy::default(),
984            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
985            imported_from: None,
986            archetype: KnowledgeArchetype::default(),
987            fidelity: None,
988            revision_count: 0,
989            status: None,
990            validity: None,
991        };
992        let decay = recency_decay(&fact);
993        assert!(
994            decay > 0.95,
995            "Recent fact should have high recency: {decay}"
996        );
997    }
998
999    #[cfg(feature = "embeddings")]
1000    #[test]
1001    fn recency_decay_old() {
1002        let old_date = chrono::Utc::now() - chrono::Duration::days(100);
1003        let fact = KnowledgeFact {
1004            anchors: Vec::new(),
1005            review: None,
1006            capture: Default::default(),
1007            origin: crate::core::knowledge::FactOrigin::Local,
1008            category: "test".to_string(),
1009            key: "k".to_string(),
1010            value: "v".to_string(),
1011            source_session: "s".to_string(),
1012            confidence: 0.5,
1013            created_at: old_date,
1014            last_confirmed: old_date,
1015            retrieval_count: 0,
1016            last_retrieved: None,
1017            valid_from: None,
1018            valid_until: None,
1019            supersedes: None,
1020            confirmation_count: 1,
1021            feedback_up: 0,
1022            feedback_down: 0,
1023            last_feedback: None,
1024            privacy: crate::core::memory_boundary::FactPrivacy::default(),
1025            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
1026            imported_from: None,
1027            archetype: KnowledgeArchetype::default(),
1028            fidelity: None,
1029            revision_count: 0,
1030            status: None,
1031            validity: None,
1032        };
1033        let decay = recency_decay(&fact);
1034        assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
1035    }
1036
1037    #[cfg(feature = "embeddings")]
1038    #[test]
1039    fn semantic_search_ranking() {
1040        let mut idx = KnowledgeEmbeddingIndex::new("test");
1041        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1042        idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
1043        idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
1044
1045        let query = vec![1.0, 0.0, 0.0];
1046        let results = idx.semantic_search(&query, 2);
1047        assert_eq!(results.len(), 2);
1048        assert_eq!(results[0].0.key, "db");
1049    }
1050
1051    #[test]
1052    fn format_scored_empty() {
1053        assert_eq!(format_scored_facts(&[]), "No matching facts found.");
1054    }
1055
1056    #[test]
1057    fn format_scored_output() {
1058        let fact = KnowledgeFact {
1059            anchors: Vec::new(),
1060            review: None,
1061            capture: Default::default(),
1062            origin: crate::core::knowledge::FactOrigin::Local,
1063            category: "arch".to_string(),
1064            key: "db".to_string(),
1065            value: "PostgreSQL".to_string(),
1066            source_session: "s1".to_string(),
1067            confidence: 0.95,
1068            created_at: chrono::Utc::now(),
1069            last_confirmed: chrono::Utc::now(),
1070            retrieval_count: 0,
1071            last_retrieved: None,
1072            valid_from: None,
1073            valid_until: None,
1074            supersedes: None,
1075            confirmation_count: 3,
1076            feedback_up: 0,
1077            feedback_down: 0,
1078            last_feedback: None,
1079            privacy: crate::core::memory_boundary::FactPrivacy::default(),
1080            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
1081            imported_from: None,
1082            archetype: KnowledgeArchetype::default(),
1083            fidelity: None,
1084            revision_count: 0,
1085            status: None,
1086            validity: None,
1087        };
1088        let scored = vec![ScoredFact {
1089            fact: &fact,
1090            score: 0.85,
1091            semantic_score: 0.9,
1092            confidence_score: 0.95,
1093            recency_score: 1.0,
1094        }];
1095        let output = format_scored_facts(&scored);
1096        assert!(output.contains("arch:db=PostgreSQL"));
1097        assert!(output.contains("★★★★"));
1098        assert!(output.contains("[s:85%]"));
1099    }
1100
1101    #[cfg(feature = "embeddings")]
1102    #[test]
1103    fn semantic_dup_flags_high_cosine_other_key() {
1104        let _policy = crate::core::policy::runtime::lock_test_override(); // remember() admits through the process-wide policy
1105        let policy = MemoryPolicy::default();
1106        let kn_root = tempfile::tempdir().unwrap();
1107        let mut kn = ProjectKnowledge::new(kn_root.path().to_str().unwrap());
1108        kn.remember(
1109            "arch",
1110            "db",
1111            "PostgreSQL is the primary database",
1112            "s",
1113            0.9,
1114            &policy,
1115        );
1116        kn.remember(
1117            "arch",
1118            "cache",
1119            "Redis is the cache layer",
1120            "s",
1121            0.9,
1122            &policy,
1123        );
1124
1125        let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1126        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1127        idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
1128
1129        // Query near-identical to the "db" entry, remembering a *different* key.
1130        let query = [1.0, 0.0, 0.0];
1131        let dups = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1132        assert_eq!(
1133            dups.len(),
1134            1,
1135            "only the near-identical entry clears threshold"
1136        );
1137        assert_eq!(dups[0].key, "db");
1138        assert!(dups[0].similarity >= 0.86);
1139    }
1140
1141    #[cfg(feature = "embeddings")]
1142    #[test]
1143    fn semantic_dup_excludes_self_and_judged() {
1144        let _policy = crate::core::policy::runtime::lock_test_override(); // remember() admits through the process-wide policy
1145        let policy = MemoryPolicy::default();
1146        let kn_root = tempfile::tempdir().unwrap();
1147        let mut kn = ProjectKnowledge::new(kn_root.path().to_str().unwrap());
1148        kn.remember(
1149            "arch",
1150            "db",
1151            "PostgreSQL primary database",
1152            "s",
1153            0.9,
1154            &policy,
1155        );
1156
1157        let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1158        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1159        let query = [1.0, 0.0, 0.0];
1160
1161        // Self: remembering the same key must never flag itself.
1162        let self_hits = semantic_duplicates_from_query(&idx, &kn, "arch", "db", &query, 0.86, 3);
1163        assert!(self_hits.is_empty(), "a fact is never its own duplicate");
1164
1165        // A different key matches — until the pair has been judged.
1166        let other = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1167        assert_eq!(other.len(), 1);
1168
1169        kn.judged_pairs.push(crate::core::knowledge::JudgedPair {
1170            key_a: "arch/database".to_string(),
1171            key_b: "arch/db".to_string(),
1172            verdict: "unrelated".to_string(),
1173            judged_at: chrono::Utc::now(),
1174        });
1175        let judged = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1176        assert!(judged.is_empty(), "already-judged pairs are not re-flagged");
1177    }
1178
1179    /// A fact that is not served now is not selected for embedding, so no
1180    /// vector is derived from its value.
1181    #[test]
1182    fn a_fact_that_is_not_served_is_not_selected_for_embedding() {
1183        use crate::core::context_admission::stored::lineage_support::{
1184            governed, project, scoped, two_facts, withdraw_source,
1185        };
1186        let _data = governed();
1187        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1188        let (_directory, root) = project();
1189        two_facts(&root, "architecture");
1190        let index = KnowledgeEmbeddingIndex::new("test");
1191        let selected = || -> Vec<String> {
1192            scoped(&root, || {
1193                let knowledge = ProjectKnowledge::load(&root).expect("knowledge");
1194                let mut keys: Vec<String> = missing_current_facts(&index, &knowledge, 10)
1195                    .into_iter()
1196                    .map(|fact| fact.key.clone())
1197                    .collect();
1198                keys.sort();
1199                keys
1200            })
1201        };
1202        assert_eq!(
1203            selected(),
1204            ["release-duty".to_owned(), "token-check".to_owned()]
1205        );
1206        withdraw_source(&root);
1207        assert_eq!(selected(), ["release-duty".to_owned()]);
1208    }
1209}