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/// Per-`remember` cap for [`backfill_missing`]. One MiniLM mini-batch
431/// (`embed_batch` chunks at 64) — bounded latency on the write path while an
432/// actively-used project converges to full vector coverage within a few calls.
433pub const BACKFILL_PER_REMEMBER: usize = 32;
434
435/// Current facts that have no vector in the side-car index, most valuable
436/// first (same ordering as `embeddings_reindex`: confidence, then recency).
437/// Engine-free so the selection is unit-testable with raw indices.
438pub fn missing_current_facts<'a>(
439    index: &KnowledgeEmbeddingIndex,
440    knowledge: &'a ProjectKnowledge,
441    cap: usize,
442) -> Vec<&'a KnowledgeFact> {
443    use std::collections::HashSet;
444
445    let have: HashSet<(&str, &str)> = index
446        .entries
447        .iter()
448        .map(|e| (e.category.as_str(), e.key.as_str()))
449        .collect();
450
451    let mut missing: Vec<&KnowledgeFact> = knowledge
452        .facts
453        .iter()
454        .filter(|f| f.is_current() && !have.contains(&(f.category.as_str(), f.key.as_str())))
455        .collect();
456    missing.sort_by(|a, b| {
457        b.confidence
458            .partial_cmp(&a.confidence)
459            .unwrap_or(std::cmp::Ordering::Equal)
460            .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
461            .then_with(|| a.category.cmp(&b.category))
462            .then_with(|| a.key.cmp(&b.key))
463    });
464    missing.truncate(cap);
465    missing
466}
467
468/// Lazy vector backfill: embeds up to `cap` current facts that are missing
469/// from the side-car index. Facts land without vectors on two paths — the
470/// consolidation/ETL writers never embed at all, and a non-blocking `remember`
471/// skips its side-car while the engine is still warming up. Without a healer
472/// those facts stay invisible to `mode=semantic` recall until a *manual*
473/// `embeddings_reindex`. Called from `remember` under the per-project lock
474/// once the engine is warm, so active projects self-heal incrementally.
475/// Returns the number of facts embedded.
476#[cfg(feature = "embeddings")]
477pub fn backfill_missing(
478    index: &mut KnowledgeEmbeddingIndex,
479    engine: &EmbeddingEngine,
480    knowledge: &ProjectKnowledge,
481    cap: usize,
482) -> usize {
483    let missing = missing_current_facts(index, knowledge, cap);
484    if missing.is_empty() {
485        return 0;
486    }
487
488    let texts: Vec<String> = missing
489        .iter()
490        .map(|f| format!("{} {}: {}", f.category, f.key, f.value))
491        .collect();
492    let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
493    let Ok(vectors) = engine.embed_batch(&refs) else {
494        return 0;
495    };
496
497    let mut stored = 0usize;
498    for (fact, vector) in missing.iter().zip(vectors) {
499        index.upsert(&fact.category, &fact.key, &vector);
500        stored += 1;
501    }
502    stored
503}
504
505/// Embedding-based near-duplicate detection for `remember`. Mirrors the lexical
506/// [`find_cross_key_similar`] but scores cosine similarity, so paraphrases that
507/// share few tokens are still caught. Read-only against the *pre-upsert* index,
508/// so the incoming fact never matches itself. Returns advisory hits for the
509/// agent to resolve via `judge` — it never mutates or merges facts.
510///
511/// [`find_cross_key_similar`]: crate::core::knowledge::find_cross_key_similar
512#[cfg(feature = "embeddings")]
513pub fn find_semantic_duplicates(
514    index: &KnowledgeEmbeddingIndex,
515    engine: &EmbeddingEngine,
516    knowledge: &ProjectKnowledge,
517    new_category: &str,
518    new_key: &str,
519    new_value: &str,
520    threshold: f32,
521    limit: usize,
522) -> Vec<crate::core::knowledge::SimilarFact> {
523    // Cheap static-model embed (microseconds); kept separate from the storage
524    // embed in `embed_and_store` so its well-tested side-car path is untouched.
525    let text = format!("{new_category} {new_key}: {new_value}");
526    let Ok(query) = engine.embed(&text) else {
527        return Vec::new();
528    };
529    semantic_duplicates_from_query(
530        index,
531        knowledge,
532        new_category,
533        new_key,
534        &query,
535        threshold,
536        limit,
537    )
538}
539
540/// Engine-free core of [`find_semantic_duplicates`]: scans an in-memory index
541/// for entries whose cosine similarity to a precomputed query embedding clears
542/// `threshold`, maps them back to current facts, and excludes the incoming
543/// fact's own key, non-current facts, and pairs the agent already judged. Pure
544/// and deterministic so the dedup logic is unit-testable with raw vectors.
545fn semantic_duplicates_from_query(
546    index: &KnowledgeEmbeddingIndex,
547    knowledge: &ProjectKnowledge,
548    new_category: &str,
549    new_key: &str,
550    query: &[f32],
551    threshold: f32,
552    limit: usize,
553) -> Vec<crate::core::knowledge::SimilarFact> {
554    use crate::core::knowledge::SimilarFact;
555
556    let composite_key = format!("{new_category}/{new_key}");
557
558    let mut scored: Vec<(&FactEmbedding, f32)> = index
559        .entries
560        .iter()
561        .filter(|e| !(e.category == new_category && e.key == new_key))
562        .map(|e| (e, e.similarity(query)))
563        .filter(|(_, sim)| *sim >= threshold)
564        .collect();
565    scored.sort_by(|a, b| {
566        b.1.partial_cmp(&a.1)
567            .unwrap_or(std::cmp::Ordering::Equal)
568            .then_with(|| a.0.category.cmp(&b.0.category))
569            .then_with(|| a.0.key.cmp(&b.0.key))
570    });
571
572    let mut out: Vec<SimilarFact> = Vec::new();
573    for (entry, sim) in scored {
574        let other_key = format!("{}/{}", entry.category, entry.key);
575        let already_judged = knowledge.judged_pairs.iter().any(|jp| {
576            (jp.key_a == composite_key && jp.key_b == other_key)
577                || (jp.key_a == other_key && jp.key_b == composite_key)
578        });
579        if already_judged {
580            continue;
581        }
582        let Some(fact) = knowledge
583            .facts
584            .iter()
585            .find(|f| f.category == entry.category && f.key == entry.key && f.is_current())
586        else {
587            continue;
588        };
589        let preview = if fact.value.len() > 60 {
590            format!("{}...", &fact.value[..fact.value.floor_char_boundary(57)])
591        } else {
592            fact.value.clone()
593        };
594        out.push(SimilarFact {
595            category: fact.category.clone(),
596            key: fact.key.clone(),
597            value_preview: preview,
598            similarity: sim,
599        });
600        if out.len() >= limit {
601            break;
602        }
603    }
604    out
605}
606
607pub fn format_scored_facts(results: &[ScoredFact<'_>]) -> String {
608    if results.is_empty() {
609        return "No matching facts found.".to_string();
610    }
611
612    let mut output = String::new();
613    for (i, scored) in results.iter().enumerate() {
614        let f = scored.fact;
615        let stars = if f.confidence >= 0.9 {
616            "★★★★"
617        } else if f.confidence >= 0.7 {
618            "★★★"
619        } else if f.confidence >= 0.5 {
620            "★★"
621        } else {
622            "★"
623        };
624
625        if i > 0 {
626            output.push('|');
627        }
628        output.push_str(&format!(
629            "{}:{}={}{} [s:{:.0}%]",
630            f.category,
631            f.key,
632            f.value,
633            stars,
634            scored.score * 100.0
635        ));
636    }
637    output
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::core::knowledge::KnowledgeArchetype;
644
645    fn fact_with(category: &str, key: &str, source: &str) -> KnowledgeFact {
646        let now = chrono::Utc::now();
647        KnowledgeFact {
648            category: category.to_string(),
649            key: key.to_string(),
650            value: "v".to_string(),
651            source_session: source.to_string(),
652            confidence: 0.8,
653            created_at: now,
654            last_confirmed: now,
655            retrieval_count: 0,
656            last_retrieved: None,
657            valid_from: None,
658            valid_until: None,
659            supersedes: None,
660            confirmation_count: 0,
661            feedback_up: 0,
662            feedback_down: 0,
663            last_feedback: None,
664            privacy: crate::core::memory_boundary::FactPrivacy::default(),
665            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
666            imported_from: None,
667            archetype: KnowledgeArchetype::infer_from_category(category),
668            fidelity: None,
669            revision_count: 0,
670        }
671    }
672
673    #[test]
674    fn observation_tier_boosts_only_synthesized_summaries() {
675        use crate::core::knowledge::COGNITION_SYNTHESIS_SOURCE;
676        // A synthesized observation (cognition-synthesis source) earns the tier boost.
677        let obs = fact_with(
678            "observation",
679            "src/auth/session.rs",
680            COGNITION_SYNTHESIS_SOURCE,
681        );
682        // A user finding (also Observation archetype) is NOT synthesized → no boost.
683        let user_finding = fact_with("observation", "src/auth/session.rs:1", "session-7");
684        // A raw evidence fact → no boost.
685        let raw = fact_with("gotcha", "src/auth/session.rs:2", "session-7");
686
687        assert!((apply_observation_tier(0.5, &raw) - 0.5).abs() < 1e-6);
688        assert!((apply_observation_tier(0.5, &user_finding) - 0.5).abs() < 1e-6);
689        assert!((apply_observation_tier(0.5, &obs) - (0.5 + OBSERVATION_TIER_BOOST)).abs() < 1e-6);
690        // The boost lifts a synthesized summary above an equal-base raw fact, yet is
691        // bounded so a boosted summary stays below an exact match (which scores 1.0).
692        assert!(apply_observation_tier(0.5, &obs) > apply_observation_tier(0.5, &raw));
693        assert!(apply_observation_tier(0.8, &obs) < 1.0);
694    }
695
696    #[test]
697    fn reset_removes_index_file() {
698        let _lock = crate::core::data_dir::test_env_lock();
699        let tmp = tempfile::tempdir().expect("tempdir");
700        crate::test_env::set_var(
701            "LEAN_CTX_DATA_DIR",
702            tmp.path().to_string_lossy().to_string(),
703        );
704
705        let idx = KnowledgeEmbeddingIndex {
706            project_hash: "projhash".to_string(),
707            entries: vec![FactEmbedding {
708                category: "arch".to_string(),
709                key: "db".to_string(),
710                embedding: vec![1.0, 0.0, 0.0],
711                quant: None,
712            }],
713        };
714        idx.save().expect("save");
715        assert!(KnowledgeEmbeddingIndex::load("projhash").is_some());
716
717        reset("projhash").expect("reset");
718        assert!(KnowledgeEmbeddingIndex::load("projhash").is_none());
719
720        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
721    }
722
723    #[test]
724    fn concurrent_remember_keeps_all_embeddings() {
725        // #412: the embedding-index read-modify-write must be serialized under
726        // the per-project lock and compacted against fresh on-disk knowledge.
727        // The old lock-free + stale-snapshot path let parallel writers clobber
728        // each other's vectors and prune just-stored ones. This mirrors
729        // `handle_remember`'s locked path (raw vectors, so no embedding engine
730        // is needed) and asserts every concurrently-stored embedding survives.
731        let _lock = crate::core::data_dir::test_env_lock();
732        let tmp = tempfile::tempdir().expect("tempdir");
733        crate::test_env::set_var(
734            "LEAN_CTX_DATA_DIR",
735            tmp.path().to_string_lossy().to_string(),
736        );
737
738        let project = tmp.path().join("proj");
739        std::fs::create_dir_all(&project).expect("mkdir");
740        let project_root = project.to_string_lossy().to_string();
741
742        const N: usize = 16;
743        let mut handles = Vec::with_capacity(N);
744        for i in 0..N {
745            let root = project_root.clone();
746            handles.push(std::thread::spawn(move || {
747                let policy = MemoryPolicy::default();
748                let cat = "arch";
749                let key = format!("k{i}");
750                // 1) Commit the fact under the lock (as handle_remember does).
751                let (knowledge, ()) = ProjectKnowledge::mutate_locked(&root, |kn| {
752                    kn.remember(cat, &key, "v", "s", 0.9, &policy);
753                })
754                .expect("commit fact");
755                // 2) Embedding side-car under the SAME lock + fresh-knowledge
756                //    compaction — exactly the fixed handle_remember path.
757                ProjectKnowledge::with_project_lock(&root, || {
758                    let mut idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash)
759                        .unwrap_or_else(|| KnowledgeEmbeddingIndex::new(&knowledge.project_hash));
760                    idx.upsert(cat, &key, &[1.0, 0.0, 0.0]);
761                    let fresh = ProjectKnowledge::load(&root);
762                    let kref = fresh.as_ref().unwrap_or(&knowledge);
763                    compact_against_knowledge(&mut idx, kref, &policy);
764                    idx.save().expect("save index");
765                });
766            }));
767        }
768        for h in handles {
769            h.join().expect("thread join");
770        }
771
772        let knowledge = ProjectKnowledge::load(&project_root).expect("knowledge persisted");
773        let current = knowledge.facts.iter().filter(|f| f.is_current()).count();
774        assert_eq!(current, N, "all {N} facts must be committed");
775
776        let idx = KnowledgeEmbeddingIndex::load(&knowledge.project_hash).expect("index persisted");
777        assert_eq!(
778            idx.entries.len(),
779            N,
780            "every concurrently-stored embedding must survive (got {})",
781            idx.entries.len()
782        );
783
784        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
785    }
786
787    #[test]
788    fn compact_drops_missing_or_archived_facts() {
789        let mut knowledge = ProjectKnowledge::new("/tmp/project");
790        let now = chrono::Utc::now();
791        knowledge.facts.push(KnowledgeFact {
792            category: "arch".to_string(),
793            key: "db".to_string(),
794            value: "Postgres".to_string(),
795            source_session: "s".to_string(),
796            confidence: 0.9,
797            created_at: now,
798            last_confirmed: now,
799            retrieval_count: 5,
800            last_retrieved: None,
801            valid_from: None,
802            valid_until: None,
803            supersedes: None,
804            confirmation_count: 1,
805            feedback_up: 0,
806            feedback_down: 0,
807            last_feedback: None,
808            privacy: crate::core::memory_boundary::FactPrivacy::default(),
809            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
810            imported_from: None,
811            archetype: KnowledgeArchetype::default(),
812            fidelity: None,
813            revision_count: 0,
814        });
815        knowledge.facts.push(KnowledgeFact {
816            category: "arch".to_string(),
817            key: "old".to_string(),
818            value: "Old".to_string(),
819            source_session: "s".to_string(),
820            confidence: 0.9,
821            created_at: now,
822            last_confirmed: now,
823            retrieval_count: 0,
824            last_retrieved: None,
825            valid_from: None,
826            valid_until: Some(now),
827            supersedes: None,
828            confirmation_count: 1,
829            feedback_up: 0,
830            feedback_down: 0,
831            last_feedback: None,
832            privacy: crate::core::memory_boundary::FactPrivacy::default(),
833            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
834            imported_from: None,
835            archetype: KnowledgeArchetype::default(),
836            fidelity: None,
837            revision_count: 0,
838        });
839
840        let mut idx = KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
841        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
842        idx.upsert("arch", "old", &[0.0, 1.0, 0.0]);
843        idx.upsert("ops", "deploy", &[0.0, 0.0, 1.0]);
844
845        compact_against_knowledge(&mut idx, &knowledge, &MemoryPolicy::default());
846        assert_eq!(idx.entries.len(), 1);
847        assert_eq!(idx.entries[0].category, "arch");
848        assert_eq!(idx.entries[0].key, "db");
849    }
850
851    #[test]
852    fn missing_current_facts_selects_unindexed_by_confidence_capped() {
853        let mut knowledge = ProjectKnowledge::new("/tmp/project");
854
855        let indexed = fact_with("arch", "indexed", "s");
856        let mut high = fact_with("arch", "high", "s");
857        high.confidence = 0.95;
858        let mut low = fact_with("arch", "low", "s");
859        low.confidence = 0.5;
860        let mut archived = fact_with("arch", "archived", "s");
861        archived.valid_until = Some(chrono::Utc::now());
862        knowledge.facts.extend([indexed, high, low, archived]);
863
864        let mut idx = KnowledgeEmbeddingIndex::new("test");
865        idx.upsert("arch", "indexed", &[1.0, 0.0, 0.0]);
866
867        // Already-indexed and archived facts are excluded; rest ordered by
868        // confidence (the reindex ordering).
869        let missing = missing_current_facts(&idx, &knowledge, 10);
870        let keys: Vec<&str> = missing.iter().map(|f| f.key.as_str()).collect();
871        assert_eq!(keys, vec!["high", "low"]);
872
873        // Cap bounds the batch (most valuable first).
874        let capped = missing_current_facts(&idx, &knowledge, 1);
875        assert_eq!(capped.len(), 1);
876        assert_eq!(capped[0].key, "high");
877
878        // Full coverage → nothing to backfill.
879        idx.upsert("arch", "high", &[0.0, 1.0, 0.0]);
880        idx.upsert("arch", "low", &[0.0, 0.0, 1.0]);
881        assert!(missing_current_facts(&idx, &knowledge, 10).is_empty());
882    }
883
884    #[test]
885    fn index_upsert_and_remove() {
886        let mut idx = KnowledgeEmbeddingIndex::new("test");
887        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
888        assert_eq!(idx.entries.len(), 1);
889
890        idx.upsert("arch", "db", &[0.0, 1.0, 0.0]);
891        assert_eq!(idx.entries.len(), 1);
892        // Stored quantized now: the dominant axis reconstructs to ~1.0.
893        let recon = idx.entries[0]
894            .quant
895            .as_ref()
896            .expect("quantized")
897            .dequantize();
898        assert!((recon[1] - 1.0).abs() < 1e-6);
899
900        idx.upsert("arch", "cache", &[0.0, 0.0, 1.0]);
901        assert_eq!(idx.entries.len(), 2);
902
903        idx.remove("arch", "db");
904        assert_eq!(idx.entries.len(), 1);
905        assert_eq!(idx.entries[0].key, "cache");
906    }
907
908    #[test]
909    fn recency_decay_recent() {
910        let fact = KnowledgeFact {
911            category: "test".to_string(),
912            key: "k".to_string(),
913            value: "v".to_string(),
914            source_session: "s".to_string(),
915            confidence: 0.9,
916            created_at: chrono::Utc::now(),
917            last_confirmed: chrono::Utc::now(),
918            retrieval_count: 0,
919            last_retrieved: None,
920            valid_from: None,
921            valid_until: None,
922            supersedes: None,
923            confirmation_count: 1,
924            feedback_up: 0,
925            feedback_down: 0,
926            last_feedback: None,
927            privacy: crate::core::memory_boundary::FactPrivacy::default(),
928            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
929            imported_from: None,
930            archetype: KnowledgeArchetype::default(),
931            fidelity: None,
932            revision_count: 0,
933        };
934        let decay = recency_decay(&fact);
935        assert!(
936            decay > 0.95,
937            "Recent fact should have high recency: {decay}"
938        );
939    }
940
941    #[test]
942    fn recency_decay_old() {
943        let old_date = chrono::Utc::now() - chrono::Duration::days(100);
944        let fact = KnowledgeFact {
945            category: "test".to_string(),
946            key: "k".to_string(),
947            value: "v".to_string(),
948            source_session: "s".to_string(),
949            confidence: 0.5,
950            created_at: old_date,
951            last_confirmed: old_date,
952            retrieval_count: 0,
953            last_retrieved: None,
954            valid_from: None,
955            valid_until: None,
956            supersedes: None,
957            confirmation_count: 1,
958            feedback_up: 0,
959            feedback_down: 0,
960            last_feedback: None,
961            privacy: crate::core::memory_boundary::FactPrivacy::default(),
962            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
963            imported_from: None,
964            archetype: KnowledgeArchetype::default(),
965            fidelity: None,
966            revision_count: 0,
967        };
968        let decay = recency_decay(&fact);
969        assert_eq!(decay, 0.0, "100-day-old fact should have 0 recency");
970    }
971
972    #[cfg(feature = "embeddings")]
973    #[test]
974    fn semantic_search_ranking() {
975        let mut idx = KnowledgeEmbeddingIndex::new("test");
976        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
977        idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
978        idx.upsert("ops", "deploy", &[0.5, 0.5, 0.0]);
979
980        let query = vec![1.0, 0.0, 0.0];
981        let results = idx.semantic_search(&query, 2);
982        assert_eq!(results.len(), 2);
983        assert_eq!(results[0].0.key, "db");
984    }
985
986    #[test]
987    fn format_scored_empty() {
988        assert_eq!(format_scored_facts(&[]), "No matching facts found.");
989    }
990
991    #[test]
992    fn format_scored_output() {
993        let fact = KnowledgeFact {
994            category: "arch".to_string(),
995            key: "db".to_string(),
996            value: "PostgreSQL".to_string(),
997            source_session: "s1".to_string(),
998            confidence: 0.95,
999            created_at: chrono::Utc::now(),
1000            last_confirmed: chrono::Utc::now(),
1001            retrieval_count: 0,
1002            last_retrieved: None,
1003            valid_from: None,
1004            valid_until: None,
1005            supersedes: None,
1006            confirmation_count: 3,
1007            feedback_up: 0,
1008            feedback_down: 0,
1009            last_feedback: None,
1010            privacy: crate::core::memory_boundary::FactPrivacy::default(),
1011            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
1012            imported_from: None,
1013            archetype: KnowledgeArchetype::default(),
1014            fidelity: None,
1015            revision_count: 0,
1016        };
1017        let scored = vec![ScoredFact {
1018            fact: &fact,
1019            score: 0.85,
1020            semantic_score: 0.9,
1021            confidence_score: 0.95,
1022            recency_score: 1.0,
1023        }];
1024        let output = format_scored_facts(&scored);
1025        assert!(output.contains("arch:db=PostgreSQL"));
1026        assert!(output.contains("★★★★"));
1027        assert!(output.contains("[s:85%]"));
1028    }
1029
1030    #[test]
1031    fn semantic_dup_flags_high_cosine_other_key() {
1032        let policy = MemoryPolicy::default();
1033        let mut kn = ProjectKnowledge::new("/tmp/semdup-1");
1034        kn.remember(
1035            "arch",
1036            "db",
1037            "PostgreSQL is the primary database",
1038            "s",
1039            0.9,
1040            &policy,
1041        );
1042        kn.remember(
1043            "arch",
1044            "cache",
1045            "Redis is the cache layer",
1046            "s",
1047            0.9,
1048            &policy,
1049        );
1050
1051        let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1052        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1053        idx.upsert("arch", "cache", &[0.0, 1.0, 0.0]);
1054
1055        // Query near-identical to the "db" entry, remembering a *different* key.
1056        let query = [1.0, 0.0, 0.0];
1057        let dups = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1058        assert_eq!(
1059            dups.len(),
1060            1,
1061            "only the near-identical entry clears threshold"
1062        );
1063        assert_eq!(dups[0].key, "db");
1064        assert!(dups[0].similarity >= 0.86);
1065    }
1066
1067    #[test]
1068    fn semantic_dup_excludes_self_and_judged() {
1069        let policy = MemoryPolicy::default();
1070        let mut kn = ProjectKnowledge::new("/tmp/semdup-2");
1071        kn.remember(
1072            "arch",
1073            "db",
1074            "PostgreSQL primary database",
1075            "s",
1076            0.9,
1077            &policy,
1078        );
1079
1080        let mut idx = KnowledgeEmbeddingIndex::new(&kn.project_hash);
1081        idx.upsert("arch", "db", &[1.0, 0.0, 0.0]);
1082        let query = [1.0, 0.0, 0.0];
1083
1084        // Self: remembering the same key must never flag itself.
1085        let self_hits = semantic_duplicates_from_query(&idx, &kn, "arch", "db", &query, 0.86, 3);
1086        assert!(self_hits.is_empty(), "a fact is never its own duplicate");
1087
1088        // A different key matches — until the pair has been judged.
1089        let other = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1090        assert_eq!(other.len(), 1);
1091
1092        kn.judged_pairs.push(crate::core::knowledge::JudgedPair {
1093            key_a: "arch/database".to_string(),
1094            key_b: "arch/db".to_string(),
1095            verdict: "unrelated".to_string(),
1096            judged_at: chrono::Utc::now(),
1097        });
1098        let judged = semantic_duplicates_from_query(&idx, &kn, "arch", "database", &query, 0.86, 3);
1099        assert!(judged.is_empty(), "already-judged pairs are not re-flagged");
1100    }
1101}