Skip to main content

ipfrs_semantic/semantic_cache_manager/
mod.rs

1//! # Semantic Cache Manager
2//!
3//! A production-quality semantic-aware cache that returns cached results for queries
4//! whose embeddings are semantically similar to previously cached queries — even when
5//! the exact query text differs.
6//!
7//! Unlike a regular cache (exact key match), a [`SemanticCacheManager`] can return a
8//! cached result for a *semantically similar* query, reducing redundant computation
9//! when two different phrasings ask for the same information.
10//!
11//! ## Features
12//!
13//! - **Exact-match fast-path** via FNV-1a hashed query text
14//! - **Semantic-match slow-path** via cosine similarity scan
15//! - **TTL expiry** on every lookup (lazy) and explicit batch expiry
16//! - **Five eviction strategies**: LRU, LFU, SemanticCluster, TTLFirst, HybridScore
17//! - **Byte-budget enforcement** (`max_bytes`)
18//! - **Semantic neighbors** query for introspection
19//! - **Greedy cluster statistics** for monitoring embedding distribution
20//! - **Comprehensive statistics** (hit-rates, avg similarity on semantic hit, etc.)
21//!
22//! ## Example
23//!
24//! ```rust
25//! use ipfrs_semantic::semantic_cache_manager::{
26//!     ScmCacheConfig, ScmCacheKey, ScmCacheHit, ScmEvictionStrategy, SemanticCacheManager,
27//! };
28//!
29//! let config = ScmCacheConfig {
30//!     max_entries: 256,
31//!     max_bytes: 64 * 1024 * 1024,
32//!     semantic_threshold: 0.90,
33//!     ttl_us: None,
34//!     strategy: ScmEvictionStrategy::LRU,
35//!     cluster_radius: 0.15,
36//! };
37//!
38//! let mut mgr = SemanticCacheManager::new(config);
39//!
40//! let key = ScmCacheKey::new("What is IPFS?".to_string(), vec![0.1, 0.2, 0.3]);
41//! let id = mgr.insert(key, b"answer bytes".to_vec(), None).expect("insert ok");
42//!
43//! let hit = mgr
44//!     .lookup("What is IPFS?", &[0.1, 0.2, 0.3], 0)
45//!     .expect("lookup ok");
46//! assert!(matches!(hit, ScmCacheHit::Exact { entry_id } if entry_id == id));
47//! ```
48
49mod scm_types;
50use scm_types::{cosine_similarity, fnv1a_64, xorshift64, Slot};
51pub use scm_types::{
52    ScmCacheConfig, ScmCacheEntry, ScmCacheError, ScmCacheHit, ScmCacheKey, ScmCacheStats,
53    ScmEvictionStrategy,
54};
55
56// ──────────────────────────────────────────────────────────────────────────────
57// SemanticCacheManager
58// ──────────────────────────────────────────────────────────────────────────────
59
60/// A semantic-aware cache backed by cosine similarity search.
61///
62/// Entries are stored in a `Vec<Slot>` for simplicity and full linear-scan
63/// semantic search. For workloads requiring sub-linear semantic lookup, wrap
64/// this with an HNSW layer on top.
65#[derive(Debug)]
66pub struct SemanticCacheManager {
67    config: ScmCacheConfig,
68    slots: Vec<Slot>,
69    next_id: u64,
70    /// Expected embedding dimension (inferred from the first insert).
71    embedding_dim: Option<usize>,
72    // Stats accumulators
73    total_insertions: u64,
74    exact_hits: u64,
75    semantic_hits: u64,
76    misses: u64,
77    evictions: u64,
78    // Running sum for average similarity on semantic hits.
79    semantic_similarity_sum: f64,
80    // PRNG state for tie-breaking.
81    rng_state: u64,
82}
83
84impl SemanticCacheManager {
85    /// Create a new [`SemanticCacheManager`] with the given configuration.
86    pub fn new(config: ScmCacheConfig) -> Self {
87        Self {
88            slots: Vec::with_capacity(config.max_entries.min(4096)),
89            next_id: 1,
90            embedding_dim: None,
91            total_insertions: 0,
92            exact_hits: 0,
93            semantic_hits: 0,
94            misses: 0,
95            evictions: 0,
96            semantic_similarity_sum: 0.0,
97            rng_state: 0xDEAD_BEEF_CAFE_0001,
98            config,
99        }
100    }
101
102    // ─────────────────────────────────────────────────────────────────────────
103    // Public API
104    // ─────────────────────────────────────────────────────────────────────────
105
106    /// Insert an entry into the cache.
107    ///
108    /// Assigns a monotonically increasing `entry_id` and stores the entry.
109    /// If the cache is over capacity after insertion, entries are evicted
110    /// according to the configured [`ScmEvictionStrategy`].
111    ///
112    /// # Errors
113    ///
114    /// - [`ScmCacheError::EntryTooLarge`] if the entry alone exceeds `max_bytes`.
115    /// - [`ScmCacheError::DimensionMismatch`] if the embedding dimension is wrong.
116    /// - [`ScmCacheError::CacheAtCapacity`] if eviction fails to free space.
117    pub fn insert(
118        &mut self,
119        key: ScmCacheKey,
120        result: Vec<u8>,
121        ttl_us: Option<u64>,
122    ) -> Result<u64, ScmCacheError> {
123        // Dimension check
124        if let Some(dim) = self.embedding_dim {
125            if key.embedding.len() != dim {
126                return Err(ScmCacheError::DimensionMismatch {
127                    expected: dim,
128                    got: key.embedding.len(),
129                });
130            }
131        } else if !key.embedding.is_empty() {
132            self.embedding_dim = Some(key.embedding.len());
133        }
134
135        let effective_ttl = ttl_us.or(self.config.ttl_us);
136        let entry = ScmCacheEntry {
137            result,
138            inserted_at: 0, // caller uses current_ts in lookup; insert doesn't mandate a clock
139            last_accessed: 0,
140            access_count: 0,
141            ttl_us: effective_ttl,
142            similarity_score: 1.0,
143            key,
144        };
145
146        let entry_bytes = entry.byte_size();
147        if entry_bytes > self.config.max_bytes {
148            return Err(ScmCacheError::EntryTooLarge(entry_bytes));
149        }
150
151        // Free space if needed
152        let current_bytes = self.current_bytes();
153        if current_bytes + entry_bytes > self.config.max_bytes
154            || self.slots.len() >= self.config.max_entries
155        {
156            let needed = entry_bytes;
157            self.evict_to_fit_internal(needed)?;
158        }
159
160        let id = self.next_id;
161        self.next_id += 1;
162        self.slots.push(Slot { id, entry });
163        self.total_insertions += 1;
164        Ok(id)
165    }
166
167    /// Insert with an explicit insertion timestamp (used in tests and production
168    /// where you track time yourself).
169    pub fn insert_at(
170        &mut self,
171        key: ScmCacheKey,
172        result: Vec<u8>,
173        ttl_us: Option<u64>,
174        inserted_at: u64,
175    ) -> Result<u64, ScmCacheError> {
176        let id = self.insert(key, result, ttl_us)?;
177        // Patch the insertion timestamp on the just-added slot.
178        if let Some(slot) = self.slots.iter_mut().find(|s| s.id == id) {
179            slot.entry.inserted_at = inserted_at;
180            slot.entry.last_accessed = inserted_at;
181        }
182        Ok(id)
183    }
184
185    /// Look up a query in the cache.
186    ///
187    /// 1. Scan all live entries; lazily expire any TTL-expired entries found.
188    /// 2. Check for an exact-text match (same FNV-1a hash *and* identical text).
189    /// 3. If no exact match, find the highest-similarity entry above the threshold.
190    ///
191    /// Updates `access_count` and `last_accessed` on the winning entry.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`ScmCacheError::DimensionMismatch`] when the provided embedding
196    /// dimension differs from the inferred dimension.
197    pub fn lookup(
198        &mut self,
199        query_text: &str,
200        embedding: &[f64],
201        current_ts: u64,
202    ) -> Result<ScmCacheHit, ScmCacheError> {
203        // Dimension check
204        if let Some(dim) = self.embedding_dim {
205            if !embedding.is_empty() && embedding.len() != dim {
206                return Err(ScmCacheError::DimensionMismatch {
207                    expected: dim,
208                    got: embedding.len(),
209                });
210            }
211        }
212
213        let query_hash = fnv1a_64(query_text.as_bytes());
214
215        // Expire stale entries lazily
216        self.slots.retain(|s| !s.entry.is_expired(current_ts));
217
218        // Find best match
219        let mut best_exact: Option<usize> = None;
220        let mut best_semantic: Option<(usize, f64)> = None;
221
222        for (i, slot) in self.slots.iter().enumerate() {
223            // Exact match fast-path
224            if slot.entry.key.query_hash == query_hash && slot.entry.key.query_text == query_text {
225                best_exact = Some(i);
226                break; // exact match wins immediately
227            }
228            // Semantic similarity
229            let sim = cosine_similarity(embedding, &slot.entry.key.embedding);
230            if sim >= self.config.semantic_threshold {
231                match best_semantic {
232                    Some((_, prev_sim)) if sim <= prev_sim => {}
233                    _ => best_semantic = Some((i, sim)),
234                }
235            }
236        }
237
238        if let Some(idx) = best_exact {
239            let slot = &mut self.slots[idx];
240            slot.entry.access_count += 1;
241            slot.entry.last_accessed = current_ts;
242            self.exact_hits += 1;
243            return Ok(ScmCacheHit::Exact { entry_id: slot.id });
244        }
245
246        if let Some((idx, sim)) = best_semantic {
247            let slot = &mut self.slots[idx];
248            slot.entry.access_count += 1;
249            slot.entry.last_accessed = current_ts;
250            let entry_id = slot.id;
251            let original_query = slot.entry.key.query_text.clone();
252            self.semantic_hits += 1;
253            self.semantic_similarity_sum += sim;
254            return Ok(ScmCacheHit::Semantic {
255                entry_id,
256                similarity: sim,
257                original_query,
258            });
259        }
260
261        self.misses += 1;
262        Ok(ScmCacheHit::Miss)
263    }
264
265    /// Retrieve a reference to an entry by its ID.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`ScmCacheError::EntryNotFound`] if no such ID exists.
270    pub fn get_entry(&self, entry_id: u64) -> Result<&ScmCacheEntry, ScmCacheError> {
271        self.slots
272            .iter()
273            .find(|s| s.id == entry_id)
274            .map(|s| &s.entry)
275            .ok_or(ScmCacheError::EntryNotFound(entry_id))
276    }
277
278    /// Remove the entry with the given ID.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`ScmCacheError::EntryNotFound`] if no such ID exists.
283    pub fn invalidate(&mut self, entry_id: u64) -> Result<(), ScmCacheError> {
284        let pos = self
285            .slots
286            .iter()
287            .position(|s| s.id == entry_id)
288            .ok_or(ScmCacheError::EntryNotFound(entry_id))?;
289        self.slots.swap_remove(pos);
290        Ok(())
291    }
292
293    /// Remove all entries whose embedding has cosine similarity > `threshold`
294    /// with the given `embedding`.  Returns the IDs of removed entries.
295    pub fn invalidate_similar(&mut self, embedding: &[f64], threshold: f64) -> Vec<u64> {
296        let mut removed = Vec::new();
297        let mut i = 0;
298        while i < self.slots.len() {
299            let sim = cosine_similarity(embedding, &self.slots[i].entry.key.embedding);
300            if sim > threshold {
301                removed.push(self.slots[i].id);
302                self.slots.swap_remove(i);
303            } else {
304                i += 1;
305            }
306        }
307        removed
308    }
309
310    /// Remove all TTL-expired entries as of `current_ts`.  Returns the IDs removed.
311    pub fn expire_ttl(&mut self, current_ts: u64) -> Vec<u64> {
312        let mut removed = Vec::new();
313        let mut i = 0;
314        while i < self.slots.len() {
315            if self.slots[i].entry.is_expired(current_ts) {
316                removed.push(self.slots[i].id);
317                self.slots.swap_remove(i);
318            } else {
319                i += 1;
320            }
321        }
322        removed
323    }
324
325    /// Evict entries (per the configured strategy) until at least `needed_bytes`
326    /// of space is available.  Returns the IDs of evicted entries.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`ScmCacheError::CacheAtCapacity`] if it is impossible to free
331    /// enough space (e.g., all entries are locked or the budget is zero).
332    pub fn evict_to_fit(&mut self, needed_bytes: usize) -> Vec<u64> {
333        let current = self.current_bytes();
334        if current + needed_bytes <= self.config.max_bytes
335            && self.slots.len() < self.config.max_entries
336        {
337            return Vec::new();
338        }
339        let mut removed = Vec::new();
340        while !self.slots.is_empty()
341            && (self.current_bytes() + needed_bytes > self.config.max_bytes
342                || self.slots.len() >= self.config.max_entries)
343        {
344            if let Some(victim) = self.pick_victim() {
345                let id = self.slots[victim].id;
346                self.slots.swap_remove(victim);
347                self.evictions += 1;
348                removed.push(id);
349            } else {
350                break;
351            }
352        }
353        removed
354    }
355
356    /// Return the top-`k` entries ranked by cosine similarity to `embedding`,
357    /// without any threshold filtering.
358    pub fn semantic_neighbors(&self, embedding: &[f64], top_k: usize) -> Vec<(u64, f64)> {
359        if top_k == 0 || self.slots.is_empty() {
360            return Vec::new();
361        }
362        let mut scores: Vec<(u64, f64)> = self
363            .slots
364            .iter()
365            .map(|s| {
366                let sim = cosine_similarity(embedding, &s.entry.key.embedding);
367                (s.id, sim)
368            })
369            .collect();
370        // Sort descending by similarity; stable sort for determinism
371        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
372        scores.truncate(top_k);
373        scores
374    }
375
376    /// Compute greedy cluster statistics.
377    ///
378    /// Groups embeddings into clusters where every member is within
379    /// `config.cluster_radius` (in cosine distance = 1 − similarity) of the
380    /// cluster centroid.  Returns a `Vec` of `(centroid_similarity, cluster_size)`.
381    ///
382    /// The "centroid" here is represented as the embedding of the first entry
383    /// assigned to each cluster (greedy seeding).  The returned f64 is the
384    /// average pairwise cosine similarity within the cluster.
385    pub fn cluster_stats(&self) -> Vec<(f64, usize)> {
386        if self.slots.is_empty() {
387            return Vec::new();
388        }
389        // Collect all embeddings with their slot indices
390        let embeddings: Vec<&Vec<f64>> =
391            self.slots.iter().map(|s| &s.entry.key.embedding).collect();
392        let n = embeddings.len();
393        let mut assigned = vec![false; n];
394        let mut clusters: Vec<(f64, usize)> = Vec::new();
395
396        for seed in 0..n {
397            if assigned[seed] {
398                continue;
399            }
400            // Start a new cluster with this seed
401            let mut members: Vec<usize> = vec![seed];
402            assigned[seed] = true;
403            for candidate in (seed + 1)..n {
404                if assigned[candidate] {
405                    continue;
406                }
407                let sim = cosine_similarity(embeddings[seed], embeddings[candidate]);
408                // cluster_radius is interpreted as (1 - similarity) threshold
409                if 1.0 - sim <= self.config.cluster_radius {
410                    members.push(candidate);
411                    assigned[candidate] = true;
412                }
413            }
414            // Compute average intra-cluster similarity as the representative score
415            let avg_sim = if members.len() == 1 {
416                1.0
417            } else {
418                let mut sum = 0.0;
419                let mut count = 0usize;
420                for i in 0..members.len() {
421                    for j in (i + 1)..members.len() {
422                        sum += cosine_similarity(embeddings[members[i]], embeddings[members[j]]);
423                        count += 1;
424                    }
425                }
426                if count == 0 {
427                    1.0
428                } else {
429                    sum / count as f64
430                }
431            };
432            clusters.push((avg_sim, members.len()));
433        }
434        clusters
435    }
436
437    /// Return a snapshot of current cache statistics.
438    pub fn stats(&self) -> ScmCacheStats {
439        let total_queries = self.exact_hits + self.semantic_hits + self.misses;
440        let semantic_hit_rate = if total_queries == 0 {
441            0.0
442        } else {
443            self.semantic_hits as f64 / total_queries as f64
444        };
445        let avg_similarity_on_semantic_hit = if self.semantic_hits == 0 {
446            0.0
447        } else {
448            self.semantic_similarity_sum / self.semantic_hits as f64
449        };
450        ScmCacheStats {
451            total_insertions: self.total_insertions,
452            exact_hits: self.exact_hits,
453            semantic_hits: self.semantic_hits,
454            misses: self.misses,
455            evictions: self.evictions,
456            current_entries: self.slots.len(),
457            current_bytes: self.current_bytes(),
458            semantic_hit_rate,
459            avg_similarity_on_semantic_hit,
460        }
461    }
462
463    // ─────────────────────────────────────────────────────────────────────────
464    // Private helpers
465    // ─────────────────────────────────────────────────────────────────────────
466
467    /// Sum of byte sizes of all current slots.
468    fn current_bytes(&self) -> usize {
469        self.slots.iter().map(|s| s.entry.byte_size()).sum()
470    }
471
472    /// Internal evict-to-fit that returns an error instead of a Vec.
473    fn evict_to_fit_internal(&mut self, needed_bytes: usize) -> Result<(), ScmCacheError> {
474        let max_iters = self.slots.len() + 1; // safety bound
475        let mut iters = 0;
476        while !self.slots.is_empty()
477            && (self.current_bytes() + needed_bytes > self.config.max_bytes
478                || self.slots.len() >= self.config.max_entries)
479        {
480            if iters >= max_iters {
481                return Err(ScmCacheError::CacheAtCapacity);
482            }
483            match self.pick_victim() {
484                Some(victim) => {
485                    self.slots.swap_remove(victim);
486                    self.evictions += 1;
487                }
488                None => return Err(ScmCacheError::CacheAtCapacity),
489            }
490            iters += 1;
491        }
492        Ok(())
493    }
494
495    /// Select the index of the entry to evict according to the current strategy.
496    /// Returns `None` only when `slots` is empty.
497    fn pick_victim(&mut self) -> Option<usize> {
498        if self.slots.is_empty() {
499            return None;
500        }
501        let strategy = self.config.strategy.clone();
502        match strategy {
503            ScmEvictionStrategy::LRU => self.victim_lru(),
504            ScmEvictionStrategy::LFU => self.victim_lfu(),
505            ScmEvictionStrategy::TTLFirst => self.victim_ttl_first(),
506            ScmEvictionStrategy::SemanticCluster => self.victim_semantic_cluster(),
507            ScmEvictionStrategy::HybridScore(w) => self.victim_hybrid(w),
508        }
509    }
510
511    /// Least-recently-used: return index with the smallest `last_accessed`.
512    fn victim_lru(&self) -> Option<usize> {
513        self.slots
514            .iter()
515            .enumerate()
516            .min_by_key(|(_, s)| s.entry.last_accessed)
517            .map(|(i, _)| i)
518    }
519
520    /// Least-frequently-used: return index with the smallest `access_count`.
521    fn victim_lfu(&self) -> Option<usize> {
522        self.slots
523            .iter()
524            .enumerate()
525            .min_by_key(|(_, s)| s.entry.access_count)
526            .map(|(i, _)| i)
527    }
528
529    /// TTL-first: evict whichever entry is closest to expiry (smallest
530    /// `ttl_us - elapsed`). Falls back to LRU when no entry has a TTL.
531    fn victim_ttl_first(&self) -> Option<usize> {
532        // Find entry with smallest remaining TTL
533        let ttl_victim = self
534            .slots
535            .iter()
536            .enumerate()
537            .filter_map(|(i, s)| {
538                s.entry.ttl_us.map(|ttl| {
539                    let remaining = ttl
540                        .saturating_sub(s.entry.last_accessed.saturating_sub(s.entry.inserted_at));
541                    (i, remaining)
542                })
543            })
544            .min_by_key(|(_, rem)| *rem)
545            .map(|(i, _)| i);
546
547        ttl_victim.or_else(|| self.victim_lru())
548    }
549
550    /// SemanticCluster: evict an entry from the largest cluster.
551    /// Uses the greedy clustering based on `cluster_radius`.
552    fn victim_semantic_cluster(&mut self) -> Option<usize> {
553        if self.slots.is_empty() {
554            return None;
555        }
556        let n = self.slots.len();
557        let embeddings: Vec<&Vec<f64>> =
558            self.slots.iter().map(|s| &s.entry.key.embedding).collect();
559
560        // Build cluster assignments (reuse the greedy logic inline for efficiency)
561        let mut cluster_id = vec![usize::MAX; n];
562        let mut cluster_seeds: Vec<usize> = Vec::new();
563
564        for seed in 0..n {
565            if cluster_id[seed] != usize::MAX {
566                continue;
567            }
568            let cid = cluster_seeds.len();
569            cluster_seeds.push(seed);
570            cluster_id[seed] = cid;
571            for candidate in (seed + 1)..n {
572                if cluster_id[candidate] != usize::MAX {
573                    continue;
574                }
575                let sim = cosine_similarity(embeddings[seed], embeddings[candidate]);
576                if 1.0 - sim <= self.config.cluster_radius {
577                    cluster_id[candidate] = cid;
578                }
579            }
580        }
581
582        // Count cluster sizes
583        let num_clusters = cluster_seeds.len();
584        let mut sizes = vec![0usize; num_clusters];
585        for &cid in &cluster_id {
586            if cid < num_clusters {
587                sizes[cid] += 1;
588            }
589        }
590
591        // Find the largest cluster
592        let (largest_cid, _) = sizes
593            .iter()
594            .enumerate()
595            .max_by_key(|(_, &s)| s)
596            .unwrap_or((0, &0));
597
598        // Pick the LRU entry within the largest cluster
599        cluster_id
600            .iter()
601            .enumerate()
602            .filter(|(_, &cid)| cid == largest_cid)
603            .min_by_key(|(i, _)| self.slots[*i].entry.last_accessed)
604            .map(|(i, _)| i)
605    }
606
607    /// HybridScore: combined recency + frequency eviction.
608    /// Evict the entry with the lowest hybrid score.
609    fn victim_hybrid(&mut self, weight: f64) -> Option<usize> {
610        if self.slots.is_empty() {
611            return None;
612        }
613        let weight = weight.clamp(0.0, 1.0);
614
615        // Normalise last_accessed and access_count into [0,1] ranges
616        let max_ts = self.slots.iter().map(|s| s.entry.last_accessed).max()?;
617        let max_ts = max_ts.max(1); // avoid divide-by-zero
618        let max_cnt = self
619            .slots
620            .iter()
621            .map(|s| s.entry.access_count)
622            .max()
623            .unwrap_or(1)
624            .max(1);
625
626        let mut best_idx = 0;
627        let mut best_score = f64::MAX;
628
629        for (i, slot) in self.slots.iter().enumerate() {
630            let recency = slot.entry.last_accessed as f64 / max_ts as f64;
631            let frequency = slot.entry.access_count as f64 / max_cnt as f64;
632            let score = weight * recency + (1.0 - weight) * frequency;
633            if score < best_score
634                || (score == best_score && {
635                    // tie-break with a seeded deterministic step
636                    let noise = xorshift64(&mut self.rng_state) & 1;
637                    noise == 0
638                })
639            {
640                best_score = score;
641                best_idx = i;
642            }
643        }
644        Some(best_idx)
645    }
646}
647
648// ──────────────────────────────────────────────────────────────────────────────
649// Public type aliases for name-collision compatibility
650// ──────────────────────────────────────────────────────────────────────────────
651
652/// Alias: the cache key type used by [`SemanticCacheManager`].
653pub type ScmKeyAlias = ScmCacheKey;
654/// Alias: the cache entry type used by [`SemanticCacheManager`].
655pub type ScmEntryAlias = ScmCacheEntry;
656/// Alias: the cache statistics type used by [`SemanticCacheManager`].
657pub type ScmStatsAlias = ScmCacheStats;
658/// Alias: the cache hit/miss result type used by [`SemanticCacheManager`].
659pub type ScmHitAlias = ScmCacheHit;
660/// Alias: the error type used by [`SemanticCacheManager`].
661pub type ScmErrorAlias = ScmCacheError;
662
663// ──────────────────────────────────────────────────────────────────────────────
664// Tests
665// ──────────────────────────────────────────────────────────────────────────────
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    // ── helpers ──────────────────────────────────────────────────────────────
672
673    fn default_config() -> ScmCacheConfig {
674        ScmCacheConfig {
675            max_entries: 16,
676            max_bytes: 1024 * 1024,
677            semantic_threshold: 0.90,
678            ttl_us: None,
679            strategy: ScmEvictionStrategy::LRU,
680            cluster_radius: 0.15,
681        }
682    }
683
684    fn make_key(text: &str, emb: Vec<f64>) -> ScmCacheKey {
685        ScmCacheKey::new(text.to_string(), emb)
686    }
687
688    fn unit_vec(dim: usize, hot: usize) -> Vec<f64> {
689        let mut v = vec![0.0; dim];
690        if hot < dim {
691            v[hot] = 1.0;
692        }
693        v
694    }
695
696    fn uniform_vec(dim: usize, val: f64) -> Vec<f64> {
697        vec![val; dim]
698    }
699
700    fn make_mgr() -> SemanticCacheManager {
701        SemanticCacheManager::new(default_config())
702    }
703
704    // ── 1. Basic insert returns a valid ID ───────────────────────────────────
705    #[test]
706    fn test_insert_returns_id() {
707        let mut mgr = make_mgr();
708        let key = make_key("hello", vec![1.0, 0.0, 0.0]);
709        let id = mgr.insert(key, b"result".to_vec(), None).expect("insert");
710        assert!(id >= 1);
711    }
712
713    // ── 2. IDs are monotonically increasing ──────────────────────────────────
714    #[test]
715    fn test_insert_monotonic_ids() {
716        let mut mgr = make_mgr();
717        let id1 = mgr
718            .insert(make_key("a", vec![1.0, 0.0]), b"r".to_vec(), None)
719            .expect("a");
720        let id2 = mgr
721            .insert(make_key("b", vec![0.0, 1.0]), b"r".to_vec(), None)
722            .expect("b");
723        assert!(id2 > id1);
724    }
725
726    // ── 3. Exact hit ─────────────────────────────────────────────────────────
727    #[test]
728    fn test_exact_hit() {
729        let mut mgr = make_mgr();
730        let emb = vec![1.0, 0.0, 0.0];
731        let id = mgr
732            .insert(make_key("exact query", emb.clone()), b"res".to_vec(), None)
733            .expect("insert");
734        let hit = mgr.lookup("exact query", &emb, 0).expect("lookup");
735        assert_eq!(hit, ScmCacheHit::Exact { entry_id: id });
736    }
737
738    // ── 4. Semantic hit above threshold ──────────────────────────────────────
739    #[test]
740    fn test_semantic_hit_above_threshold() {
741        let mut mgr = make_mgr();
742        // Insert with embedding [1, 0, 0]
743        let emb = vec![1.0, 0.0, 0.0];
744        let id = mgr
745            .insert(make_key("what is ipfs", emb.clone()), b"res".to_vec(), None)
746            .expect("insert");
747        // Query with slightly perturbed embedding — high cosine similarity
748        let query_emb = vec![0.999, 0.04, 0.0];
749        let hit = mgr.lookup("what is ipfs?", &query_emb, 0).expect("lookup");
750        match hit {
751            ScmCacheHit::Semantic {
752                entry_id,
753                similarity,
754                ..
755            } => {
756                assert_eq!(entry_id, id);
757                assert!(similarity >= 0.90);
758            }
759            other => panic!("expected Semantic hit, got {:?}", other),
760        }
761    }
762
763    // ── 5. Semantic hit below threshold → Miss ───────────────────────────────
764    #[test]
765    fn test_semantic_hit_below_threshold() {
766        let mut mgr = make_mgr();
767        mgr.insert(
768            make_key("topic A", vec![1.0, 0.0, 0.0]),
769            b"r".to_vec(),
770            None,
771        )
772        .expect("insert");
773        // Perpendicular embedding → similarity = 0 < 0.90
774        let hit = mgr.lookup("topic B", &[0.0, 1.0, 0.0], 0).expect("lookup");
775        assert_eq!(hit, ScmCacheHit::Miss);
776    }
777
778    // ── 6. Miss on empty cache ────────────────────────────────────────────────
779    #[test]
780    fn test_miss_on_empty_cache() {
781        let mut mgr = make_mgr();
782        let hit = mgr.lookup("anything", &[1.0, 0.0], 0).expect("lookup");
783        assert_eq!(hit, ScmCacheHit::Miss);
784    }
785
786    // ── 7. TTL expiration during lookup ──────────────────────────────────────
787    #[test]
788    fn test_ttl_expiration_during_lookup() {
789        let mut mgr = make_mgr();
790        // Insert with 100 µs TTL at ts=0
791        let emb = vec![1.0, 0.0, 0.0];
792        mgr.insert_at(
793            make_key("expiring", emb.clone()),
794            b"r".to_vec(),
795            Some(100),
796            0,
797        )
798        .expect("insert");
799        // Lookup at ts=200 — entry should have expired
800        let hit = mgr.lookup("expiring", &emb, 200).expect("lookup");
801        assert_eq!(hit, ScmCacheHit::Miss);
802        assert_eq!(mgr.slots.len(), 0);
803    }
804
805    // ── 8. TTL entry alive before expiry ─────────────────────────────────────
806    #[test]
807    fn test_ttl_entry_alive_before_expiry() {
808        let mut mgr = make_mgr();
809        let emb = vec![1.0, 0.0, 0.0];
810        let id = mgr
811            .insert_at(make_key("live", emb.clone()), b"r".to_vec(), Some(1000), 0)
812            .expect("insert");
813        // Lookup at ts=50 — still alive
814        let hit = mgr.lookup("live", &emb, 50).expect("lookup");
815        assert_eq!(hit, ScmCacheHit::Exact { entry_id: id });
816    }
817
818    // ── 9. explicit expire_ttl ───────────────────────────────────────────────
819    #[test]
820    fn test_expire_ttl_explicit() {
821        let mut mgr = make_mgr();
822        let emb = vec![1.0, 0.0];
823        let id1 = mgr
824            .insert_at(make_key("q1", emb.clone()), b"r1".to_vec(), Some(50), 0)
825            .expect("i1");
826        let id2 = mgr
827            .insert_at(make_key("q2", vec![0.0, 1.0]), b"r2".to_vec(), None, 0)
828            .expect("i2");
829        let removed = mgr.expire_ttl(100);
830        assert!(removed.contains(&id1));
831        assert!(!removed.contains(&id2));
832        assert_eq!(mgr.slots.len(), 1);
833    }
834
835    // ── 10. LRU eviction strategy ─────────────────────────────────────────────
836    #[test]
837    fn test_eviction_lru() {
838        let config = ScmCacheConfig {
839            max_entries: 3,
840            max_bytes: 1024 * 1024,
841            semantic_threshold: 0.90,
842            ttl_us: None,
843            strategy: ScmEvictionStrategy::LRU,
844            cluster_radius: 0.15,
845        };
846        let mut mgr = SemanticCacheManager::new(config);
847
848        // Insert 3 entries at different timestamps
849        let id1 = mgr
850            .insert_at(make_key("q1", unit_vec(4, 0)), b"r".to_vec(), None, 100)
851            .expect("i1");
852        let _id2 = mgr
853            .insert_at(make_key("q2", unit_vec(4, 1)), b"r".to_vec(), None, 200)
854            .expect("i2");
855        let _id3 = mgr
856            .insert_at(make_key("q3", unit_vec(4, 2)), b"r".to_vec(), None, 300)
857            .expect("i3");
858
859        // Insert a 4th — must evict id1 (least recently accessed, ts=100)
860        let _id4 = mgr
861            .insert_at(make_key("q4", unit_vec(4, 3)), b"r".to_vec(), None, 400)
862            .expect("i4");
863
864        assert_eq!(mgr.slots.len(), 3);
865        assert!(mgr.get_entry(id1).is_err(), "id1 should have been evicted");
866    }
867
868    // ── 11. LFU eviction strategy ─────────────────────────────────────────────
869    #[test]
870    fn test_eviction_lfu() {
871        let config = ScmCacheConfig {
872            max_entries: 3,
873            max_bytes: 1024 * 1024,
874            semantic_threshold: 0.90,
875            ttl_us: None,
876            strategy: ScmEvictionStrategy::LFU,
877            cluster_radius: 0.15,
878        };
879        let mut mgr = SemanticCacheManager::new(config);
880
881        let id1 = mgr
882            .insert(make_key("rare", unit_vec(4, 0)), b"r".to_vec(), None)
883            .expect("i1");
884        let _id2 = mgr
885            .insert(make_key("common1", unit_vec(4, 1)), b"r".to_vec(), None)
886            .expect("i2");
887        let _id3 = mgr
888            .insert(make_key("common2", unit_vec(4, 2)), b"r".to_vec(), None)
889            .expect("i3");
890
891        // Access id2 and id3 multiple times so id1 has lowest access_count
892        for _ in 0..5 {
893            let _ = mgr.lookup("common1", &unit_vec(4, 1), 0);
894            let _ = mgr.lookup("common2", &unit_vec(4, 2), 0);
895        }
896
897        // Insert 4th — id1 (access_count=0) should be evicted
898        let _id4 = mgr
899            .insert(make_key("new", unit_vec(4, 3)), b"r".to_vec(), None)
900            .expect("i4");
901
902        assert_eq!(mgr.slots.len(), 3);
903        assert!(mgr.get_entry(id1).is_err(), "id1 (LFU) should be evicted");
904    }
905
906    // ── 12. TTLFirst eviction strategy ───────────────────────────────────────
907    #[test]
908    fn test_eviction_ttl_first() {
909        let config = ScmCacheConfig {
910            max_entries: 3,
911            max_bytes: 1024 * 1024,
912            semantic_threshold: 0.90,
913            ttl_us: None,
914            strategy: ScmEvictionStrategy::TTLFirst,
915            cluster_radius: 0.15,
916        };
917        let mut mgr = SemanticCacheManager::new(config);
918
919        let id_short = mgr
920            .insert(
921                make_key("short-ttl", unit_vec(4, 0)),
922                b"r".to_vec(),
923                Some(10),
924            )
925            .expect("short");
926        let _id_long = mgr
927            .insert(
928                make_key("long-ttl", unit_vec(4, 1)),
929                b"r".to_vec(),
930                Some(10_000),
931            )
932            .expect("long");
933        let _id_none = mgr
934            .insert(make_key("no-ttl", unit_vec(4, 2)), b"r".to_vec(), None)
935            .expect("none");
936
937        // Insert 4th — should evict the shortest-TTL entry
938        let _id4 = mgr
939            .insert(make_key("q4", unit_vec(4, 3)), b"r".to_vec(), None)
940            .expect("i4");
941
942        assert_eq!(mgr.slots.len(), 3);
943        assert!(
944            mgr.get_entry(id_short).is_err(),
945            "short-TTL entry should be evicted first"
946        );
947    }
948
949    // ── 13. SemanticCluster eviction strategy ────────────────────────────────
950    #[test]
951    fn test_eviction_semantic_cluster() {
952        let config = ScmCacheConfig {
953            max_entries: 4,
954            max_bytes: 1024 * 1024,
955            semantic_threshold: 0.50,
956            ttl_us: None,
957            strategy: ScmEvictionStrategy::SemanticCluster,
958            cluster_radius: 0.10, // tight radius — group near-identical embeddings
959        };
960        let mut mgr = SemanticCacheManager::new(config);
961
962        // Insert 3 very similar embeddings (cluster A) and 1 distinct one (cluster B)
963        let base = vec![1.0_f64, 0.001, 0.0];
964        mgr.insert(make_key("a1", base.clone()), b"r".to_vec(), None)
965            .expect("a1");
966        mgr.insert(make_key("a2", vec![1.0, 0.002, 0.0]), b"r".to_vec(), None)
967            .expect("a2");
968        mgr.insert(make_key("a3", vec![1.0, 0.003, 0.0]), b"r".to_vec(), None)
969            .expect("a3");
970        mgr.insert(make_key("b1", vec![0.0, 1.0, 0.0]), b"r".to_vec(), None)
971            .expect("b1");
972
973        // Insert a 5th — should evict from the over-represented cluster A
974        mgr.insert(make_key("new", vec![0.0, 0.0, 1.0]), b"r".to_vec(), None)
975            .expect("new");
976
977        assert_eq!(mgr.slots.len(), 4);
978        // Cluster B (size=1) and cluster A (size=3) → eviction from A
979        // Verify at least one of a1/a2/a3 was removed
980        let remaining_a: Vec<_> = mgr
981            .slots
982            .iter()
983            .filter(|s| {
984                let t = &s.entry.key.query_text;
985                t == "a1" || t == "a2" || t == "a3"
986            })
987            .collect();
988        assert!(
989            remaining_a.len() < 3,
990            "One of the cluster-A entries should have been evicted"
991        );
992    }
993
994    // ── 14. HybridScore eviction (weight=1.0 ≈ LRU) ──────────────────────────
995    #[test]
996    fn test_eviction_hybrid_weight_one() {
997        let config = ScmCacheConfig {
998            max_entries: 3,
999            max_bytes: 1024 * 1024,
1000            semantic_threshold: 0.90,
1001            ttl_us: None,
1002            strategy: ScmEvictionStrategy::HybridScore(1.0),
1003            cluster_radius: 0.15,
1004        };
1005        let mut mgr = SemanticCacheManager::new(config);
1006
1007        let id_old = mgr
1008            .insert_at(make_key("old", unit_vec(4, 0)), b"r".to_vec(), None, 1)
1009            .expect("old");
1010        let _id_mid = mgr
1011            .insert_at(make_key("mid", unit_vec(4, 1)), b"r".to_vec(), None, 50)
1012            .expect("mid");
1013        let _id_new = mgr
1014            .insert_at(make_key("new", unit_vec(4, 2)), b"r".to_vec(), None, 100)
1015            .expect("new");
1016
1017        // Insert 4th — HybridScore(1.0) = pure recency → evict oldest
1018        let _id4 = mgr
1019            .insert_at(make_key("q4", unit_vec(4, 3)), b"r".to_vec(), None, 200)
1020            .expect("i4");
1021
1022        assert_eq!(mgr.slots.len(), 3);
1023        assert!(
1024            mgr.get_entry(id_old).is_err(),
1025            "oldest entry should be evicted with weight=1.0"
1026        );
1027    }
1028
1029    // ── 15. HybridScore eviction (weight=0.0 ≈ LFU) ──────────────────────────
1030    #[test]
1031    fn test_eviction_hybrid_weight_zero() {
1032        let config = ScmCacheConfig {
1033            max_entries: 3,
1034            max_bytes: 1024 * 1024,
1035            semantic_threshold: 0.90,
1036            ttl_us: None,
1037            strategy: ScmEvictionStrategy::HybridScore(0.0),
1038            cluster_radius: 0.15,
1039        };
1040        let mut mgr = SemanticCacheManager::new(config);
1041
1042        let id_rare = mgr
1043            .insert(make_key("rare", unit_vec(4, 0)), b"r".to_vec(), None)
1044            .expect("rare");
1045        let _id_freq1 = mgr
1046            .insert(make_key("freq1", unit_vec(4, 1)), b"r".to_vec(), None)
1047            .expect("freq1");
1048        let _id_freq2 = mgr
1049            .insert(make_key("freq2", unit_vec(4, 2)), b"r".to_vec(), None)
1050            .expect("freq2");
1051
1052        // Bump access counts on freq1 and freq2
1053        for _ in 0..10 {
1054            let _ = mgr.lookup("freq1", &unit_vec(4, 1), 0);
1055            let _ = mgr.lookup("freq2", &unit_vec(4, 2), 0);
1056        }
1057
1058        // Insert 4th — pure frequency → evict "rare" (access_count=0)
1059        let _id4 = mgr
1060            .insert(make_key("q4", unit_vec(4, 3)), b"r".to_vec(), None)
1061            .expect("i4");
1062
1063        assert_eq!(mgr.slots.len(), 3);
1064        assert!(
1065            mgr.get_entry(id_rare).is_err(),
1066            "least-frequent entry should be evicted with weight=0.0"
1067        );
1068    }
1069
1070    // ── 16. invalidate removes entry ─────────────────────────────────────────
1071    #[test]
1072    fn test_invalidate() {
1073        let mut mgr = make_mgr();
1074        let id = mgr
1075            .insert(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), None)
1076            .expect("insert");
1077        mgr.invalidate(id).expect("invalidate");
1078        assert!(mgr.get_entry(id).is_err());
1079    }
1080
1081    // ── 17. invalidate unknown ID returns error ───────────────────────────────
1082    #[test]
1083    fn test_invalidate_unknown_id() {
1084        let mut mgr = make_mgr();
1085        let err = mgr.invalidate(999).unwrap_err();
1086        assert_eq!(err, ScmCacheError::EntryNotFound(999));
1087    }
1088
1089    // ── 18. invalidate_similar removes semantically close entries ─────────────
1090    #[test]
1091    fn test_invalidate_similar() {
1092        let mut mgr = make_mgr();
1093        let id1 = mgr
1094            .insert(make_key("a", vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1095            .expect("a");
1096        let id2 = mgr
1097            .insert(make_key("b", vec![0.999, 0.044, 0.0]), b"r".to_vec(), None)
1098            .expect("b");
1099        let id3 = mgr
1100            .insert(make_key("c", vec![0.0, 1.0, 0.0]), b"r".to_vec(), None)
1101            .expect("c");
1102
1103        // Remove entries similar to [1, 0, 0] with threshold 0.95
1104        let removed = mgr.invalidate_similar(&[1.0, 0.0, 0.0], 0.95);
1105        assert!(removed.contains(&id1));
1106        assert!(removed.contains(&id2));
1107        assert!(!removed.contains(&id3));
1108        assert_eq!(mgr.slots.len(), 1);
1109    }
1110
1111    // ── 19. invalidate_similar returns empty when nothing matches ─────────────
1112    #[test]
1113    fn test_invalidate_similar_no_match() {
1114        let mut mgr = make_mgr();
1115        mgr.insert(make_key("a", vec![0.0, 1.0, 0.0]), b"r".to_vec(), None)
1116            .expect("a");
1117        let removed = mgr.invalidate_similar(&[1.0, 0.0, 0.0], 0.99);
1118        assert!(removed.is_empty());
1119    }
1120
1121    // ── 20. semantic_neighbors top-k ─────────────────────────────────────────
1122    #[test]
1123    fn test_semantic_neighbors_top_k() {
1124        let mut mgr = make_mgr();
1125        for i in 0..8 {
1126            let mut emb = vec![0.0_f64; 8];
1127            emb[i] = 1.0;
1128            mgr.insert(make_key(&format!("q{i}"), emb), b"r".to_vec(), None)
1129                .expect("insert");
1130        }
1131        // Query along dimension 0 — unit_vec(8, 0)
1132        let query = unit_vec(8, 0);
1133        let neighbors = mgr.semantic_neighbors(&query, 3);
1134        assert_eq!(neighbors.len(), 3);
1135        // The first neighbor should be the exact match (similarity = 1.0)
1136        assert!((neighbors[0].1 - 1.0).abs() < 1e-9);
1137        // Descending order
1138        for w in neighbors.windows(2) {
1139            assert!(w[0].1 >= w[1].1);
1140        }
1141    }
1142
1143    // ── 21. semantic_neighbors top_k larger than cache ───────────────────────
1144    #[test]
1145    fn test_semantic_neighbors_k_larger_than_cache() {
1146        let mut mgr = make_mgr();
1147        mgr.insert(make_key("only", vec![1.0, 0.0]), b"r".to_vec(), None)
1148            .expect("insert");
1149        let neighbors = mgr.semantic_neighbors(&[1.0, 0.0], 100);
1150        assert_eq!(neighbors.len(), 1);
1151    }
1152
1153    // ── 22. semantic_neighbors on empty cache ─────────────────────────────────
1154    #[test]
1155    fn test_semantic_neighbors_empty() {
1156        let mgr = make_mgr();
1157        let neighbors = mgr.semantic_neighbors(&[1.0, 0.0], 5);
1158        assert!(neighbors.is_empty());
1159    }
1160
1161    // ── 23. cluster_stats on empty cache ─────────────────────────────────────
1162    #[test]
1163    fn test_cluster_stats_empty() {
1164        let mgr = make_mgr();
1165        let stats = mgr.cluster_stats();
1166        assert!(stats.is_empty());
1167    }
1168
1169    // ── 24. cluster_stats single entry ───────────────────────────────────────
1170    #[test]
1171    fn test_cluster_stats_single() {
1172        let mut mgr = make_mgr();
1173        mgr.insert(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), None)
1174            .expect("insert");
1175        let stats = mgr.cluster_stats();
1176        assert_eq!(stats.len(), 1);
1177        assert_eq!(stats[0].1, 1); // cluster size = 1
1178    }
1179
1180    // ── 25. cluster_stats two clusters ───────────────────────────────────────
1181    #[test]
1182    fn test_cluster_stats_two_clusters() {
1183        let config = ScmCacheConfig {
1184            cluster_radius: 0.05, // very tight → orthogonal vectors form separate clusters
1185            ..default_config()
1186        };
1187        let mut mgr = SemanticCacheManager::new(config);
1188        // Cluster A: embeddings near [1, 0, 0]
1189        mgr.insert(make_key("a1", vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1190            .expect("a1");
1191        mgr.insert(make_key("a2", vec![1.0, 0.001, 0.0]), b"r".to_vec(), None)
1192            .expect("a2");
1193        // Cluster B: embeddings near [0, 1, 0]
1194        mgr.insert(make_key("b1", vec![0.0, 1.0, 0.0]), b"r".to_vec(), None)
1195            .expect("b1");
1196        mgr.insert(make_key("b2", vec![0.001, 1.0, 0.0]), b"r".to_vec(), None)
1197            .expect("b2");
1198
1199        let stats = mgr.cluster_stats();
1200        // Expect two clusters, each of size 2
1201        assert_eq!(stats.len(), 2);
1202        for (_, size) in &stats {
1203            assert_eq!(*size, 2);
1204        }
1205    }
1206
1207    // ── 26. cluster_stats intra-cluster similarity ────────────────────────────
1208    #[test]
1209    fn test_cluster_stats_similarity_bound() {
1210        let mut mgr = SemanticCacheManager::new(ScmCacheConfig {
1211            cluster_radius: 0.20,
1212            ..default_config()
1213        });
1214        mgr.insert(make_key("x", vec![1.0, 0.0]), b"r".to_vec(), None)
1215            .expect("x");
1216        let stats = mgr.cluster_stats();
1217        assert!(!stats.is_empty());
1218        for (sim, _) in &stats {
1219            assert!(*sim >= -1.0 && *sim <= 1.0);
1220        }
1221    }
1222
1223    // ── 27. dimension mismatch on insert ─────────────────────────────────────
1224    #[test]
1225    fn test_dimension_mismatch_insert() {
1226        let mut mgr = make_mgr();
1227        mgr.insert(make_key("q1", vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1228            .expect("first insert");
1229        let err = mgr
1230            .insert(make_key("q2", vec![1.0, 0.0]), b"r".to_vec(), None)
1231            .unwrap_err();
1232        assert!(matches!(
1233            err,
1234            ScmCacheError::DimensionMismatch {
1235                expected: 3,
1236                got: 2
1237            }
1238        ));
1239    }
1240
1241    // ── 28. dimension mismatch on lookup ─────────────────────────────────────
1242    #[test]
1243    fn test_dimension_mismatch_lookup() {
1244        let mut mgr = make_mgr();
1245        mgr.insert(make_key("q1", vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1246            .expect("insert");
1247        let err = mgr.lookup("q2", &[1.0, 0.0], 0).unwrap_err();
1248        assert!(matches!(
1249            err,
1250            ScmCacheError::DimensionMismatch {
1251                expected: 3,
1252                got: 2
1253            }
1254        ));
1255    }
1256
1257    // ── 29. capacity enforcement (entry count) ───────────────────────────────
1258    #[test]
1259    fn test_capacity_entry_count() {
1260        let config = ScmCacheConfig {
1261            max_entries: 5,
1262            max_bytes: 1024 * 1024,
1263            semantic_threshold: 0.90,
1264            ttl_us: None,
1265            strategy: ScmEvictionStrategy::LRU,
1266            cluster_radius: 0.15,
1267        };
1268        let mut mgr = SemanticCacheManager::new(config);
1269        for i in 0..10usize {
1270            let mut emb = vec![0.0; 10];
1271            emb[i % 10] = 1.0;
1272            mgr.insert(make_key(&format!("q{i}"), emb), b"r".to_vec(), None)
1273                .expect("insert");
1274        }
1275        assert!(mgr.slots.len() <= 5);
1276    }
1277
1278    // ── 30. capacity enforcement (byte budget) ───────────────────────────────
1279    #[test]
1280    fn test_capacity_bytes() {
1281        let config = ScmCacheConfig {
1282            max_entries: 1000,
1283            max_bytes: 512, // very small byte budget
1284            semantic_threshold: 0.90,
1285            ttl_us: None,
1286            strategy: ScmEvictionStrategy::LRU,
1287            cluster_radius: 0.15,
1288        };
1289        let mut mgr = SemanticCacheManager::new(config);
1290        // Insert entries that are deliberately small
1291        for i in 0..20usize {
1292            let mut emb = vec![0.0; 2];
1293            emb[i % 2] = 1.0;
1294            let _ = mgr.insert(make_key(&format!("q{i}"), emb), b"r".to_vec(), None);
1295        }
1296        assert!(mgr.current_bytes() <= 512 + 512); // some tolerance for last inserted
1297    }
1298
1299    // ── 31. stats: exact hits counter ────────────────────────────────────────
1300    #[test]
1301    fn test_stats_exact_hits() {
1302        let mut mgr = make_mgr();
1303        let emb = vec![1.0, 0.0];
1304        let id = mgr
1305            .insert(make_key("q", emb.clone()), b"r".to_vec(), None)
1306            .expect("insert");
1307        for _ in 0..3 {
1308            let hit = mgr.lookup("q", &emb, 0).expect("lookup");
1309            assert_eq!(hit, ScmCacheHit::Exact { entry_id: id });
1310        }
1311        let s = mgr.stats();
1312        assert_eq!(s.exact_hits, 3);
1313        assert_eq!(s.semantic_hits, 0);
1314        assert_eq!(s.misses, 0);
1315    }
1316
1317    // ── 32. stats: semantic hits counter ─────────────────────────────────────
1318    #[test]
1319    fn test_stats_semantic_hits() {
1320        let mut mgr = make_mgr();
1321        mgr.insert(
1322            make_key("original", vec![1.0, 0.0, 0.0]),
1323            b"r".to_vec(),
1324            None,
1325        )
1326        .expect("insert");
1327        // Close enough to trigger semantic hit but not exact text
1328        let _ = mgr.lookup("different text", &[0.999, 0.044, 0.0], 0);
1329        let s = mgr.stats();
1330        assert_eq!(s.semantic_hits, 1);
1331        assert!(s.avg_similarity_on_semantic_hit > 0.0);
1332    }
1333
1334    // ── 33. stats: miss counter ───────────────────────────────────────────────
1335    #[test]
1336    fn test_stats_misses() {
1337        let mut mgr = make_mgr();
1338        mgr.insert(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), None)
1339            .expect("insert");
1340        let _ = mgr.lookup("totally different", &[0.0, 1.0], 0);
1341        let s = mgr.stats();
1342        assert_eq!(s.misses, 1);
1343    }
1344
1345    // ── 34. stats: evictions counter ─────────────────────────────────────────
1346    #[test]
1347    fn test_stats_evictions() {
1348        let config = ScmCacheConfig {
1349            max_entries: 2,
1350            ..default_config()
1351        };
1352        let mut mgr = SemanticCacheManager::new(config);
1353        mgr.insert(make_key("a", unit_vec(3, 0)), b"r".to_vec(), None)
1354            .expect("a");
1355        mgr.insert(make_key("b", unit_vec(3, 1)), b"r".to_vec(), None)
1356            .expect("b");
1357        mgr.insert(make_key("c", unit_vec(3, 2)), b"r".to_vec(), None)
1358            .expect("c");
1359        assert_eq!(mgr.stats().evictions, 1);
1360    }
1361
1362    // ── 35. stats: total_insertions ──────────────────────────────────────────
1363    #[test]
1364    fn test_stats_total_insertions() {
1365        let mut mgr = make_mgr();
1366        for i in 0..5usize {
1367            let mut e = vec![0.0; 5];
1368            e[i] = 1.0;
1369            mgr.insert(make_key(&format!("q{i}"), e), b"r".to_vec(), None)
1370                .expect("insert");
1371        }
1372        assert_eq!(mgr.stats().total_insertions, 5);
1373    }
1374
1375    // ── 36. stats: semantic_hit_rate ─────────────────────────────────────────
1376    #[test]
1377    fn test_stats_semantic_hit_rate() {
1378        let mut mgr = make_mgr();
1379        mgr.insert(make_key("q", vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1380            .expect("insert");
1381        // 1 semantic hit, 1 miss
1382        let _ = mgr.lookup("close", &[0.999, 0.044, 0.0], 0);
1383        let _ = mgr.lookup("far", &[0.0, 1.0, 0.0], 0);
1384        let s = mgr.stats();
1385        // semantic_hit_rate = 1 / (0 + 1 + 1) = 0.5
1386        assert!((s.semantic_hit_rate - 0.5).abs() < 1e-9);
1387    }
1388
1389    // ── 37. stats: avg_similarity_on_semantic_hit ────────────────────────────
1390    #[test]
1391    fn test_stats_avg_similarity() {
1392        let mut mgr = make_mgr();
1393        mgr.insert(make_key("q", vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1394            .expect("insert");
1395        // Two semantic hits
1396        let _ = mgr.lookup("close1", &[0.999, 0.044, 0.0], 0);
1397        let _ = mgr.lookup("close2", &[0.999, 0.044, 0.0], 0);
1398        let s = mgr.stats();
1399        assert!(s.avg_similarity_on_semantic_hit > 0.0);
1400        assert!(s.avg_similarity_on_semantic_hit <= 1.0);
1401    }
1402
1403    // ── 38. get_entry returns correct payload ─────────────────────────────────
1404    #[test]
1405    fn test_get_entry() {
1406        let mut mgr = make_mgr();
1407        let payload = b"important data".to_vec();
1408        let id = mgr
1409            .insert(make_key("q", vec![1.0, 0.0]), payload.clone(), None)
1410            .expect("insert");
1411        let entry = mgr.get_entry(id).expect("get_entry");
1412        assert_eq!(entry.result, payload);
1413    }
1414
1415    // ── 39. get_entry unknown ID returns error ────────────────────────────────
1416    #[test]
1417    fn test_get_entry_not_found() {
1418        let mgr = make_mgr();
1419        assert!(matches!(
1420            mgr.get_entry(42),
1421            Err(ScmCacheError::EntryNotFound(42))
1422        ));
1423    }
1424
1425    // ── 40. access_count updated on hit ──────────────────────────────────────
1426    #[test]
1427    fn test_access_count_updated() {
1428        let mut mgr = make_mgr();
1429        let emb = vec![1.0, 0.0];
1430        let id = mgr
1431            .insert(make_key("q", emb.clone()), b"r".to_vec(), None)
1432            .expect("insert");
1433        assert_eq!(mgr.get_entry(id).expect("entry").access_count, 0);
1434        let _ = mgr.lookup("q", &emb, 0);
1435        let _ = mgr.lookup("q", &emb, 1);
1436        assert_eq!(mgr.get_entry(id).expect("entry").access_count, 2);
1437    }
1438
1439    // ── 41. last_accessed updated on hit ─────────────────────────────────────
1440    #[test]
1441    fn test_last_accessed_updated() {
1442        let mut mgr = make_mgr();
1443        let emb = vec![1.0, 0.0];
1444        let id = mgr
1445            .insert(make_key("q", emb.clone()), b"r".to_vec(), None)
1446            .expect("insert");
1447        let _ = mgr.lookup("q", &emb, 999);
1448        assert_eq!(mgr.get_entry(id).expect("entry").last_accessed, 999);
1449    }
1450
1451    // ── 42. entry_too_large error ─────────────────────────────────────────────
1452    #[test]
1453    fn test_entry_too_large() {
1454        let config = ScmCacheConfig {
1455            max_bytes: 10, // tiny
1456            ..default_config()
1457        };
1458        let mut mgr = SemanticCacheManager::new(config);
1459        let err = mgr
1460            .insert(
1461                make_key("q", vec![1.0]),
1462                vec![0u8; 200], // payload alone > 10 bytes
1463                None,
1464            )
1465            .unwrap_err();
1466        assert!(matches!(err, ScmCacheError::EntryTooLarge(_)));
1467    }
1468
1469    // ── 43. evict_to_fit returns evicted IDs ─────────────────────────────────
1470    #[test]
1471    fn test_evict_to_fit_returns_ids() {
1472        // Use a tiny byte budget so that asking for even a small amount forces eviction.
1473        let config = ScmCacheConfig {
1474            max_entries: 100,
1475            max_bytes: 512, // small enough that 5 entries exhaust it
1476            ..default_config()
1477        };
1478        let mut mgr = SemanticCacheManager::new(config);
1479        for i in 0..5usize {
1480            let mut e = vec![0.0; 5];
1481            e[i] = 1.0;
1482            let _ = mgr.insert(make_key(&format!("q{i}"), e), b"r".to_vec(), None);
1483        }
1484        // Now request budget that exceeds max_bytes to force eviction
1485        let removed = mgr.evict_to_fit(mgr.config.max_bytes + 1);
1486        assert!(!removed.is_empty());
1487    }
1488
1489    // ── 44. evict_to_fit no-op when space is available ───────────────────────
1490    #[test]
1491    fn test_evict_to_fit_noop() {
1492        let mut mgr = make_mgr();
1493        mgr.insert(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), None)
1494            .expect("insert");
1495        let removed = mgr.evict_to_fit(0);
1496        assert!(removed.is_empty());
1497    }
1498
1499    // ── 45. FNV-1a hash consistency ──────────────────────────────────────────
1500    #[test]
1501    fn test_fnv1a_consistency() {
1502        let h1 = fnv1a_64(b"hello world");
1503        let h2 = fnv1a_64(b"hello world");
1504        assert_eq!(h1, h2);
1505        let h3 = fnv1a_64(b"Hello World");
1506        assert_ne!(h1, h3);
1507    }
1508
1509    // ── 46. cosine_similarity edge cases ─────────────────────────────────────
1510    #[test]
1511    fn test_cosine_similarity_edge_cases() {
1512        assert_eq!(cosine_similarity(&[], &[]), 0.0);
1513        assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), 0.0); // zero vector
1514        assert!((cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-9);
1515        assert!((cosine_similarity(&[1.0, 0.0], &[0.0, 1.0])).abs() < 1e-9);
1516        // Mismatched lengths
1517        assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]), 0.0);
1518    }
1519
1520    // ── 47. xorshift64 produces distinct values ───────────────────────────────
1521    #[test]
1522    fn test_xorshift64_distinct() {
1523        let mut state = 0x1234_5678_9ABC_DEF0u64;
1524        let v1 = xorshift64(&mut state);
1525        let v2 = xorshift64(&mut state);
1526        assert_ne!(v1, v2);
1527    }
1528
1529    // ── 48. ScmCacheKey::new computes hash from text ──────────────────────────
1530    #[test]
1531    fn test_cache_key_hash() {
1532        let k = ScmCacheKey::new("test".to_string(), vec![]);
1533        assert_eq!(k.query_hash, fnv1a_64(b"test"));
1534    }
1535
1536    // ── 49. ScmCacheEntry::is_expired semantics ───────────────────────────────
1537    #[test]
1538    fn test_cache_entry_is_expired() {
1539        let key = ScmCacheKey::new("q".to_string(), vec![1.0]);
1540        let entry = ScmCacheEntry {
1541            key,
1542            result: vec![],
1543            inserted_at: 0,
1544            last_accessed: 0,
1545            access_count: 0,
1546            ttl_us: Some(100),
1547            similarity_score: 1.0,
1548        };
1549        assert!(!entry.is_expired(99));
1550        assert!(entry.is_expired(100));
1551        assert!(entry.is_expired(200));
1552    }
1553
1554    // ── 50. ScmCacheEntry without TTL never expires ───────────────────────────
1555    #[test]
1556    fn test_cache_entry_no_ttl() {
1557        let key = ScmCacheKey::new("q".to_string(), vec![]);
1558        let entry = ScmCacheEntry {
1559            key,
1560            result: vec![],
1561            inserted_at: 0,
1562            last_accessed: 0,
1563            access_count: 0,
1564            ttl_us: None,
1565            similarity_score: 1.0,
1566        };
1567        assert!(!entry.is_expired(u64::MAX));
1568    }
1569
1570    // ── 51. multiple inserts, semantic search picks best ─────────────────────
1571    #[test]
1572    fn test_semantic_best_match() {
1573        let mut mgr = make_mgr();
1574        // Insert two entries with different similarities to [1, 0, 0]
1575        mgr.insert(
1576            make_key("good", vec![0.98, 0.2, 0.0]),
1577            b"good".to_vec(),
1578            None,
1579        )
1580        .expect("good");
1581        mgr.insert(make_key("ok", vec![0.92, 0.4, 0.0]), b"ok".to_vec(), None)
1582            .expect("ok");
1583        // Query: [1, 0, 0] — "good" should be the best semantic hit
1584        let hit = mgr.lookup("query", &[1.0, 0.0, 0.0], 0).expect("lookup");
1585        match hit {
1586            ScmCacheHit::Semantic { original_query, .. } => {
1587                assert_eq!(original_query, "good");
1588            }
1589            other => panic!("expected Semantic hit, got {:?}", other),
1590        }
1591    }
1592
1593    // ── 52. uniform embeddings produce nonzero similarity ─────────────────────
1594    #[test]
1595    fn test_uniform_embeddings_similarity() {
1596        let a = uniform_vec(64, 0.5);
1597        let b = uniform_vec(64, 0.3);
1598        // Both are constant vectors — cosine similarity = 1.0
1599        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
1600    }
1601
1602    // ── 53. evict_to_fit respects byte budget ────────────────────────────────
1603    #[test]
1604    fn test_evict_to_fit_respects_bytes() {
1605        let config = ScmCacheConfig {
1606            max_entries: 1000,
1607            max_bytes: 2048,
1608            ..default_config()
1609        };
1610        let mut mgr = SemanticCacheManager::new(config);
1611        for i in 0..10usize {
1612            let mut e = vec![0.0; 2];
1613            e[i % 2] = 1.0;
1614            let _ = mgr.insert(make_key(&format!("q{i}"), e), vec![0u8; 50], None);
1615        }
1616        let current = mgr.current_bytes();
1617        // Force removal of enough entries to free ~half the space
1618        let freed_target = current / 2;
1619        let removed = mgr.evict_to_fit(freed_target + mgr.config.max_bytes);
1620        assert!(!removed.is_empty());
1621    }
1622
1623    // ── 54. default config is usable ─────────────────────────────────────────
1624    #[test]
1625    fn test_default_config() {
1626        let config = ScmCacheConfig::default();
1627        let mut mgr = SemanticCacheManager::new(config);
1628        let id = mgr
1629            .insert(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), None)
1630            .expect("insert");
1631        assert!(id >= 1);
1632    }
1633
1634    // ── 55. ScmCacheError Display ─────────────────────────────────────────────
1635    #[test]
1636    fn test_cache_error_display() {
1637        assert!(ScmCacheError::EntryTooLarge(100)
1638            .to_string()
1639            .contains("100"));
1640        assert!(ScmCacheError::CacheAtCapacity
1641            .to_string()
1642            .contains("capacity"));
1643        assert!(ScmCacheError::EntryNotFound(7).to_string().contains("7"));
1644        assert!(ScmCacheError::DimensionMismatch {
1645            expected: 3,
1646            got: 2
1647        }
1648        .to_string()
1649        .contains("mismatch"));
1650        assert!(ScmCacheError::TtlExpired(5).to_string().contains("5"));
1651    }
1652
1653    // ── 56. semantic hit updates original_query field ─────────────────────────
1654    #[test]
1655    fn test_semantic_hit_original_query() {
1656        let mut mgr = make_mgr();
1657        let original = "the original query text";
1658        mgr.insert(make_key(original, vec![1.0, 0.0, 0.0]), b"r".to_vec(), None)
1659            .expect("insert");
1660        let hit = mgr
1661            .lookup("a different phrasing", &[0.999, 0.044, 0.0], 0)
1662            .expect("lookup");
1663        match hit {
1664            ScmCacheHit::Semantic { original_query, .. } => {
1665                assert_eq!(original_query, original);
1666            }
1667            other => panic!("expected Semantic, got {:?}", other),
1668        }
1669    }
1670
1671    // ── 57. large-scale stress: 1000 inserts under capacity ──────────────────
1672    #[test]
1673    fn test_large_scale_capacity() {
1674        let config = ScmCacheConfig {
1675            max_entries: 100,
1676            max_bytes: 64 * 1024 * 1024,
1677            semantic_threshold: 0.90,
1678            ttl_us: None,
1679            strategy: ScmEvictionStrategy::LRU,
1680            cluster_radius: 0.20,
1681        };
1682        let mut mgr = SemanticCacheManager::new(config);
1683        let mut state = 0xFEED_FACE_DEAD_BEEF_u64;
1684        for i in 0..1000usize {
1685            let dim = 16;
1686            let emb: Vec<f64> = (0..dim)
1687                .map(|_| (xorshift64(&mut state) >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0)
1688                .collect();
1689            let _ = mgr.insert(make_key(&format!("stress-{i}"), emb), vec![0u8; 32], None);
1690        }
1691        assert!(mgr.slots.len() <= 100);
1692    }
1693
1694    // ── 58. insert with explicit TTL overrides config TTL = None ─────────────
1695    #[test]
1696    fn test_insert_explicit_ttl() {
1697        let mut mgr = make_mgr(); // config.ttl_us = None
1698        let id = mgr
1699            .insert_at(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), Some(50), 0)
1700            .expect("insert");
1701        let entry = mgr.get_entry(id).expect("get");
1702        assert_eq!(entry.ttl_us, Some(50));
1703    }
1704
1705    // ── 59. insert inherits config TTL when None given ────────────────────────
1706    #[test]
1707    fn test_insert_inherits_config_ttl() {
1708        let config = ScmCacheConfig {
1709            ttl_us: Some(1000),
1710            ..default_config()
1711        };
1712        let mut mgr = SemanticCacheManager::new(config);
1713        let id = mgr
1714            .insert(make_key("q", vec![1.0, 0.0]), b"r".to_vec(), None)
1715            .expect("insert");
1716        let entry = mgr.get_entry(id).expect("get");
1717        assert_eq!(entry.ttl_us, Some(1000));
1718    }
1719}