1use std::sync::{Arc, Mutex};
19
20use super::jobs::now_ms;
21use super::persist::{MlPersistence, MlPersistenceResult};
22use crate::json::{Map, Value as JsonValue};
23
24#[derive(Debug, Clone)]
26pub struct SemanticCacheEntry {
27 pub prompt: String,
28 pub response: String,
29 pub embedding: Vec<f32>,
30 pub expires_at_ms: u64,
32 pub last_hit_ms: u64,
34 pub inserted_at_ms: u64,
36}
37
38impl SemanticCacheEntry {
39 pub fn is_expired_at(&self, now_ms_val: u64) -> bool {
40 self.expires_at_ms != 0 && now_ms_val >= self.expires_at_ms
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct SemanticCacheConfig {
47 pub similarity_threshold: f32,
50 pub default_ttl_ms: u64,
54 pub max_entries: usize,
57 pub namespace: String,
60}
61
62impl Default for SemanticCacheConfig {
63 fn default() -> Self {
64 Self {
65 similarity_threshold: 0.95,
66 default_ttl_ms: 24 * 60 * 60 * 1000,
67 max_entries: 10_000,
68 namespace: "default".to_string(),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct SemanticCacheStats {
77 pub entries: usize,
78 pub hits: u64,
79 pub misses: u64,
80 pub expired_evictions: u64,
81 pub capacity_evictions: u64,
82}
83
84struct Inner {
85 entries: Vec<SemanticCacheEntry>,
86 stats: SemanticCacheStats,
87}
88
89#[derive(Clone)]
91pub struct SemanticCache {
92 inner: Arc<Mutex<Inner>>,
93 config: SemanticCacheConfig,
94 backend: Option<Arc<dyn MlPersistence>>,
95}
96
97impl std::fmt::Debug for SemanticCache {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.debug_struct("SemanticCache")
100 .field("namespace", &self.config.namespace)
101 .field("similarity_threshold", &self.config.similarity_threshold)
102 .field("max_entries", &self.config.max_entries)
103 .field("persistent", &self.backend.is_some())
104 .finish()
105 }
106}
107
108impl SemanticCache {
109 pub fn new(config: SemanticCacheConfig) -> Self {
111 Self {
112 inner: Arc::new(Mutex::new(Inner {
113 entries: Vec::new(),
114 stats: SemanticCacheStats::default(),
115 })),
116 config,
117 backend: None,
118 }
119 }
120
121 pub fn with_backend(config: SemanticCacheConfig, backend: Arc<dyn MlPersistence>) -> Self {
124 let cache = Self {
125 inner: Arc::new(Mutex::new(Inner {
126 entries: Vec::new(),
127 stats: SemanticCacheStats::default(),
128 })),
129 config,
130 backend: Some(backend),
131 };
132 let _ = cache.load_from_backend();
133 cache
134 }
135
136 fn backend_namespace(&self) -> String {
137 format!("cache:{}", self.config.namespace)
138 }
139
140 fn persist_entry(&self, key: &str, entry: &SemanticCacheEntry) {
141 if let Some(backend) = self.backend.as_ref() {
142 let _ = backend.put(&self.backend_namespace(), key, &encode_entry(entry));
143 }
144 }
145
146 fn forget_entry(&self, key: &str) {
147 if let Some(backend) = self.backend.as_ref() {
148 let _ = backend.delete(&self.backend_namespace(), key);
149 }
150 }
151
152 pub fn load_from_backend(&self) -> MlPersistenceResult<usize> {
155 let Some(backend) = self.backend.as_ref() else {
156 return Ok(0);
157 };
158 let rows = backend.list(&self.backend_namespace())?;
159 let mut loaded = 0usize;
160 let now = now_ms();
161 let mut guard = match self.inner.lock() {
162 Ok(g) => g,
163 Err(p) => p.into_inner(),
164 };
165 guard.entries.clear();
166 for (_, raw) in rows {
167 let Some(entry) = decode_entry(&raw) else {
168 continue;
169 };
170 if entry.is_expired_at(now) {
171 continue;
176 }
177 guard.entries.push(entry);
178 loaded += 1;
179 }
180 guard.stats.entries = guard.entries.len();
181 Ok(loaded)
182 }
183
184 pub fn lookup(&self, embedding: &[f32]) -> Option<String> {
187 if embedding.is_empty() {
188 return None;
189 }
190 let now = now_ms();
191 let mut guard = match self.inner.lock() {
192 Ok(g) => g,
193 Err(p) => p.into_inner(),
194 };
195 let before = guard.entries.len();
197 guard.entries.retain(|e| !e.is_expired_at(now));
198 let evicted = before - guard.entries.len();
199 guard.stats.expired_evictions += evicted as u64;
200
201 let mut best: Option<(usize, f32)> = None;
202 for (idx, entry) in guard.entries.iter().enumerate() {
203 let score = cosine_similarity(embedding, &entry.embedding);
204 if score >= self.config.similarity_threshold {
205 match best {
206 Some((_, best_score)) if best_score >= score => {}
207 _ => best = Some((idx, score)),
208 }
209 }
210 }
211 match best {
212 Some((idx, _)) => {
213 let entry = &mut guard.entries[idx];
214 entry.last_hit_ms = now;
215 let response = entry.response.clone();
216 let persisted = entry.clone();
217 guard.stats.hits += 1;
218 guard.stats.entries = guard.entries.len();
219 drop(guard);
220 let key = cache_key(&persisted);
223 self.persist_entry(&key, &persisted);
224 Some(response)
225 }
226 None => {
227 guard.stats.misses += 1;
228 guard.stats.entries = guard.entries.len();
229 None
230 }
231 }
232 }
233
234 pub fn insert(
237 &self,
238 prompt: impl Into<String>,
239 response: impl Into<String>,
240 embedding: Vec<f32>,
241 ttl_ms_override: Option<u64>,
242 ) {
243 if embedding.is_empty() {
244 return;
245 }
246 let now = now_ms();
247 let ttl = ttl_ms_override.unwrap_or(self.config.default_ttl_ms);
248 let expires_at_ms = if ttl == 0 { 0 } else { now.saturating_add(ttl) };
249 let entry = SemanticCacheEntry {
250 prompt: prompt.into(),
251 response: response.into(),
252 embedding,
253 expires_at_ms,
254 last_hit_ms: now,
255 inserted_at_ms: now,
256 };
257 let evicted_keys: Vec<String>;
258 let stored_key: String;
259 let persist_entry: SemanticCacheEntry;
260 {
261 let mut guard = match self.inner.lock() {
262 Ok(g) => g,
263 Err(p) => p.into_inner(),
264 };
265 let mut pruned: Vec<String> = Vec::new();
269 if self.config.max_entries > 0 {
270 while guard.entries.len() >= self.config.max_entries {
271 if let Some((oldest_idx, _)) = guard
272 .entries
273 .iter()
274 .enumerate()
275 .min_by_key(|(_, e)| e.inserted_at_ms)
276 {
277 let gone = guard.entries.swap_remove(oldest_idx);
280 guard.stats.capacity_evictions += 1;
281 pruned.push(cache_key(&gone));
282 } else {
283 break;
284 }
285 }
286 }
287 guard.entries.push(entry.clone());
288 guard.stats.entries = guard.entries.len();
289 evicted_keys = pruned;
290 stored_key = cache_key(&entry);
291 persist_entry = entry;
292 }
293 for k in &evicted_keys {
294 self.forget_entry(k);
295 }
296 self.persist_entry(&stored_key, &persist_entry);
297 }
298
299 pub fn evict_expired(&self) -> usize {
301 let now = now_ms();
302 let evicted_keys: Vec<String>;
303 let count;
304 {
305 let mut guard = match self.inner.lock() {
306 Ok(g) => g,
307 Err(p) => p.into_inner(),
308 };
309 let mut keep = Vec::with_capacity(guard.entries.len());
310 let mut dropped = Vec::new();
311 for entry in guard.entries.drain(..) {
312 if entry.is_expired_at(now) {
313 dropped.push(cache_key(&entry));
314 } else {
315 keep.push(entry);
316 }
317 }
318 count = dropped.len();
319 guard.entries = keep;
320 guard.stats.expired_evictions += count as u64;
321 guard.stats.entries = guard.entries.len();
322 evicted_keys = dropped;
323 }
324 for k in &evicted_keys {
325 self.forget_entry(k);
326 }
327 count
328 }
329
330 pub fn stats(&self) -> SemanticCacheStats {
332 let guard = match self.inner.lock() {
333 Ok(g) => g,
334 Err(p) => p.into_inner(),
335 };
336 SemanticCacheStats {
337 entries: guard.entries.len(),
338 ..guard.stats.clone()
339 }
340 }
341
342 pub fn config(&self) -> &SemanticCacheConfig {
343 &self.config
344 }
345}
346
347fn cache_key(entry: &SemanticCacheEntry) -> String {
351 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
356 const FNV_PRIME: u64 = 0x100000001b3;
357 let mut h = FNV_OFFSET;
358 for b in entry.prompt.as_bytes() {
359 h ^= *b as u64;
360 h = h.wrapping_mul(FNV_PRIME);
361 }
362 format!("{:020}-{:016x}", entry.inserted_at_ms, h)
363}
364
365fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
366 if a.len() != b.len() || a.is_empty() {
367 return 0.0;
368 }
369 let mut dot = 0.0f32;
370 let mut na = 0.0f32;
371 let mut nb = 0.0f32;
372 for i in 0..a.len() {
373 dot += a[i] * b[i];
374 na += a[i] * a[i];
375 nb += b[i] * b[i];
376 }
377 if na == 0.0 || nb == 0.0 {
378 return 0.0;
379 }
380 dot / (na.sqrt() * nb.sqrt())
381}
382
383fn encode_entry(entry: &SemanticCacheEntry) -> String {
386 let mut obj = Map::new();
387 obj.insert(
388 "prompt".to_string(),
389 JsonValue::String(entry.prompt.clone()),
390 );
391 obj.insert(
392 "response".to_string(),
393 JsonValue::String(entry.response.clone()),
394 );
395 obj.insert(
396 "embedding".to_string(),
397 JsonValue::Array(
398 entry
399 .embedding
400 .iter()
401 .map(|f| JsonValue::Number(*f as f64))
402 .collect(),
403 ),
404 );
405 obj.insert(
406 "expires_at".to_string(),
407 JsonValue::Number(entry.expires_at_ms as f64),
408 );
409 obj.insert(
410 "last_hit".to_string(),
411 JsonValue::Number(entry.last_hit_ms as f64),
412 );
413 obj.insert(
414 "inserted_at".to_string(),
415 JsonValue::Number(entry.inserted_at_ms as f64),
416 );
417 JsonValue::Object(obj).to_string_compact()
418}
419
420fn decode_entry(raw: &str) -> Option<SemanticCacheEntry> {
421 let parsed = crate::json::parse_json(raw).ok()?;
422 let value = JsonValue::from(parsed);
423 let obj = value.as_object()?;
424 let prompt = obj.get("prompt")?.as_str()?.to_string();
425 let response = obj.get("response")?.as_str()?.to_string();
426 let embedding = obj
427 .get("embedding")?
428 .as_array()?
429 .iter()
430 .filter_map(|v| v.as_f64().map(|f| f as f32))
431 .collect::<Vec<f32>>();
432 let expires_at_ms = obj.get("expires_at")?.as_i64()? as u64;
433 let last_hit_ms = obj.get("last_hit")?.as_i64()? as u64;
434 let inserted_at_ms = obj.get("inserted_at")?.as_i64()? as u64;
435 Some(SemanticCacheEntry {
436 prompt,
437 response,
438 embedding,
439 expires_at_ms,
440 last_hit_ms,
441 inserted_at_ms,
442 })
443}
444
445#[cfg(test)]
446mod tests {
447 use super::super::persist::InMemoryMlPersistence;
448 use super::*;
449
450 fn cfg(threshold: f32, max: usize, ttl: u64) -> SemanticCacheConfig {
451 SemanticCacheConfig {
452 similarity_threshold: threshold,
453 default_ttl_ms: ttl,
454 max_entries: max,
455 namespace: "t".to_string(),
456 }
457 }
458
459 #[test]
460 fn cosine_similarity_is_symmetric_and_bounded() {
461 let a = [1.0, 0.0, 0.0];
462 let b = [0.0, 1.0, 0.0];
463 let c = [1.0, 0.0, 0.0];
464 assert!((cosine_similarity(&a, &c) - 1.0).abs() < 1e-6);
465 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
466 assert!((cosine_similarity(&a, &b) - cosine_similarity(&b, &a)).abs() < 1e-6);
467 }
468
469 #[test]
470 fn cosine_zero_on_mismatched_dims_or_zero_vec() {
471 assert_eq!(cosine_similarity(&[1.0], &[1.0, 0.0]), 0.0);
472 assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
473 }
474
475 #[test]
476 fn miss_returns_none_and_increments_miss_counter() {
477 let c = SemanticCache::new(cfg(0.9, 100, 0));
478 assert!(c.lookup(&[1.0, 0.0]).is_none());
479 assert_eq!(c.stats().misses, 1);
480 assert_eq!(c.stats().hits, 0);
481 }
482
483 #[test]
484 fn inserted_entry_is_found_on_identical_vector() {
485 let c = SemanticCache::new(cfg(0.9, 100, 0));
486 c.insert("p", "hello world", vec![1.0, 0.0, 0.0], None);
487 let got = c.lookup(&[1.0, 0.0, 0.0]).unwrap();
488 assert_eq!(got, "hello world");
489 assert_eq!(c.stats().hits, 1);
490 }
491
492 #[test]
493 fn below_threshold_is_a_miss() {
494 let c = SemanticCache::new(cfg(0.99, 100, 0));
495 c.insert("p", "r", vec![1.0, 0.0, 0.0], None);
496 assert!(c.lookup(&[0.8, 0.6, 0.0]).is_none());
498 }
499
500 #[test]
501 fn expired_entries_are_skipped_and_evicted() {
502 let c = SemanticCache::new(cfg(0.9, 100, 1));
503 c.insert("p", "r", vec![1.0, 0.0], None);
504 std::thread::sleep(std::time::Duration::from_millis(5));
505 assert!(c.lookup(&[1.0, 0.0]).is_none());
506 let stats = c.stats();
507 assert_eq!(stats.entries, 0);
508 assert!(stats.expired_evictions >= 1);
509 }
510
511 #[test]
512 fn capacity_limit_evicts_oldest_inserted() {
513 let c = SemanticCache::new(cfg(0.9, 2, 0));
514 c.insert("first", "r1", vec![1.0, 0.0], None);
515 std::thread::sleep(std::time::Duration::from_millis(2));
516 c.insert("second", "r2", vec![0.0, 1.0], None);
517 std::thread::sleep(std::time::Duration::from_millis(2));
518 c.insert("third", "r3", vec![1.0, 1.0], None);
519 assert_eq!(c.stats().entries, 2);
520 assert!(c.stats().capacity_evictions >= 1);
521 assert!(c.lookup(&[1.0, 0.0]).is_none() || c.lookup(&[1.0, 0.0]) != Some("r1".to_string()));
523 }
524
525 #[test]
526 fn best_candidate_wins_when_multiple_match() {
527 let c = SemanticCache::new(cfg(0.5, 100, 0));
528 c.insert("lo", "LO", vec![0.7, 0.7, 0.1], None);
529 c.insert("hi", "HI", vec![1.0, 0.0, 0.0], None);
530 let got = c.lookup(&[1.0, 0.0, 0.0]).unwrap();
531 assert_eq!(got, "HI");
532 }
533
534 #[test]
535 fn backend_round_trips_entry() {
536 let backend: Arc<dyn MlPersistence> = Arc::new(InMemoryMlPersistence::new());
537 let c1 = SemanticCache::with_backend(cfg(0.9, 100, 0), Arc::clone(&backend));
538 c1.insert("prompt one", "response one", vec![1.0, 0.0], None);
539 let c2 = SemanticCache::with_backend(cfg(0.9, 100, 0), backend);
540 let got = c2.lookup(&[1.0, 0.0]).unwrap();
541 assert_eq!(got, "response one");
542 }
543
544 #[test]
545 fn encode_decode_entry_round_trips() {
546 let e = SemanticCacheEntry {
547 prompt: "why".to_string(),
548 response: "because".to_string(),
549 embedding: vec![0.1, 0.2, -0.3],
550 expires_at_ms: 100,
551 last_hit_ms: 50,
552 inserted_at_ms: 10,
553 };
554 let back = decode_entry(&encode_entry(&e)).unwrap();
555 assert_eq!(back.prompt, e.prompt);
556 assert_eq!(back.response, e.response);
557 assert_eq!(back.embedding.len(), e.embedding.len());
558 for (a, b) in back.embedding.iter().zip(e.embedding.iter()) {
559 assert!((a - b).abs() < 1e-6);
560 }
561 assert_eq!(back.expires_at_ms, e.expires_at_ms);
562 }
563
564 #[test]
565 fn stats_entries_reflect_live_set() {
566 let c = SemanticCache::new(cfg(0.9, 100, 0));
567 c.insert("a", "1", vec![1.0, 0.0], None);
568 c.insert("b", "2", vec![0.0, 1.0], None);
569 assert_eq!(c.stats().entries, 2);
570 }
571}