use crate::search::SearchMetrics;
use crate::types::SearchResult;
use std::collections::VecDeque;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheStamp {
pub updated_at: String,
pub schema_version: u32,
}
struct CacheEntry {
query: String,
embedding: Vec<f32>,
results: Vec<SearchResult>,
metrics: SearchMetrics,
}
pub struct SemanticCache {
entries: VecDeque<CacheEntry>,
capacity: usize,
stamp: Option<CacheStamp>,
}
impl SemanticCache {
pub fn new(capacity: usize) -> Self {
Self {
entries: VecDeque::with_capacity(capacity.min(64)),
capacity: capacity.max(1),
stamp: None,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn flush(&mut self) {
self.entries.clear();
}
fn enforce_stamp(&mut self, incoming: &CacheStamp) {
match &self.stamp {
Some(existing) if existing == incoming => {}
_ => {
self.flush();
self.stamp = Some(incoming.clone());
}
}
}
pub fn lookup(
&mut self,
query: &str,
query_vec: &[f32],
threshold: f32,
stamp: &CacheStamp,
) -> Option<(Vec<SearchResult>, SearchMetrics)> {
self.enforce_stamp(stamp);
if self.entries.is_empty() {
return None;
}
if let Some(idx) = self.entries.iter().position(|e| e.query == query) {
let entry = self.entries.remove(idx)?;
let out = (entry.results.clone(), entry.metrics.clone());
self.entries.push_front(entry);
return Some(out);
}
let mut best: Option<(usize, f32)> = None;
for (idx, e) in self.entries.iter().enumerate() {
let sim = crate::search::mmr::cosine(query_vec, &e.embedding);
if sim >= threshold && best.is_none_or(|(_, b)| sim > b) {
best = Some((idx, sim));
}
}
let (idx, _) = best?;
let entry = self.entries.remove(idx)?;
let out = (entry.results.clone(), entry.metrics.clone());
self.entries.push_front(entry);
Some(out)
}
pub fn store(
&mut self,
query: &str,
query_vec: &[f32],
results: Vec<SearchResult>,
metrics: SearchMetrics,
stamp: &CacheStamp,
) {
self.enforce_stamp(stamp);
if let Some(idx) = self.entries.iter().position(|e| e.query == query) {
self.entries.remove(idx);
}
self.entries.push_front(CacheEntry {
query: query.to_string(),
embedding: query_vec.to_vec(),
results,
metrics,
});
while self.entries.len() > self.capacity {
self.entries.pop_back();
}
}
}
pub fn read_stamp(index_dir: &Path) -> Option<CacheStamp> {
let meta_str = std::fs::read_to_string(index_dir.join("meta.json")).ok()?;
let meta: crate::types::IndexMeta = serde_json::from_str(&meta_str).ok()?;
Some(CacheStamp {
updated_at: meta.updated_at,
schema_version: meta.schema_version,
})
}
pub fn is_enabled() -> bool {
std::env::var("SEMANTEX_SEMANTIC_CACHE")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
pub fn threshold_from_env() -> f32 {
const DEFAULT: f32 = 0.85;
std::env::var("SEMANTEX_SEMANTIC_CACHE_THRESHOLD")
.ok()
.and_then(|v| v.trim().parse::<f32>().ok())
.filter(|x| x.is_finite() && (0.0..=1.0).contains(x))
.unwrap_or(DEFAULT)
}
pub fn capacity_from_env() -> usize {
crate::config::env_usize("SEMANTEX_SEMANTIC_CACHE_CAP", 1000)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Chunk, ChunkType, Confidence, SearchResult, SearchSource};
use std::path::PathBuf;
fn stamp(updated: &str) -> CacheStamp {
CacheStamp {
updated_at: updated.to_string(),
schema_version: 10,
}
}
fn metrics() -> SearchMetrics {
SearchMetrics {
total_ms: 1,
dense_ms: None,
sparse_ms: None,
exact_ms: None,
fusion_ms: None,
rerank_ms: None,
dense_count: 0,
sparse_count: 0,
exact_count: 0,
fused_count: 0,
result_count: 1,
query_type: "Semantic".to_string(),
response_bytes: None,
}
}
fn one_result(id: u64) -> Vec<SearchResult> {
vec![SearchResult {
chunk: Chunk {
id,
file_path: PathBuf::from("a.rs"),
content: "x".to_string(),
start_line: 1,
end_line: 2,
chunk_type: ChunkType::TextWindow { window_index: 0 },
},
score: 1.0,
source: SearchSource::Hybrid,
score_dense: 0.0,
score_sparse: 0.0,
score_exact: 0.0,
confidence: Confidence::Inferred,
confidence_score: 0.0,
}]
}
#[test]
fn exact_match_returns_stored_results() {
let mut cache = SemanticCache::new(10);
let st = stamp("100");
cache.store("auth flow", &[1.0, 0.0], one_result(7), metrics(), &st);
let hit = cache.lookup(
"auth flow",
&[0.0, 1.0],
0.85,
&st,
);
assert!(
hit.is_some(),
"identical query text must hit via exact-match fast path"
);
assert_eq!(hit.unwrap().0[0].chunk.id, 7);
}
#[test]
fn cosine_near_match_hits_above_threshold() {
let mut cache = SemanticCache::new(10);
let st = stamp("100");
cache.store(
"how is auth handled",
&[1.0, 0.0],
one_result(9),
metrics(),
&st,
);
let hit = cache.lookup("auth handling overview", &[0.999, 0.01], 0.85, &st);
assert!(
hit.is_some(),
"near-parallel embedding above threshold must hit"
);
assert_eq!(hit.unwrap().0[0].chunk.id, 9);
}
#[test]
fn cosine_below_threshold_misses() {
let mut cache = SemanticCache::new(10);
let st = stamp("100");
cache.store(
"how is auth handled",
&[1.0, 0.0],
one_result(9),
metrics(),
&st,
);
let hit = cache.lookup("database migrations", &[0.0, 1.0], 0.85, &st);
assert!(hit.is_none());
}
#[test]
fn lru_evicts_oldest_over_capacity() {
let mut cache = SemanticCache::new(2);
let st = stamp("100");
cache.store("q1", &[1.0, 0.0], one_result(1), metrics(), &st);
cache.store("q2", &[0.0, 1.0], one_result(2), metrics(), &st);
cache.store("q3", &[1.0, 1.0], one_result(3), metrics(), &st); assert_eq!(cache.len(), 2);
assert!(cache.lookup("q1", &[0.0, 0.0], 0.85, &st).is_none());
assert!(cache.lookup("q3", &[0.0, 0.0], 0.85, &st).is_some());
}
#[test]
fn stamp_change_flushes_cache() {
let mut cache = SemanticCache::new(10);
let st_old = stamp("100");
cache.store("auth flow", &[1.0, 0.0], one_result(7), metrics(), &st_old);
assert!(
cache
.lookup("auth flow", &[1.0, 0.0], 0.85, &st_old)
.is_some()
);
let st_new = stamp("200");
assert!(
cache
.lookup("auth flow", &[1.0, 0.0], 0.85, &st_new)
.is_none(),
"reindex (new updated_at) MUST invalidate the cache"
);
assert_eq!(cache.len(), 0, "stamp change flushes all entries");
}
#[test]
fn schema_version_change_flushes_cache() {
let mut cache = SemanticCache::new(10);
let st_v10 = CacheStamp {
updated_at: "100".into(),
schema_version: 10,
};
cache.store("q", &[1.0, 0.0], one_result(1), metrics(), &st_v10);
let st_v11 = CacheStamp {
updated_at: "100".into(),
schema_version: 11,
};
assert!(cache.lookup("q", &[1.0, 0.0], 0.85, &st_v11).is_none());
}
#[test]
fn read_stamp_from_meta_json() {
let tmp = tempfile::TempDir::new().unwrap();
let index_dir = tmp.path();
let meta = crate::types::IndexMeta {
schema_version: crate::types::IndexMeta::CURRENT_SCHEMA_VERSION,
project_path: index_dir.to_path_buf(),
created_at: "0".to_string(),
updated_at: "1717000000".to_string(),
file_count: 1,
chunk_count: 2,
embedding_model: "CodeRankEmbed".to_string(),
embedding_dim: 48,
use_bm25_stemmer: true,
dense_backend: "coderank-hnsw".to_string(), embedder_fingerprint: "test-fp".to_string(), };
std::fs::write(
index_dir.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let st = read_stamp(index_dir).expect("meta.json present → Some stamp");
assert_eq!(st.updated_at, "1717000000");
assert_eq!(
st.schema_version,
crate::types::IndexMeta::CURRENT_SCHEMA_VERSION
);
}
#[test]
fn read_stamp_missing_meta_is_none() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(read_stamp(tmp.path()).is_none());
}
#[test]
fn cache_disabled_by_default_enabled_by_env() {
unsafe {
std::env::remove_var("SEMANTEX_SEMANTIC_CACHE");
}
assert!(!is_enabled(), "semantic cache is OFF by default");
unsafe {
std::env::set_var("SEMANTEX_SEMANTIC_CACHE", "1");
}
assert!(is_enabled());
unsafe {
std::env::remove_var("SEMANTEX_SEMANTIC_CACHE");
}
}
#[test]
fn threshold_default_is_point85() {
unsafe {
std::env::remove_var("SEMANTEX_SEMANTIC_CACHE_THRESHOLD");
}
assert!((threshold_from_env() - 0.85).abs() < f32::EPSILON);
}
}