Skip to main content

lean_ctx/core/
semantic_cache.rs

1//! Per-project similarity index over the files a session read.
2//!
3//! An entry is derived from ONE file: its path and the term frequencies of
4//! its text. It is a derived store like any index (G04, E3):
5//!
6//! - at the write the text passes storage admission (`StoreAdmission`); text
7//!   that is withheld or restricted leaves no entry, and the index records
8//!   the digest of the policy its entries were admitted under;
9//! - at the use ([`SemanticCacheIndex::find_similar`]) an index its store
10//!   loaded names a file only while the admitted rooted reader returns that
11//!   file NOW and its text still has the terms the entry was built from. A
12//!   file that is gone, outside the project, withheld, restricted or changed
13//!   is not named; its entry is refreshed when the file is read again.
14//!
15//! The reads of one `find_similar` call are bounded and not remembered
16//! beyond it.
17
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23use crate::core::context_admission::stores::StoreAdmission;
24
25/// Tool identity the source of an entry is re-read for.
26const SOURCE_TOOL: &str = "ctx_read";
27/// Sources one `find_similar` call re-reads. Over it nothing more is named
28/// (fail closed): fewer hints, never an unchecked one.
29const MAX_SOURCE_READS: usize = 16;
30
31/// Recompute global IDF after this many mutations; searches refresh IDF when `idf_dirty`.
32const IDF_REBUILD_BATCH: u32 = 100;
33/// Hard cap on cached entries. Matches the search cap so the index stays bounded and
34/// never grows past what `find_similar` will actually search (previously the structure
35/// grew one entry per distinct file ever read, with no eviction).
36const MAX_SEMANTIC_ENTRIES: usize = 200;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SemanticCacheEntry {
40    pub path: String,
41    pub tfidf_vector: Vec<(String, f64)>,
42    pub token_count: usize,
43    pub access_count: u32,
44    pub last_session: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct SemanticCacheIndex {
49    pub entries: Vec<SemanticCacheEntry>,
50    pub idf: HashMap<String, f64>,
51    pub total_docs: usize,
52    /// Documents containing each term (unique terms per entry).
53    #[serde(default)]
54    pub term_document_freq: HashMap<String, usize>,
55    #[serde(default)]
56    idf_dirty: bool,
57    #[serde(default)]
58    mutations_since_idf_rebuild: u32,
59    /// Hex digest of the gateway policy every entry was admitted under. An
60    /// index of another policy — or of none, written before admission
61    /// existed — is not handed out ([`Self::load`]).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    admission_policy: Option<String>,
64    /// Set for an index its store loaded for a project: its entries are asked
65    /// again before they are named. Resident only.
66    #[serde(skip)]
67    source_root: Option<PathBuf>,
68}
69
70/// The current gateway policy as a snapshot that is not observed: a caller
71/// may hold a cache lock, and observing a change drops resident stores.
72fn store_admission() -> StoreAdmission {
73    StoreAdmission::with_policy(
74        crate::core::context_admission::AdmissionPolicy::from_config(
75            &crate::core::config::Config::load_arc(),
76        ),
77    )
78}
79
80impl SemanticCacheIndex {
81    /// Index `content` of `path`. Only text storage admission keeps enters:
82    /// a withheld or restricted source leaves no entry (an earlier one of the
83    /// same path is dropped), and entries admitted under another policy are
84    /// dropped before the first write under this one.
85    pub fn add_file(&mut self, path: &str, content: &str, session_id: &str) {
86        let admission = store_admission();
87        if self.admission_policy.as_deref() != Some(admission.digest()) {
88            self.entries.clear();
89            self.term_document_freq.clear();
90            self.idf.clear();
91            self.admission_policy = Some(admission.digest().to_owned());
92        }
93        let Some(admitted) = admission.admit(content, Path::new(path), None) else {
94            if let Some(position) = self.entries.iter().position(|e| e.path == path) {
95                let dropped = self.entries.swap_remove(position);
96                remove_doc_terms(&mut self.term_document_freq, &dropped.tfidf_vector);
97                self.total_docs = self.entries.len();
98                self.note_idf_mutation();
99            }
100            return;
101        };
102        let content = admitted.as_str();
103        let tf = compute_tf(content);
104        let token_count = content.split_whitespace().count();
105
106        if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
107            remove_doc_terms(&mut self.term_document_freq, &existing.tfidf_vector);
108            existing.tfidf_vector = tf.iter().map(|(k, v)| (k.clone(), *v)).collect();
109            existing.token_count = token_count;
110            existing.access_count += 1;
111            existing.last_session = session_id.to_string();
112            add_doc_terms(&mut self.term_document_freq, &existing.tfidf_vector);
113        } else {
114            if self.entries.len() >= MAX_SEMANTIC_ENTRIES {
115                // Evict the lowest-access_count entry to bound the index. Decrement its
116                // terms from the DF map via the existing helper so IDF stays correct.
117                if let Some(victim_idx) = self
118                    .entries
119                    .iter()
120                    .enumerate()
121                    .min_by_key(|(_, e)| e.access_count)
122                    .map(|(i, _)| i)
123                {
124                    let victim = self.entries.swap_remove(victim_idx);
125                    remove_doc_terms(&mut self.term_document_freq, &victim.tfidf_vector);
126                }
127            }
128            let tf_vec: Vec<(String, f64)> = tf.iter().map(|(k, v)| (k.clone(), *v)).collect();
129            add_doc_terms(&mut self.term_document_freq, &tf_vec);
130            self.entries.push(SemanticCacheEntry {
131                path: path.to_string(),
132                tfidf_vector: tf_vec,
133                token_count,
134                access_count: 1,
135                last_session: session_id.to_string(),
136            });
137        }
138
139        self.total_docs = self.entries.len();
140        self.note_idf_mutation();
141    }
142
143    fn note_idf_mutation(&mut self) {
144        self.idf_dirty = true;
145        self.mutations_since_idf_rebuild = self.mutations_since_idf_rebuild.saturating_add(1);
146        if self.mutations_since_idf_rebuild >= IDF_REBUILD_BATCH {
147            self.recompute_idf_from_df();
148            self.idf_dirty = false;
149            self.mutations_since_idf_rebuild = 0;
150        }
151    }
152
153    fn recompute_idf_from_df(&mut self) {
154        self.idf.clear();
155        let n = self.total_docs as f64;
156        if n <= 0.0 {
157            return;
158        }
159        for (term, count) in &self.term_document_freq {
160            let idf = (n / (*count as f64 + 1.0)).ln() + 1.0;
161            self.idf.insert(term.clone(), idf);
162        }
163    }
164
165    fn rebuild_df_from_entries(&mut self) {
166        self.term_document_freq.clear();
167        for entry in &self.entries {
168            add_doc_terms(&mut self.term_document_freq, &entry.tfidf_vector);
169        }
170    }
171
172    fn repair_after_deserialize(&mut self) {
173        self.total_docs = self.entries.len();
174        if self.term_document_freq.is_empty() && !self.entries.is_empty() {
175            self.rebuild_df_from_entries();
176            self.idf_dirty = true;
177        }
178    }
179
180    fn ensure_idf_for_search(&mut self) {
181        if self.idf_dirty {
182            self.recompute_idf_from_df();
183            self.idf_dirty = false;
184            self.mutations_since_idf_rebuild = 0;
185        }
186    }
187
188    pub fn find_similar(&mut self, content: &str, threshold: f64) -> Vec<(String, f64)> {
189        if self.entries.len() > MAX_SEMANTIC_ENTRIES {
190            return Vec::new();
191        }
192
193        self.ensure_idf_for_search();
194
195        let query_tf = compute_tf(content);
196        let query_vec = self.tfidf_vector(&query_tf);
197
198        let mut results: Vec<(String, f64)> = self
199            .entries
200            .iter()
201            .filter_map(|entry| {
202                let entry_vec = self.tfidf_vector_from_stored(&entry.tfidf_vector);
203                let sim = cosine_similarity(&query_vec, &entry_vec);
204                if sim >= threshold {
205                    Some((entry.path.clone(), sim))
206                } else {
207                    None
208                }
209            })
210            .collect();
211
212        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
213        let Some(root) = self.source_root.as_deref() else {
214            // A view built in the running call, never handed out by the store.
215            return results;
216        };
217        // The gate of this call: best match first, each source read once.
218        let mut reads = 0usize;
219        let mut remaining = crate::core::limits::max_read_bytes();
220        let before = results.len();
221        results.retain(|(path, _)| {
222            if reads >= MAX_SOURCE_READS {
223                return false;
224            }
225            reads += 1;
226            self.entries
227                .iter()
228                .find(|entry| &entry.path == path)
229                .is_some_and(|entry| still_derived(root, entry, &mut remaining))
230        });
231        if results.len() < before {
232            tracing::debug!(
233                "[semantic-cache] {} entr(y/ies) withheld: source no longer admitted",
234                before - results.len()
235            );
236        }
237        results
238    }
239
240    /// Paths ranked by access. Test-only: it has no product caller, and a
241    /// product use would have to gate its paths like [`Self::find_similar`].
242    #[cfg(test)]
243    pub fn suggest_warmup(&self, top_n: usize) -> Vec<String> {
244        let mut ranked: Vec<(&SemanticCacheEntry, f64)> = self
245            .entries
246            .iter()
247            .map(|e| {
248                let score = e.access_count as f64 * 0.6 + e.token_count as f64 * 0.0001;
249                (e, score)
250            })
251            .collect();
252
253        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
254
255        ranked
256            .into_iter()
257            .take(top_n)
258            .map(|(e, _)| e.path.clone())
259            .collect()
260    }
261
262    fn tfidf_vector(&self, tf: &HashMap<String, f64>) -> HashMap<String, f64> {
263        tf.iter()
264            .map(|(term, freq)| {
265                let idf = self.idf.get(term).copied().unwrap_or(1.0);
266                (term.clone(), freq * idf)
267            })
268            .collect()
269    }
270
271    fn tfidf_vector_from_stored(&self, stored: &[(String, f64)]) -> HashMap<String, f64> {
272        stored
273            .iter()
274            .map(|(term, freq)| {
275                let idf = self.idf.get(term).copied().unwrap_or(1.0);
276                (term.clone(), freq * idf)
277            })
278            .collect()
279    }
280
281    pub fn save(&self, project_root: &str) -> Result<(), String> {
282        let path = index_path(project_root);
283        if let Some(dir) = path.parent() {
284            std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
285        }
286        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
287        std::fs::write(&path, json).map_err(|e| e.to_string())
288    }
289
290    pub fn load(project_root: &str) -> Option<Self> {
291        let path = index_path(project_root);
292        let content = std::fs::read_to_string(&path)
293            .or_else(|_| {
294                let legacy = legacy_index_path(project_root);
295                if legacy == path {
296                    return Err(std::io::Error::new(
297                        std::io::ErrorKind::NotFound,
298                        "same path",
299                    ));
300                }
301                let data = std::fs::read_to_string(&legacy)?;
302                let _ = std::fs::copy(&legacy, &path);
303                Ok(data)
304            })
305            .ok()?;
306        let mut index: SemanticCacheIndex = serde_json::from_str(&content).ok()?;
307        // Only an index of the current policy is handed out. One written
308        // under another policy, or before admission existed (no digest), is
309        // left behind: its entries come back through admission as their
310        // files are read again.
311        if index.admission_policy.as_deref() != Some(store_admission().digest()) {
312            return None;
313        }
314        index.repair_after_deserialize();
315        index.source_root = Some(PathBuf::from(project_root));
316        Some(index)
317    }
318
319    pub fn load_or_create(project_root: &str) -> Self {
320        Self::load(project_root).unwrap_or_else(|| Self {
321            source_root: Some(PathBuf::from(project_root)),
322            ..Self::default()
323        })
324    }
325}
326
327/// Whether `entry` is still what its source yields: the admitted rooted
328/// reader returns the file now (path jail, role, project policy, gateway,
329/// decided on the original bytes), the original may be kept in a derived
330/// store, and its text has exactly the terms the entry was built from.
331fn still_derived(root: &Path, entry: &SemanticCacheEntry, remaining: &mut usize) -> bool {
332    let path = root.join(&entry.path);
333    let (Some(path), Some(root)) = (path.to_str(), root.to_str()) else {
334        return false;
335    };
336    let read = crate::core::admitted_sources::read(path, root, SOURCE_TOOL, remaining);
337    read.storable().is_some_and(|current| {
338        let terms = compute_tf(current);
339        terms.len() == entry.tfidf_vector.len()
340            && entry
341                .tfidf_vector
342                .iter()
343                .all(|(term, frequency)| terms.get(term) == Some(frequency))
344    })
345}
346
347fn remove_doc_terms(df: &mut HashMap<String, usize>, tf_vec: &[(String, f64)]) {
348    let unique: HashSet<&str> = tf_vec.iter().map(|(k, _)| k.as_str()).collect();
349    for term in unique {
350        if let Some(c) = df.get_mut(term) {
351            *c = c.saturating_sub(1);
352            if *c == 0 {
353                df.remove(term);
354            }
355        }
356    }
357}
358
359fn add_doc_terms(df: &mut HashMap<String, usize>, tf_vec: &[(String, f64)]) {
360    let unique: HashSet<&str> = tf_vec.iter().map(|(k, _)| k.as_str()).collect();
361    for term in unique {
362        *df.entry(term.to_string()).or_default() += 1;
363    }
364}
365
366fn compute_tf(content: &str) -> HashMap<String, f64> {
367    let mut counts: HashMap<String, usize> = HashMap::new();
368    let mut total = 0usize;
369
370    for word in content.split(|c: char| !c.is_alphanumeric() && c != '_') {
371        let w = word.to_lowercase();
372        if w.len() >= 2 {
373            *counts.entry(w).or_default() += 1;
374            total += 1;
375        }
376    }
377
378    if total == 0 {
379        return HashMap::new();
380    }
381
382    counts
383        .into_iter()
384        .map(|(term, count)| (term, count as f64 / total as f64))
385        .collect()
386}
387
388fn cosine_similarity(a: &HashMap<String, f64>, b: &HashMap<String, f64>) -> f64 {
389    let mut dot = 0.0f64;
390    let mut norm_a = 0.0f64;
391    let mut norm_b = 0.0f64;
392
393    for (term, val) in a {
394        norm_a += val * val;
395        if let Some(bval) = b.get(term) {
396            dot += val * bval;
397        }
398    }
399    for val in b.values() {
400        norm_b += val * val;
401    }
402
403    let denom = norm_a.sqrt() * norm_b.sqrt();
404    if denom < 1e-10 {
405        return 0.0;
406    }
407    dot / denom
408}
409
410fn index_path(project_root: &str) -> PathBuf {
411    let hash = crate::core::project_hash::hash_project_root(project_root);
412    crate::core::paths::cache_dir()
413        .unwrap_or_default()
414        .join("semantic_cache")
415        .join(format!("{hash}.json"))
416}
417
418fn legacy_index_path(project_root: &str) -> PathBuf {
419    use md5::{Digest, Md5};
420    let mut hasher = Md5::new();
421    hasher.update(project_root.as_bytes());
422    let hash = crate::core::agent_identity::hex_encode(&hasher.finalize());
423    crate::core::paths::cache_dir()
424        .unwrap_or_default()
425        .join("semantic_cache")
426        .join(format!("{hash}.json"))
427}
428
429#[cfg(test)]
430pub mod tests {
431    use super::*;
432
433    #[test]
434    fn compute_tf_basic() {
435        let tf = compute_tf("fn handle_request request response handle");
436        assert!(tf.contains_key("handle"));
437        assert!(tf.contains_key("request"));
438        assert!(tf["handle"] > 0.0);
439    }
440
441    #[test]
442    fn cosine_identical() {
443        let mut a = HashMap::new();
444        a.insert("hello".to_string(), 1.0);
445        a.insert("world".to_string(), 0.5);
446        let sim = cosine_similarity(&a, &a);
447        assert!((sim - 1.0).abs() < 0.001);
448    }
449
450    #[test]
451    fn cosine_orthogonal() {
452        let mut a = HashMap::new();
453        a.insert("hello".to_string(), 1.0);
454        let mut b = HashMap::new();
455        b.insert("world".to_string(), 1.0);
456        let sim = cosine_similarity(&a, &b);
457        assert!(sim.abs() < 0.001);
458    }
459
460    #[test]
461    fn add_and_find_similar() {
462        let mut index = SemanticCacheIndex::default();
463        index.add_file(
464            "auth.rs",
465            "fn validate_token check jwt expiry auth login",
466            "s1",
467        );
468        index.add_file(
469            "db.rs",
470            "fn connect_database pool query insert delete",
471            "s1",
472        );
473
474        let results = index.find_similar("validate auth token jwt", 0.1);
475        assert!(!results.is_empty(), "`results` is empty");
476        assert_eq!(results[0].0, "auth.rs");
477    }
478
479    #[test]
480    fn warmup_suggestions() {
481        let mut index = SemanticCacheIndex::default();
482        index.add_file("hot.rs", "frequently accessed file", "s1");
483        index.entries[0].access_count = 50;
484        index.add_file("cold.rs", "rarely used", "s1");
485
486        let warmup = index.suggest_warmup(1);
487        assert_eq!(warmup.len(), 1);
488        assert_eq!(warmup[0], "hot.rs");
489    }
490
491    /// G04/E3: an index its store loaded names a similar file only while the
492    /// admitted reader still returns the text its entry was built from, and
493    /// restricted text never becomes an entry.
494    #[test]
495    fn a_loaded_index_names_a_file_only_while_its_source_admits_it() {
496        let _data = crate::core::data_dir::isolated_data_dir();
497        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
498        let project = tempfile::tempdir().expect("project");
499        let root = project.path().canonicalize().expect("canonical root");
500        let root_text = root.to_str().expect("utf-8 root");
501        let body = "fn settle_invoice reconciliation ledger posting balance\n";
502        let file = |name: &str| root.join(name).to_str().expect("utf-8 path").to_owned();
503        for name in ["billing.rs", "ledger.rs"] {
504            std::fs::write(root.join(name), body).expect("source");
505        }
506        let mut index = SemanticCacheIndex::load_or_create(root_text);
507        index.add_file(&file("billing.rs"), body, "s1");
508        index.add_file(&file("ledger.rs"), body, "s1");
509        index.add_file(
510            &file("plan.rs"),
511            &format!("*** TOP SECRET ***\n{body}"),
512            "s1",
513        );
514        assert!(
515            index
516                .entries
517                .iter()
518                .all(|entry| !entry.path.ends_with("plan.rs"))
519        );
520        index.save(root_text).expect("persisted");
521
522        let named = || -> Vec<String> {
523            SemanticCacheIndex::load(root_text)
524                .expect("index of the current policy")
525                .find_similar(body, 0.5)
526                .into_iter()
527                .map(|(path, _)| path)
528                .collect()
529        };
530        assert_eq!(named().len(), 2);
531
532        // Rewritten, then deleted: the entry no longer is what the source yields.
533        std::fs::write(root.join("billing.rs"), "fn unrelated() {}\n").expect("rewrite");
534        assert_eq!(named(), [file("ledger.rs")]);
535        std::fs::remove_file(root.join("ledger.rs")).expect("delete");
536        assert!(named().is_empty());
537    }
538}