Skip to main content

lattice_embed/
cache.rs

1//! In-memory embedding cache with sharded LRU eviction.
2//!
3//! A key selects one of 16 independent shards. Cache hits refresh LRU order and therefore
4//! take that shard's write lock; hit/miss counters stay local to the shard. A zero-capacity
5//! cache bypasses storage operations and locking. The power-of-two shard count is required
6//! by the masking-based shard selection.
7//!
8//! See docs/model.md for key construction, capacity semantics, and lifecycle details.
9
10use crate::model::ModelConfig;
11use crate::service::EmbeddingRole;
12use lru::LruCache;
13use parking_lot::RwLock;
14use std::num::NonZeroUsize;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicU64, Ordering};
17use tracing::debug;
18
19/// **Unstable**: internal implementation detail; type alias may change with cache redesign.
20pub type CacheKey = [u8; 32];
21
22/// **Unstable**: tuning constant; value may change as memory models evolve.
23///
24/// Default cache capacity (number of embeddings). ~6MB for 384-dim vectors at 4000 entries.
25pub const DEFAULT_CACHE_CAPACITY: usize = 4000;
26
27/// Number of cache shards. Must be a power of 2 for fast modulo (bitwise AND).
28/// 16 shards on 8-core M4 Pro gives 2x oversubscription, keeping contention low.
29const NUM_SHARDS: usize = 16;
30
31/// Mask for shard index computation: `key[0] as usize & SHARD_MASK`.
32const SHARD_MASK: usize = NUM_SHARDS - 1;
33
34// Compile-time assertion that NUM_SHARDS is a power of 2.
35const _: () = assert!(
36    NUM_SHARDS.is_power_of_two(),
37    "NUM_SHARDS must be a power of 2"
38);
39
40/// A single cache shard with its own LRU cache and hit/miss counters.
41struct CacheShard {
42    lru: RwLock<LruCache<CacheKey, Arc<[f32]>>>,
43    hits: AtomicU64,
44    misses: AtomicU64,
45}
46
47impl CacheShard {
48    fn new(capacity: NonZeroUsize) -> Self {
49        Self {
50            lru: RwLock::new(LruCache::new(capacity)),
51            hits: AtomicU64::new(0),
52            misses: AtomicU64::new(0),
53        }
54    }
55
56    #[inline]
57    fn get(&self, key: &CacheKey) -> Option<Arc<[f32]>> {
58        let mut lru = self.lru.write();
59        let result = lru.get(key).cloned();
60        if result.is_some() {
61            self.hits.fetch_add(1, Ordering::Relaxed);
62        } else {
63            self.misses.fetch_add(1, Ordering::Relaxed);
64        }
65        result
66    }
67
68    #[inline]
69    fn put(&self, key: CacheKey, embedding: Arc<[f32]>) {
70        let mut lru = self.lru.write();
71        lru.put(key, embedding);
72    }
73
74    fn len(&self) -> usize {
75        self.lru.read().len()
76    }
77
78    fn clear(&self) {
79        self.lru.write().clear();
80    }
81
82    fn hits(&self) -> u64 {
83        self.hits.load(Ordering::Relaxed)
84    }
85
86    fn misses(&self) -> u64 {
87        self.misses.load(Ordering::Relaxed)
88    }
89}
90
91/// **Unstable**: the sharding and eviction implementation may change.
92///
93/// Thread-safe, sharded LRU cache for computed embeddings.
94/// A capacity of zero disables storage operations.
95/// See [`docs/design.md`](../docs/design.md#embeddingcache) for key identity, locking, and capacity semantics.
96pub struct EmbeddingCache {
97    shards: Vec<CacheShard>,
98    enabled: bool,
99    capacity: usize,
100}
101
102/// Select shard index from a cache key. Uses first byte masked to shard count.
103/// Blake3 output is uniformly distributed, so this gives balanced load.
104#[inline(always)]
105fn shard_index(key: &CacheKey) -> usize {
106    key[0] as usize & SHARD_MASK
107}
108
109impl EmbeddingCache {
110    /// **Unstable**: constructor signature may change when shard count becomes configurable.
111    ///
112    /// Creates a cache with the requested capacity; zero disables storage.
113    /// Nonzero capacity is rounded up independently across its fixed shards.
114    /// See [`docs/design.md`](../docs/design.md#embeddingcache) for sharding and eviction behavior.
115    pub fn new(capacity: usize) -> Self {
116        let enabled = capacity != 0;
117
118        // Round up per shard so the actual aggregate capacity meets the request.
119        let per_shard = if enabled {
120            let base = capacity.div_ceil(NUM_SHARDS);
121            if base == 0 { 1 } else { base }
122        } else {
123            1 // Disabled caches still need a valid LRU capacity.
124        };
125
126        let per_shard_nz = NonZeroUsize::new(per_shard).expect("per_shard is always >= 1");
127
128        let shards = (0..NUM_SHARDS)
129            .map(|_| CacheShard::new(per_shard_nz))
130            .collect();
131
132        Self {
133            shards,
134            enabled,
135            capacity,
136        }
137    }
138
139    /// **Unstable**: convenience constructor; subject to change with cache redesign.
140    pub fn with_default_capacity() -> Self {
141        Self::new(DEFAULT_CACHE_CAPACITY)
142    }
143
144    /// **Unstable**: the key scheme may change; do not persist keys across sessions.
145    ///
146    /// Hashes text, model identity, active dimension, and retrieval role into a cache key.
147    /// See [`docs/design.md`](../docs/design.md#embeddingcache) for identity and collision-isolation details.
148    pub fn compute_key(
149        &self,
150        text: &str,
151        model_config: ModelConfig,
152        role: EmbeddingRole,
153    ) -> CacheKey {
154        let mut hasher = blake3::Hasher::new();
155        hasher.update(text.as_bytes());
156        // Deterministic model/role namespace for this cache key.
157        let model_key = format!(
158            "{}:{}:{}:{}",
159            model_config.model,
160            model_config.model.key_version(),
161            model_config.dimensions(),
162            role.cache_tag(),
163        );
164        hasher.update(model_key.as_bytes());
165        *hasher.finalize().as_bytes()
166    }
167
168    /// **Unstable**: return type (`Arc<[f32]>`) may change to a newtype; internal cache API.
169    ///
170    /// Returns `Some(Arc<[f32]>)` if found (cheap refcount bump), `None` otherwise.
171    /// Updates per-shard hit/miss counters for metrics.
172    pub fn get(&self, key: &CacheKey) -> Option<Arc<[f32]>> {
173        if !self.enabled {
174            return None;
175        }
176
177        let idx = shard_index(key);
178        let result = self.shards[idx].get(key);
179
180        if result.is_some() {
181            debug!("cache hit for key {:?}", &key[..8]);
182        }
183
184        result
185    }
186
187    /// **Unstable**: internal cache storage method; interface may change.
188    ///
189    /// Converts the Vec into `Arc<[f32]>` for shared-ownership storage.
190    /// If the shard is at capacity, its least recently used entry is evicted.
191    pub fn put(&self, key: CacheKey, embedding: Vec<f32>) {
192        if !self.enabled {
193            return;
194        }
195
196        let idx = shard_index(&key);
197        self.shards[idx].put(key, Arc::from(embedding));
198        debug!("cached embedding for key {:?}", &key[..8]);
199    }
200
201    /// **Unstable**: batch cache access; return type may change with cache redesign.
202    ///
203    /// Returns a vector of `Option<Arc<[f32]>>` for each key, in the same order.
204    /// Each hit is an O(1) refcount bump (no data copy).
205    pub fn get_many(&self, keys: &[CacheKey]) -> Vec<Option<Arc<[f32]>>> {
206        if !self.enabled {
207            return vec![None; keys.len()];
208        }
209
210        keys.iter()
211            .map(|key| {
212                let idx = shard_index(key);
213                self.shards[idx].get(key)
214            })
215            .collect()
216    }
217
218    /// **Unstable**: batch cache storage; interface may change with cache redesign.
219    ///
220    /// Converts each Vec into `Arc<[f32]>` for shared-ownership storage.
221    pub fn put_many(&self, entries: Vec<(CacheKey, Vec<f32>)>) {
222        if !self.enabled {
223            return;
224        }
225
226        for (key, embedding) in entries {
227            let idx = shard_index(&key);
228            self.shards[idx].put(key, Arc::from(embedding));
229        }
230    }
231
232    /// **Unstable**: returns `CacheStats` which is itself Unstable; metrics shape may evolve.
233    ///
234    /// Aggregates per-shard counters. The `size` field is the sum of all shard sizes.
235    pub fn stats(&self) -> CacheStats {
236        if !self.enabled {
237            let (hits, misses) = self.aggregate_counters();
238            return CacheStats {
239                size: 0,
240                capacity: 0,
241                hits,
242                misses,
243            };
244        }
245
246        let size: usize = self.shards.iter().map(CacheShard::len).sum();
247        let (hits, misses) = self.aggregate_counters();
248
249        CacheStats {
250            size,
251            capacity: self.capacity,
252            hits,
253            misses,
254        }
255    }
256
257    /// **Unstable**: internal monitoring hook; shard count and `ShardStats` shape may change.
258    ///
259    /// Returns a vector of `(size, hits, misses)` tuples, one per shard.
260    pub fn per_shard_stats(&self) -> Vec<ShardStats> {
261        self.shards
262            .iter()
263            .enumerate()
264            .map(|(i, s)| ShardStats {
265                shard_id: i,
266                size: s.len(),
267                hits: s.hits(),
268                misses: s.misses(),
269            })
270            .collect()
271    }
272
273    /// **Unstable**: internal cache management; may be removed in favor of capacity-based eviction.
274    pub fn clear(&self) {
275        if !self.enabled {
276            return;
277        }
278
279        for shard in &self.shards {
280            shard.clear();
281        }
282        debug!("cache cleared");
283    }
284
285    /// **Unstable**: internal state query; may be removed when zero-capacity is the only disable path.
286    #[inline]
287    pub fn is_enabled(&self) -> bool {
288        self.enabled
289    }
290
291    /// Aggregate hit/miss counters across all shards.
292    fn aggregate_counters(&self) -> (u64, u64) {
293        let hits: u64 = self.shards.iter().map(CacheShard::hits).sum();
294        let misses: u64 = self.shards.iter().map(CacheShard::misses).sum();
295        (hits, misses)
296    }
297}
298
299impl Default for EmbeddingCache {
300    fn default() -> Self {
301        Self::with_default_capacity()
302    }
303}
304
305/// **Unstable**: metrics fields may be added/removed as monitoring needs evolve.
306///
307/// Cache statistics (aggregated across all shards).
308#[derive(Debug, Clone, Copy)]
309pub struct CacheStats {
310    /// Current number of cached entries (sum across all shards).
311    pub size: usize,
312    /// Maximum total cache capacity.
313    pub capacity: usize,
314    /// Number of cache hits (sum across all shards).
315    pub hits: u64,
316    /// Number of cache misses (sum across all shards).
317    pub misses: u64,
318}
319
320impl CacheStats {
321    /// **Unstable**: convenience metric; may move to a separate stats helper.
322    pub fn hit_rate(&self) -> f64 {
323        let total = self.hits + self.misses;
324        if total == 0 {
325            0.0
326        } else {
327            self.hits as f64 / total as f64
328        }
329    }
330}
331
332/// **Unstable**: shard count is an internal implementation detail; this struct may be removed.
333///
334/// Per-shard statistics for detailed monitoring.
335#[derive(Debug, Clone, Copy)]
336pub struct ShardStats {
337    /// Shard index (0 to NUM_SHARDS-1).
338    pub shard_id: usize,
339    /// Current number of entries in this shard.
340    pub size: usize,
341    /// Number of cache hits in this shard.
342    pub hits: u64,
343    /// Number of cache misses in this shard.
344    pub misses: u64,
345}
346
347impl ShardStats {
348    /// **Unstable**: per-shard metric; may be removed with `ShardStats`.
349    pub fn hit_rate(&self) -> f64 {
350        let total = self.hits + self.misses;
351        if total == 0 {
352            0.0
353        } else {
354            self.hits as f64 / total as f64
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::model::EmbeddingModel;
363
364    #[test]
365    fn test_cache_basic_operations() {
366        let cache = EmbeddingCache::new(100);
367        let key = cache.compute_key(
368            "hello",
369            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
370            EmbeddingRole::Generic,
371        );
372
373        assert!(cache.get(&key).is_none());
374
375        let embedding = vec![0.1, 0.2, 0.3];
376        cache.put(key, embedding.clone());
377
378        let cached = cache.get(&key).unwrap();
379        assert_eq!(&*cached, &embedding[..]);
380    }
381
382    #[test]
383    fn test_cache_eviction() {
384        // With 16 shards, a capacity of 16 gives 1 entry per shard.
385        // To test eviction, we need keys that hash to the same shard.
386        // Use a larger capacity and fill it up.
387        let cache = EmbeddingCache::new(16);
388
389        // Insert 32 entries — each shard has capacity 1, so each shard
390        // can only hold 1 entry. Inserting 2 entries to the same shard
391        // will evict the first.
392        let mut keys = Vec::new();
393        for i in 0..32u32 {
394            let text = format!("text_{i}");
395            let key = cache.compute_key(
396                &text,
397                ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
398                EmbeddingRole::Generic,
399            );
400            keys.push(key);
401            cache.put(key, vec![i as f32]);
402        }
403
404        // Total size should not exceed capacity (16)
405        let stats = cache.stats();
406        assert!(stats.size <= 16, "size {} exceeds capacity 16", stats.size);
407    }
408
409    #[test]
410    fn test_cache_lru_eviction_within_shard() {
411        // Create cache with capacity 32 (2 per shard).
412        let cache = EmbeddingCache::new(32);
413
414        // Find 3 keys that land in the same shard.
415        let mut same_shard_keys = Vec::new();
416        let mut i = 0u32;
417
418        // Find the first key's shard and collect 3 keys for it.
419        let first_key = cache.compute_key(
420            "probe_0",
421            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
422            EmbeddingRole::Generic,
423        );
424        let target_shard = shard_index(&first_key);
425
426        loop {
427            let key = cache.compute_key(
428                &format!("lru_test_{i}"),
429                ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
430                EmbeddingRole::Generic,
431            );
432            if shard_index(&key) == target_shard {
433                same_shard_keys.push((key, i));
434            }
435            if same_shard_keys.len() == 3 {
436                break;
437            }
438            i += 1;
439        }
440
441        let (k1, v1) = same_shard_keys[0];
442        let (k2, v2) = same_shard_keys[1];
443        let (k3, v3) = same_shard_keys[2];
444
445        // Insert k1 and k2 (shard capacity is 2).
446        cache.put(k1, vec![v1 as f32]);
447        cache.put(k2, vec![v2 as f32]);
448
449        // Access k1 to make it recently used.
450        assert!(cache.get(&k1).is_some());
451
452        // Insert k3 — should evict k2 (least recently used in this shard).
453        cache.put(k3, vec![v3 as f32]);
454
455        assert!(
456            cache.get(&k1).is_some(),
457            "k1 should survive (recently accessed)"
458        );
459        assert!(cache.get(&k2).is_none(), "k2 should be evicted (LRU)");
460        assert!(cache.get(&k3).is_some(), "k3 should exist (just inserted)");
461    }
462
463    #[test]
464    fn test_cache_different_models_different_keys() {
465        let cache = EmbeddingCache::new(100);
466
467        let key_small = cache.compute_key(
468            "text",
469            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
470            EmbeddingRole::Generic,
471        );
472        let key_base = cache.compute_key(
473            "text",
474            ModelConfig::new(EmbeddingModel::BgeBaseEnV15),
475            EmbeddingRole::Generic,
476        );
477
478        // Same text, different models = different keys
479        assert_ne!(key_small, key_base);
480    }
481
482    #[test]
483    fn test_cache_stats() {
484        let cache = EmbeddingCache::new(100);
485        let key = cache.compute_key(
486            "hello",
487            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
488            EmbeddingRole::Generic,
489        );
490
491        cache.get(&key); // Miss
492        cache.put(key, vec![0.1]);
493        cache.get(&key); // Hit
494
495        let stats = cache.stats();
496        assert_eq!(stats.size, 1);
497        assert_eq!(stats.hits, 1);
498        assert_eq!(stats.misses, 1);
499        assert!((stats.hit_rate() - 0.5).abs() < 0.001);
500    }
501
502    #[test]
503    fn test_cache_get_many() {
504        // Use capacity large enough that no shard evicts.
505        let cache = EmbeddingCache::new(100);
506
507        let key1 = cache.compute_key(
508            "one",
509            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
510            EmbeddingRole::Generic,
511        );
512        let key2 = cache.compute_key(
513            "two",
514            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
515            EmbeddingRole::Generic,
516        );
517        let key3 = cache.compute_key(
518            "three",
519            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
520            EmbeddingRole::Generic,
521        );
522
523        cache.put(key1, vec![1.0]);
524        cache.put(key3, vec![3.0]);
525
526        let results = cache.get_many(&[key1, key2, key3]);
527        assert_eq!(results.len(), 3);
528        assert_eq!(&**results[0].as_ref().unwrap(), &[1.0f32]);
529        assert!(results[1].is_none());
530        assert_eq!(&**results[2].as_ref().unwrap(), &[3.0f32]);
531    }
532
533    #[test]
534    fn test_cache_put_many() {
535        // Use capacity large enough that no shard evicts (ceil(100/16) = 7 per shard).
536        let cache = EmbeddingCache::new(100);
537
538        let key1 = cache.compute_key(
539            "one",
540            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
541            EmbeddingRole::Generic,
542        );
543        let key2 = cache.compute_key(
544            "two",
545            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
546            EmbeddingRole::Generic,
547        );
548
549        cache.put_many(vec![(key1, vec![1.0]), (key2, vec![2.0])]);
550
551        let v1 = cache.get(&key1).unwrap();
552        assert_eq!(&*v1, [1.0f32].as_slice());
553        let v2 = cache.get(&key2).unwrap();
554        assert_eq!(&*v2, [2.0f32].as_slice());
555    }
556
557    #[test]
558    fn test_cache_clear() {
559        let cache = EmbeddingCache::new(100);
560        let key = cache.compute_key(
561            "hello",
562            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
563            EmbeddingRole::Generic,
564        );
565
566        cache.put(key, vec![0.1]);
567        assert!(cache.get(&key).is_some());
568
569        cache.clear();
570        assert!(cache.get(&key).is_none());
571        assert_eq!(cache.stats().size, 0);
572    }
573
574    #[test]
575    fn test_cache_default_capacity() {
576        let cache = EmbeddingCache::with_default_capacity();
577        assert_eq!(cache.stats().capacity, DEFAULT_CACHE_CAPACITY);
578    }
579
580    #[test]
581    fn test_cache_disabled_is_noop() {
582        let cache = EmbeddingCache::new(0);
583        assert!(!cache.is_enabled());
584
585        let key = cache.compute_key(
586            "hello",
587            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
588            EmbeddingRole::Generic,
589        );
590        cache.put(key, vec![0.1]);
591        assert!(cache.get(&key).is_none());
592
593        let stats = cache.stats();
594        assert_eq!(stats.capacity, 0);
595        assert_eq!(stats.size, 0);
596    }
597
598    #[test]
599    fn test_concurrent_access() {
600        use std::thread;
601
602        // Use large capacity so no eviction occurs (800 entries across 16 shards).
603        // Per-shard capacity = ceil(4000/16) = 250, so 800 entries fit easily.
604        let cache = Arc::new(EmbeddingCache::new(4000));
605        let mut handles = Vec::new();
606
607        // Spawn 8 threads, each doing 100 put+get operations.
608        for t in 0..8 {
609            let cache = Arc::clone(&cache);
610            handles.push(thread::spawn(move || {
611                for i in 0..100 {
612                    // Each thread uses unique keys to avoid contention on same entry.
613                    let text = format!("thread_{t}_item_{i}");
614                    let key = cache.compute_key(
615                        &text,
616                        ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
617                        EmbeddingRole::Generic,
618                    );
619                    let embedding = vec![t as f32; 384];
620                    cache.put(key, embedding.clone());
621
622                    let result = cache.get(&key);
623                    assert!(result.is_some(), "put followed by get must succeed");
624                    assert_eq!(result.unwrap().len(), 384);
625                }
626            }));
627        }
628
629        for h in handles {
630            h.join().expect("thread panicked");
631        }
632
633        // All 800 entries should be in cache (capacity 4000 >> 800).
634        let stats = cache.stats();
635        assert_eq!(stats.size, 800);
636        assert!(stats.hits >= 800, "at least 800 hits expected");
637    }
638
639    #[test]
640    fn test_shard_distribution() {
641        // Use generous capacity to avoid eviction from uneven distribution.
642        // 4000 total → 250/shard. We insert 800 entries → ~50/shard on average.
643        let cache = EmbeddingCache::new(4000);
644
645        let n = 800;
646        for i in 0..n {
647            let key = cache.compute_key(
648                &format!("item_{i}"),
649                ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
650                EmbeddingRole::Generic,
651            );
652            cache.put(key, vec![i as f32]);
653        }
654
655        let shard_stats = cache.per_shard_stats();
656        assert_eq!(shard_stats.len(), NUM_SHARDS);
657
658        // Each shard should have entries. With uniform hash, each shard gets ~50.
659        for ss in &shard_stats {
660            assert!(
661                ss.size > 0,
662                "shard {} is empty — distribution is pathological",
663                ss.shard_id
664            );
665        }
666
667        // No eviction since 800 << 4000. Total should be exactly 800.
668        let total: usize = shard_stats.iter().map(|s| s.size).sum();
669        assert_eq!(total, n);
670
671        // Check distribution is reasonably uniform: no shard has >3x the average.
672        let avg = n / NUM_SHARDS; // 50
673        for ss in &shard_stats {
674            assert!(
675                ss.size <= avg * 3,
676                "shard {} has {} entries (avg {}), distribution too skewed",
677                ss.shard_id,
678                ss.size,
679                avg
680            );
681        }
682    }
683
684    #[test]
685    fn test_per_shard_stats_hit_tracking() {
686        let cache = EmbeddingCache::new(100);
687
688        // Insert a few entries and access them.
689        let key1 = cache.compute_key(
690            "hello",
691            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
692            EmbeddingRole::Generic,
693        );
694        let key2 = cache.compute_key(
695            "world",
696            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
697            EmbeddingRole::Generic,
698        );
699
700        cache.put(key1, vec![1.0]);
701        cache.put(key2, vec![2.0]);
702
703        // Access key1 three times, key2 once.
704        cache.get(&key1);
705        cache.get(&key1);
706        cache.get(&key1);
707        cache.get(&key2);
708
709        let shard_stats = cache.per_shard_stats();
710        let total_hits: u64 = shard_stats.iter().map(|s| s.hits).sum();
711        assert_eq!(total_hits, 4, "total hits should be 4");
712
713        let stats = cache.stats();
714        assert_eq!(stats.hits, 4);
715        assert_eq!(stats.misses, 0);
716    }
717
718    #[test]
719    fn test_small_capacity_rounds_up() {
720        // Capacity smaller than NUM_SHARDS: each shard gets at least 1.
721        let cache = EmbeddingCache::new(3);
722        assert!(cache.is_enabled());
723
724        let key = cache.compute_key(
725            "x",
726            ModelConfig::new(EmbeddingModel::BgeSmallEnV15),
727            EmbeddingRole::Generic,
728        );
729        cache.put(key, vec![42.0]);
730        assert!(cache.get(&key).is_some());
731    }
732
733    // -------------------------------------------------------------------------
734    // Role-aware cache key tests (P0-E2)
735    // -------------------------------------------------------------------------
736
737    /// Query and passage roles must produce different cache keys for the same raw text
738    /// so that embed_query("hello") and embed_passage("hello") are stored separately.
739    #[test]
740    fn test_role_query_vs_passage_different_keys() {
741        let cache = EmbeddingCache::new(100);
742        let model = ModelConfig::new(EmbeddingModel::MultilingualE5Small);
743        let text = "hello world";
744
745        let key_query = cache.compute_key(text, model, EmbeddingRole::Query);
746        let key_passage = cache.compute_key(text, model, EmbeddingRole::Passage);
747        let key_generic = cache.compute_key(text, model, EmbeddingRole::Generic);
748
749        assert_ne!(key_query, key_passage, "query vs passage must differ");
750        assert_ne!(key_query, key_generic, "query vs generic must differ");
751        assert_ne!(key_passage, key_generic, "passage vs generic must differ");
752    }
753
754    /// Role keys are consistent: same inputs always produce same key.
755    #[test]
756    fn test_role_key_deterministic() {
757        let cache = EmbeddingCache::new(100);
758        let model = ModelConfig::new(EmbeddingModel::BgeSmallEnV15);
759
760        let k1 = cache.compute_key("test", model, EmbeddingRole::Query);
761        let k2 = cache.compute_key("test", model, EmbeddingRole::Query);
762        assert_eq!(k1, k2, "identical inputs must produce identical key");
763    }
764
765    /// Storing under one role does not pollute the other role's key.
766    #[test]
767    fn test_role_cache_isolation() {
768        let cache = EmbeddingCache::new(100);
769        let model = ModelConfig::new(EmbeddingModel::MultilingualE5Small);
770
771        let key_query = cache.compute_key("embed me", model, EmbeddingRole::Query);
772        let key_passage = cache.compute_key("embed me", model, EmbeddingRole::Passage);
773
774        // Store under Query role.
775        cache.put(key_query, vec![1.0, 2.0]);
776
777        // Passage role key must still be a miss.
778        assert!(
779            cache.get(&key_passage).is_none(),
780            "passage key must miss after storing under query key"
781        );
782
783        // Query role key must hit.
784        assert!(
785            cache.get(&key_query).is_some(),
786            "query key must hit after storing under query key"
787        );
788    }
789}