Skip to main content

frankensearch_embed/
cached_embedder.rs

1//! Caching wrapper for any [`Embedder`] implementation.
2//!
3//! `CachedEmbedder` sits between the search pipeline and an inner embedder,
4//! caching query embeddings so that repeated queries skip inference entirely.
5//!
6//! The cache uses FIFO eviction with a bounded capacity (default 128 entries).
7//! Cache hits return a cloned `Vec<f32>`, which is cheap (~1.5 KiB for 384-dim).
8//!
9//! # Thread Safety
10//!
11//! The cache is protected by a `std::sync::Mutex`, keeping the wrapper `Send + Sync`.
12//! The lock is held only for the brief `HashMap` lookup/insert — never across an
13//! async `.await` boundary.
14
15use std::collections::{HashMap, VecDeque};
16use std::sync::{Arc, Mutex};
17
18use asupersync::Cx;
19use frankensearch_core::traits::{Embedder, ModelCategory, ModelTier, SearchFuture};
20
21/// Default maximum number of cached query embeddings.
22const DEFAULT_CAPACITY: usize = 128;
23
24/// Statistics snapshot from a [`CachedEmbedder`].
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct CacheStats {
27    /// Number of cache hits since creation (or last clear).
28    pub hits: u64,
29    /// Number of cache misses since creation (or last clear).
30    pub misses: u64,
31    /// Current number of entries in the cache.
32    pub entries: usize,
33    /// Maximum capacity before FIFO eviction kicks in.
34    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        // capacity == 0 means caching is disabled.
68        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
97/// Caching wrapper around any [`Embedder`].
98///
99/// Intercepts `embed()` calls and returns cached vectors for previously-seen
100/// query strings. All other trait methods delegate directly to the inner embedder.
101///
102/// # Construction
103///
104/// ```ignore
105/// use frankensearch_embed::CachedEmbedder;
106///
107/// let inner: Arc<dyn Embedder> = /* ... */;
108/// let cached = CachedEmbedder::new(inner, 128);
109/// ```
110pub 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    /// Wrap an embedder with a bounded query cache.
130    ///
131    /// `capacity` controls the maximum number of cached embeddings before
132    /// FIFO eviction begins.
133    #[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    /// Wrap an embedder with the default capacity (128 entries).
142    #[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    /// Return a snapshot of cache statistics.
154    #[must_use]
155    pub fn cache_stats(&self) -> CacheStats {
156        self.state_lock().stats()
157    }
158
159    /// Clear all cached embeddings and reset statistics.
160    pub fn clear_cache(&self) {
161        self.state_lock().clear();
162    }
163
164    /// Reference to the inner embedder.
165    #[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        // Check cache before acquiring any async resources.
174        // Lock scope is tiny: just a HashMap lookup.
175        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            // Insert into cache (lock scope: HashMap insert + possible eviction).
184            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    /// Test double: counts how many times `embed()` is called.
243    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            // Deterministic: use text length to seed a simple pattern
266            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            // Cache is full (2 entries). Inserting a third evicts "first".
342            cached.embed(&cx, "third").await.unwrap();
343            assert_eq!(inner.call_count(), 3);
344
345            // "first" was evicted, so this is a miss.
346            cached.embed(&cx, "first").await.unwrap();
347            assert_eq!(inner.call_count(), 4);
348
349            // "second" was evicted when "first" was re-inserted.
350            // "third" should still be cached.
351            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            // Pre-warm "alpha" into cache
404            cached.embed(&cx, "alpha").await.unwrap();
405            assert_eq!(inner.call_count(), 1);
406
407            // Batch with "alpha" (cached) and "beta" (miss)
408            let batch = cached.embed_batch(&cx, &["alpha", "beta"]).await.unwrap();
409            assert_eq!(batch.len(), 2);
410            // Only "beta" should have triggered an inner call
411            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            // Re-embed same query — should be a cache hit, not a duplicate insert
423            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    // ─── bd-1ocg tests begin ───
430
431    #[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; // Copy
440        let cloned = { stats }; // Clone trait is available (Copy implies Clone)
441        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            // Second insert evicts "first"
465            cached.embed(&cx, "second").await.unwrap();
466            assert_eq!(inner.call_count(), 2);
467            assert_eq!(cached.cache_stats().entries, 1);
468
469            // "first" is evicted, so it's a miss
470            cached.embed(&cx, "first").await.unwrap();
471            assert_eq!(inner.call_count(), 3);
472
473            // "second" was evicted by "first" re-insert
474            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        // CountingEmbedder uses default is_ready() which returns true
493        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        // CountingEmbedder uses default tier() which returns ModelTier::Fast
501        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        // CountingEmbedder uses default supports_mrl() which returns false
509        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(); // hit
518            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            // After clear, "alpha" is a miss again
527            cached.embed(&cx, "alpha").await.unwrap();
528            assert_eq!(inner.call_count(), 2); // called again
529            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            // Fill cache: a, b, c
539            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            // Insert d -> evicts a (FIFO)
546            cached.embed(&cx, "d").await.unwrap();
547            assert_eq!(inner.call_count(), 4);
548
549            // a is evicted (miss), b is still cached (hit)
550            cached.embed(&cx, "a").await.unwrap();
551            assert_eq!(inner.call_count(), 5); // miss
552            cached.embed(&cx, "b").await.unwrap();
553            // b was evicted when d was added (b was 2nd oldest after a was evicted,
554            // then a was re-added evicting b)
555            // Actually let's check: after d inserted, cache = [b, c, d]
556            // Then a inserted -> evicts b, cache = [c, d, a]
557            // So b should be a miss
558            assert_eq!(inner.call_count(), 6); // b is a miss
559        });
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); // second is cache hit
570        });
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            // Evict x, add z
582            cached.embed(&cx, "z").await.unwrap();
583            assert_eq!(cached.cache_stats().entries, 2); // stays at capacity
584
585            // Evict y, add w
586            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(); // hit
597            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            // Batch with duplicate items: "hello" appears twice, "world" once
621            let batch = cached
622                .embed_batch(&cx, &["hello", "hello", "world"])
623                .await
624                .unwrap();
625            assert_eq!(batch.len(), 3);
626            // Only 2 unique texts → 2 inner calls (second "hello" hits cache)
627            assert_eq!(inner.call_count(), 2);
628            // Both "hello" embeddings should be identical
629            assert_eq!(batch[0], batch[1]);
630            // "world" should differ
631            assert_ne!(batch[0], batch[2]);
632            // Stats: 1 hit (second "hello"), 2 misses (first "hello" + "world")
633            assert_eq!(cached.cache_stats().hits, 1);
634            assert_eq!(cached.cache_stats().misses, 2);
635        });
636    }
637
638    // ─── bd-1ocg tests end ───
639}