1use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23use crate::core::context_admission::stores::StoreAdmission;
24
25const SOURCE_TOOL: &str = "ctx_read";
27const MAX_SOURCE_READS: usize = 16;
30
31const IDF_REBUILD_BATCH: u32 = 100;
33const 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
63 admission_policy: Option<String>,
64 #[serde(skip)]
67 source_root: Option<PathBuf>,
68}
69
70fn 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 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)) 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 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 return results;
216 };
217 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 #[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 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
327fn 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 #[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 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}