Skip to main content

lean_ctx/core/
knowledge_embedding.rs

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