1use std::collections::{HashMap, VecDeque};
16use std::sync::{Arc, Mutex};
17
18use asupersync::Cx;
19use frankensearch_core::traits::{Embedder, ModelCategory, ModelTier, SearchFuture};
20
21const DEFAULT_CAPACITY: usize = 128;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct CacheStats {
27 pub hits: u64,
29 pub misses: u64,
31 pub entries: usize,
33 pub capacity: usize,
35}
36
37struct CacheState {
38 map: HashMap<String, Vec<f32>>,
39 order: VecDeque<String>,
40 capacity: usize,
41 hits: u64,
42 misses: u64,
43}
44
45impl CacheState {
46 fn new(capacity: usize) -> Self {
47 Self {
48 map: HashMap::with_capacity(capacity),
49 order: VecDeque::with_capacity(capacity),
50 capacity,
51 hits: 0,
52 misses: 0,
53 }
54 }
55
56 fn get(&mut self, key: &str) -> Option<Vec<f32>> {
57 if let Some(vec) = self.map.get(key) {
58 self.hits += 1;
59 Some(vec.clone())
60 } else {
61 self.misses += 1;
62 None
63 }
64 }
65
66 fn insert(&mut self, key: String, value: Vec<f32>) {
67 if self.capacity == 0 || self.map.contains_key(&key) {
69 return;
70 }
71 if self.order.len() >= self.capacity
72 && let Some(evicted) = self.order.pop_front()
73 {
74 self.map.remove(&evicted);
75 }
76 self.order.push_back(key.clone());
77 self.map.insert(key, value);
78 }
79
80 fn stats(&self) -> CacheStats {
81 CacheStats {
82 hits: self.hits,
83 misses: self.misses,
84 entries: self.map.len(),
85 capacity: self.capacity,
86 }
87 }
88
89 fn clear(&mut self) {
90 self.map.clear();
91 self.order.clear();
92 self.hits = 0;
93 self.misses = 0;
94 }
95}
96
97pub struct CachedEmbedder {
111 inner: Arc<dyn Embedder>,
112 state: Mutex<CacheState>,
113}
114
115impl std::fmt::Debug for CachedEmbedder {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 let stats = self.cache_stats();
118 f.debug_struct("CachedEmbedder")
119 .field("inner_id", &self.inner.id())
120 .field("hits", &stats.hits)
121 .field("misses", &stats.misses)
122 .field("entries", &stats.entries)
123 .field("capacity", &stats.capacity)
124 .finish_non_exhaustive()
125 }
126}
127
128impl CachedEmbedder {
129 #[must_use]
134 pub fn new(inner: Arc<dyn Embedder>, capacity: usize) -> Self {
135 Self {
136 inner,
137 state: Mutex::new(CacheState::new(capacity)),
138 }
139 }
140
141 #[must_use]
143 pub fn with_default_capacity(inner: Arc<dyn Embedder>) -> Self {
144 Self::new(inner, DEFAULT_CAPACITY)
145 }
146
147 fn state_lock(&self) -> std::sync::MutexGuard<'_, CacheState> {
148 self.state
149 .lock()
150 .unwrap_or_else(std::sync::PoisonError::into_inner)
151 }
152
153 #[must_use]
155 pub fn cache_stats(&self) -> CacheStats {
156 self.state_lock().stats()
157 }
158
159 pub fn clear_cache(&self) {
161 self.state_lock().clear();
162 }
163
164 #[must_use]
166 pub fn inner(&self) -> &dyn Embedder {
167 &*self.inner
168 }
169}
170
171impl Embedder for CachedEmbedder {
172 fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
173 let cached = self.state_lock().get(text);
176 if let Some(vec) = cached {
177 return Box::pin(async move { Ok(vec) });
178 }
179
180 let key = text.to_owned();
181 Box::pin(async move {
182 let vec = self.inner.embed(cx, text).await?;
183 self.state_lock().insert(key, vec.clone());
185 Ok(vec)
186 })
187 }
188
189 fn embed_batch<'a>(
190 &'a self,
191 cx: &'a Cx,
192 texts: &'a [&'a str],
193 ) -> SearchFuture<'a, Vec<Vec<f32>>> {
194 Box::pin(async move {
195 let mut out = Vec::with_capacity(texts.len());
196 for text in texts {
197 out.push(self.embed(cx, text).await?);
198 }
199 Ok(out)
200 })
201 }
202
203 fn dimension(&self) -> usize {
204 self.inner.dimension()
205 }
206
207 fn id(&self) -> &str {
208 self.inner.id()
209 }
210
211 fn model_name(&self) -> &str {
212 self.inner.model_name()
213 }
214
215 fn is_ready(&self) -> bool {
216 self.inner.is_ready()
217 }
218
219 fn is_semantic(&self) -> bool {
220 self.inner.is_semantic()
221 }
222
223 fn category(&self) -> ModelCategory {
224 self.inner.category()
225 }
226
227 fn tier(&self) -> ModelTier {
228 self.inner.tier()
229 }
230
231 fn supports_mrl(&self) -> bool {
232 self.inner.supports_mrl()
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use frankensearch_core::traits::l2_normalize;
240 use std::sync::atomic::{AtomicUsize, Ordering};
241
242 struct CountingEmbedder {
244 dim: usize,
245 calls: AtomicUsize,
246 }
247
248 impl CountingEmbedder {
249 fn new(dim: usize) -> Self {
250 Self {
251 dim,
252 calls: AtomicUsize::new(0),
253 }
254 }
255
256 fn call_count(&self) -> usize {
257 self.calls.load(Ordering::Relaxed)
258 }
259 }
260
261 impl Embedder for CountingEmbedder {
262 fn embed<'a>(&'a self, _cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
263 self.calls.fetch_add(1, Ordering::Relaxed);
264 let mut vec = vec![0.0_f32; self.dim];
265 for (i, b) in text.bytes().enumerate() {
267 vec[i % self.dim] += f32::from(b);
268 }
269 let normalized = l2_normalize(&vec);
270 Box::pin(async move { Ok(normalized) })
271 }
272
273 fn dimension(&self) -> usize {
274 self.dim
275 }
276
277 fn id(&self) -> &'static str {
278 "counting-test"
279 }
280
281 fn model_name(&self) -> &'static str {
282 "Counting Test Embedder"
283 }
284
285 fn is_semantic(&self) -> bool {
286 false
287 }
288
289 fn category(&self) -> ModelCategory {
290 ModelCategory::HashEmbedder
291 }
292 }
293
294 fn make_cached(capacity: usize) -> (CachedEmbedder, Arc<CountingEmbedder>) {
295 let inner = Arc::new(CountingEmbedder::new(64));
296 let cached = CachedEmbedder::new(inner.clone(), capacity);
297 (cached, inner)
298 }
299
300 #[test]
301 fn cache_hit_avoids_inner_call() {
302 let (cached, inner) = make_cached(16);
303 asupersync::test_utils::run_test_with_cx(|cx| async move {
304 let v1 = cached.embed(&cx, "hello world").await.unwrap();
305 let v2 = cached.embed(&cx, "hello world").await.unwrap();
306 assert_eq!(v1, v2);
307 assert_eq!(inner.call_count(), 1);
308 });
309 }
310
311 #[test]
312 fn cache_miss_calls_inner() {
313 let (cached, inner) = make_cached(16);
314 asupersync::test_utils::run_test_with_cx(|cx| async move {
315 cached.embed(&cx, "query a").await.unwrap();
316 cached.embed(&cx, "query b").await.unwrap();
317 assert_eq!(inner.call_count(), 2);
318 });
319 }
320
321 #[test]
322 fn stats_track_hits_and_misses() {
323 let (cached, _inner) = make_cached(16);
324 asupersync::test_utils::run_test_with_cx(|cx| async move {
325 cached.embed(&cx, "alpha").await.unwrap();
326 cached.embed(&cx, "alpha").await.unwrap();
327 cached.embed(&cx, "beta").await.unwrap();
328 let stats = cached.cache_stats();
329 assert_eq!(stats.misses, 2);
330 assert_eq!(stats.hits, 1);
331 assert_eq!(stats.entries, 2);
332 });
333 }
334
335 #[test]
336 fn fifo_eviction_at_capacity() {
337 let (cached, inner) = make_cached(2);
338 asupersync::test_utils::run_test_with_cx(|cx| async move {
339 cached.embed(&cx, "first").await.unwrap();
340 cached.embed(&cx, "second").await.unwrap();
341 cached.embed(&cx, "third").await.unwrap();
343 assert_eq!(inner.call_count(), 3);
344
345 cached.embed(&cx, "first").await.unwrap();
347 assert_eq!(inner.call_count(), 4);
348
349 cached.embed(&cx, "third").await.unwrap();
352 assert_eq!(inner.call_count(), 4);
353 });
354 }
355
356 #[test]
357 fn clear_resets_stats_and_entries() {
358 let (cached, _inner) = make_cached(16);
359 asupersync::test_utils::run_test_with_cx(|cx| async move {
360 cached.embed(&cx, "test").await.unwrap();
361 assert_eq!(cached.cache_stats().entries, 1);
362
363 cached.clear_cache();
364 let stats = cached.cache_stats();
365 assert_eq!(stats.entries, 0);
366 assert_eq!(stats.hits, 0);
367 assert_eq!(stats.misses, 0);
368 });
369 }
370
371 #[test]
372 fn delegates_trait_methods_to_inner() {
373 let inner = Arc::new(CountingEmbedder::new(64));
374 let cached = CachedEmbedder::new(inner, 16);
375
376 assert_eq!(cached.dimension(), 64);
377 assert_eq!(cached.id(), "counting-test");
378 assert_eq!(cached.model_name(), "Counting Test Embedder");
379 assert!(!cached.is_semantic());
380 assert_eq!(cached.category(), ModelCategory::HashEmbedder);
381 }
382
383 #[test]
384 fn with_default_capacity_uses_128() {
385 let inner = Arc::new(CountingEmbedder::new(64));
386 let cached = CachedEmbedder::with_default_capacity(inner);
387 assert_eq!(cached.cache_stats().capacity, 128);
388 }
389
390 #[test]
391 fn debug_format_includes_stats() {
392 let inner = Arc::new(CountingEmbedder::new(64));
393 let cached = CachedEmbedder::new(inner, 16);
394 let dbg = format!("{cached:?}");
395 assert!(dbg.contains("CachedEmbedder"));
396 assert!(dbg.contains("counting-test"));
397 }
398
399 #[test]
400 fn embed_batch_uses_per_item_cache() {
401 let (cached, inner) = make_cached(16);
402 asupersync::test_utils::run_test_with_cx(|cx| async move {
403 cached.embed(&cx, "alpha").await.unwrap();
405 assert_eq!(inner.call_count(), 1);
406
407 let batch = cached.embed_batch(&cx, &["alpha", "beta"]).await.unwrap();
409 assert_eq!(batch.len(), 2);
410 assert_eq!(inner.call_count(), 2);
412 });
413 }
414
415 #[test]
416 fn duplicate_insert_is_idempotent() {
417 let (cached, inner) = make_cached(4);
418 asupersync::test_utils::run_test_with_cx(|cx| async move {
419 cached.embed(&cx, "same").await.unwrap();
420 assert_eq!(inner.call_count(), 1);
421 assert_eq!(cached.cache_stats().entries, 1);
422 cached.embed(&cx, "same").await.unwrap();
424 assert_eq!(inner.call_count(), 1);
425 assert_eq!(cached.cache_stats().entries, 1);
426 });
427 }
428
429 #[test]
432 fn cache_stats_debug_clone_copy_eq() {
433 let stats = CacheStats {
434 hits: 5,
435 misses: 3,
436 entries: 8,
437 capacity: 128,
438 };
439 let copied = stats; let cloned = { stats }; assert_eq!(stats, copied);
442 assert_eq!(stats, cloned);
443
444 let different = CacheStats {
445 hits: 0,
446 misses: 0,
447 entries: 0,
448 capacity: 128,
449 };
450 assert_ne!(stats, different);
451
452 let dbg = format!("{stats:?}");
453 assert!(dbg.contains("CacheStats"));
454 assert!(dbg.contains("hits: 5"));
455 }
456
457 #[test]
458 fn capacity_one_evicts_immediately() {
459 let (cached, inner) = make_cached(1);
460 asupersync::test_utils::run_test_with_cx(|cx| async move {
461 cached.embed(&cx, "first").await.unwrap();
462 assert_eq!(cached.cache_stats().entries, 1);
463
464 cached.embed(&cx, "second").await.unwrap();
466 assert_eq!(inner.call_count(), 2);
467 assert_eq!(cached.cache_stats().entries, 1);
468
469 cached.embed(&cx, "first").await.unwrap();
471 assert_eq!(inner.call_count(), 3);
472
473 cached.embed(&cx, "second").await.unwrap();
475 assert_eq!(inner.call_count(), 4);
476 });
477 }
478
479 #[test]
480 fn inner_accessor_returns_same_embedder() {
481 let inner = Arc::new(CountingEmbedder::new(64));
482 let cached = CachedEmbedder::new(inner, 16);
483 assert_eq!(cached.inner().id(), "counting-test");
484 assert_eq!(cached.inner().dimension(), 64);
485 assert_eq!(cached.inner().model_name(), "Counting Test Embedder");
486 }
487
488 #[test]
489 fn is_ready_delegates() {
490 let inner = Arc::new(CountingEmbedder::new(64));
491 let cached = CachedEmbedder::new(inner, 16);
492 assert!(cached.is_ready());
494 }
495
496 #[test]
497 fn tier_delegates() {
498 let inner = Arc::new(CountingEmbedder::new(64));
499 let cached = CachedEmbedder::new(inner, 16);
500 assert_eq!(cached.tier(), ModelTier::Fast);
502 }
503
504 #[test]
505 fn supports_mrl_delegates() {
506 let inner = Arc::new(CountingEmbedder::new(64));
507 let cached = CachedEmbedder::new(inner, 16);
508 assert!(!cached.supports_mrl());
510 }
511
512 #[test]
513 fn clear_then_reuse_resets_everything() {
514 let (cached, inner) = make_cached(16);
515 asupersync::test_utils::run_test_with_cx(|cx| async move {
516 cached.embed(&cx, "alpha").await.unwrap();
517 cached.embed(&cx, "alpha").await.unwrap(); assert_eq!(cached.cache_stats().hits, 1);
519 assert_eq!(cached.cache_stats().misses, 1);
520
521 cached.clear_cache();
522 assert_eq!(cached.cache_stats().hits, 0);
523 assert_eq!(cached.cache_stats().misses, 0);
524 assert_eq!(cached.cache_stats().entries, 0);
525
526 cached.embed(&cx, "alpha").await.unwrap();
528 assert_eq!(inner.call_count(), 2); assert_eq!(cached.cache_stats().misses, 1);
530 assert_eq!(cached.cache_stats().entries, 1);
531 });
532 }
533
534 #[test]
535 fn sequential_evictions_maintain_fifo_order() {
536 let (cached, inner) = make_cached(3);
537 asupersync::test_utils::run_test_with_cx(|cx| async move {
538 cached.embed(&cx, "a").await.unwrap();
540 cached.embed(&cx, "b").await.unwrap();
541 cached.embed(&cx, "c").await.unwrap();
542 assert_eq!(inner.call_count(), 3);
543 assert_eq!(cached.cache_stats().entries, 3);
544
545 cached.embed(&cx, "d").await.unwrap();
547 assert_eq!(inner.call_count(), 4);
548
549 cached.embed(&cx, "a").await.unwrap();
551 assert_eq!(inner.call_count(), 5); cached.embed(&cx, "b").await.unwrap();
553 assert_eq!(inner.call_count(), 6); });
560 }
561
562 #[test]
563 fn empty_string_embedding() {
564 let (cached, inner) = make_cached(16);
565 asupersync::test_utils::run_test_with_cx(|cx| async move {
566 let v1 = cached.embed(&cx, "").await.unwrap();
567 let v2 = cached.embed(&cx, "").await.unwrap();
568 assert_eq!(v1, v2);
569 assert_eq!(inner.call_count(), 1); });
571 }
572
573 #[test]
574 fn stats_entries_accurate_after_evictions() {
575 let (cached, _inner) = make_cached(2);
576 asupersync::test_utils::run_test_with_cx(|cx| async move {
577 cached.embed(&cx, "x").await.unwrap();
578 cached.embed(&cx, "y").await.unwrap();
579 assert_eq!(cached.cache_stats().entries, 2);
580
581 cached.embed(&cx, "z").await.unwrap();
583 assert_eq!(cached.cache_stats().entries, 2); cached.embed(&cx, "w").await.unwrap();
587 assert_eq!(cached.cache_stats().entries, 2);
588 });
589 }
590
591 #[test]
592 fn debug_format_after_operations() {
593 let (cached, _inner) = make_cached(16);
594 asupersync::test_utils::run_test_with_cx(|cx| async move {
595 cached.embed(&cx, "test").await.unwrap();
596 cached.embed(&cx, "test").await.unwrap(); let dbg = format!("{cached:?}");
598 assert!(dbg.contains("hits"));
599 assert!(dbg.contains("misses"));
600 assert!(dbg.contains("entries"));
601 assert!(dbg.contains("capacity"));
602 });
603 }
604
605 #[test]
606 fn embed_batch_empty_input() {
607 let (cached, _inner) = make_cached(16);
608 asupersync::test_utils::run_test_with_cx(|cx| async move {
609 let empty: &[&str] = &[];
610 let result = cached.embed_batch(&cx, empty).await.unwrap();
611 assert!(result.is_empty());
612 assert_eq!(cached.cache_stats().entries, 0);
613 });
614 }
615
616 #[test]
617 fn embed_batch_deduplicates_within_batch() {
618 let (cached, inner) = make_cached(16);
619 asupersync::test_utils::run_test_with_cx(|cx| async move {
620 let batch = cached
622 .embed_batch(&cx, &["hello", "hello", "world"])
623 .await
624 .unwrap();
625 assert_eq!(batch.len(), 3);
626 assert_eq!(inner.call_count(), 2);
628 assert_eq!(batch[0], batch[1]);
630 assert_ne!(batch[0], batch[2]);
632 assert_eq!(cached.cache_stats().hits, 1);
634 assert_eq!(cached.cache_stats().misses, 2);
635 });
636 }
637
638 }