Skip to main content

zeph_memory/
response_cache.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! SQLite-backed response cache with TTL expiry.
5//!
6//! Caches LLM responses keyed by a content hash so identical prompts within
7//! the TTL window are served from the database without an API round-trip.
8
9use zeph_db::DbPool;
10#[allow(unused_imports)]
11use zeph_db::sql;
12
13use crate::error::MemoryError;
14
15/// SQLite-backed cache for LLM responses.
16///
17/// Entries expire after `ttl_secs` seconds.  The `cache_key` is typically a
18/// BLAKE3 hash of the serialized prompt so that structurally identical requests
19/// are deduplicated across sessions.
20///
21/// # Examples
22///
23/// ```rust,no_run
24/// # async fn example(pool: zeph_db::DbPool) -> Result<(), zeph_memory::MemoryError> {
25/// use zeph_memory::ResponseCache;
26///
27/// let cache = ResponseCache::new(pool, 3600);
28/// cache.put("key123", "The answer is 42", "gpt-4o-mini").await?;
29/// let hit = cache.get("key123").await?;
30/// assert!(hit.is_some());
31/// # Ok(())
32/// # }
33/// ```
34pub struct ResponseCache {
35    pool: DbPool,
36    ttl_secs: u64,
37}
38
39impl ResponseCache {
40    #[must_use]
41    pub fn new(pool: DbPool, ttl_secs: u64) -> Self {
42        Self { pool, ttl_secs }
43    }
44
45    /// Look up a cached response by key. Returns `None` if not found or expired.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if the database query fails.
50    #[tracing::instrument(name = "memory.cache.get", skip_all, fields(key = %key))]
51    pub async fn get(&self, key: &str) -> Result<Option<String>, MemoryError> {
52        let now = unix_now();
53        let row: Option<(String,)> = zeph_db::query_as(sql!(
54            "SELECT response FROM response_cache WHERE cache_key = ? AND expires_at > ?"
55        ))
56        .bind(key)
57        .bind(now)
58        .fetch_optional(&self.pool)
59        .await?;
60        Ok(row.map(|(r,)| r))
61    }
62
63    /// Store a response in the cache with TTL.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the database insert fails.
68    #[tracing::instrument(name = "memory.cache.put", skip_all, fields(key = %key, model = %model))]
69    pub async fn put(&self, key: &str, response: &str, model: &str) -> Result<(), MemoryError> {
70        let now = unix_now();
71        // Cap TTL at 1 year (31_536_000 s) to prevent i64 overflow for extreme values.
72        let expires_at = now.saturating_add(self.ttl_secs.min(31_536_000).cast_signed());
73        zeph_db::query(sql!(
74            "INSERT INTO response_cache (cache_key, response, model, created_at, expires_at) \
75             VALUES (?, ?, ?, ?, ?) \
76             ON CONFLICT(cache_key) DO UPDATE SET \
77               response = excluded.response, model = excluded.model, \
78               created_at = excluded.created_at, expires_at = excluded.expires_at"
79        ))
80        .bind(key)
81        .bind(response)
82        .bind(model)
83        .bind(now)
84        .bind(expires_at)
85        .execute(&self.pool)
86        .await?;
87        Ok(())
88    }
89
90    /// Semantic similarity-based cache lookup.
91    ///
92    /// Fetches up to `max_candidates` non-expired rows with matching `embedding_model`,
93    /// deserializes each embedding, computes cosine similarity against the query vector,
94    /// and returns the response with the highest score if it meets `similarity_threshold`.
95    ///
96    /// Returns `(response_text, score)` on hit, `None` on miss.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if the database query fails.
101    #[tracing::instrument(name = "memory.cache.get_semantic", skip_all, fields(model = %embedding_model, threshold = %similarity_threshold, max_candidates = %max_candidates))]
102    pub async fn get_semantic(
103        &self,
104        embedding: &[f32],
105        embedding_model: &str,
106        similarity_threshold: f32,
107        max_candidates: u32,
108    ) -> Result<Option<(String, f32)>, MemoryError> {
109        let now = unix_now();
110        let rows: Vec<(String, Vec<u8>)> = zeph_db::query_as(sql!(
111            "SELECT response, embedding FROM response_cache \
112             WHERE embedding_model = ? AND embedding IS NOT NULL AND expires_at > ? \
113             ORDER BY embedding_ts DESC LIMIT ?"
114        ))
115        .bind(embedding_model)
116        .bind(now)
117        .bind(i64::from(max_candidates))
118        .fetch_all(&self.pool)
119        .await?;
120
121        let mut best_score = -1.0_f32;
122        let mut best_response: Option<String> = None;
123
124        for (response, blob) in &rows {
125            if blob.len() % 4 != 0 {
126                continue;
127            }
128            let stored: Vec<f32> = blob
129                .chunks_exact(4)
130                .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
131                .collect();
132            let score = zeph_common::math::cosine_similarity(embedding, &stored);
133            tracing::debug!(
134                score,
135                threshold = similarity_threshold,
136                "semantic cache candidate evaluated",
137            );
138            if score > best_score {
139                best_score = score;
140                best_response = Some(response.clone());
141            }
142        }
143
144        tracing::debug!(
145            examined = rows.len(),
146            best_score,
147            threshold = similarity_threshold,
148            hit = best_score >= similarity_threshold,
149            "semantic cache scan complete",
150        );
151
152        if best_score >= similarity_threshold {
153            Ok(best_response.map(|r| (r, best_score)))
154        } else {
155            Ok(None)
156        }
157    }
158
159    /// Store a response with an embedding vector for future semantic matching.
160    ///
161    /// Uses `INSERT OR REPLACE` — updates the embedding on existing rows.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the database insert fails.
166    #[tracing::instrument(name = "memory.cache.put_with_embedding", skip_all, fields(key = %key, model = %model, embedding_model = %embedding_model))]
167    pub async fn put_with_embedding(
168        &self,
169        key: &str,
170        response: &str,
171        model: &str,
172        embedding: &[f32],
173        embedding_model: &str,
174    ) -> Result<(), MemoryError> {
175        let now = unix_now();
176        let expires_at = now.saturating_add(self.ttl_secs.min(31_536_000).cast_signed());
177        let blob: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
178        zeph_db::query(
179            sql!("INSERT INTO response_cache \
180             (cache_key, response, model, created_at, expires_at, embedding, embedding_model, embedding_ts) \
181             VALUES (?, ?, ?, ?, ?, ?, ?, ?) \
182             ON CONFLICT(cache_key) DO UPDATE SET \
183               response = excluded.response, model = excluded.model, \
184               created_at = excluded.created_at, expires_at = excluded.expires_at, \
185               embedding = excluded.embedding, embedding_model = excluded.embedding_model, \
186               embedding_ts = excluded.embedding_ts"),
187        )
188        .bind(key)
189        .bind(response)
190        .bind(model)
191        .bind(now)
192        .bind(expires_at)
193        .bind(blob)
194        .bind(embedding_model)
195        .bind(now)
196        .execute(&self.pool)
197        .await?;
198        Ok(())
199    }
200
201    /// Set `embedding = NULL` for all rows with the given `embedding_model`.
202    ///
203    /// Called when the embedding model changes to prevent cross-model false hits.
204    /// Returns the number of rows updated.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if the database update fails.
209    #[tracing::instrument(name = "memory.cache.invalidate_embeddings", skip_all, fields(model = %old_model))]
210    pub async fn invalidate_embeddings_for_model(
211        &self,
212        old_model: &str,
213    ) -> Result<u64, MemoryError> {
214        let result = zeph_db::query(sql!(
215            "UPDATE response_cache \
216             SET embedding = NULL, embedding_model = NULL, embedding_ts = NULL \
217             WHERE embedding_model = ?"
218        ))
219        .bind(old_model)
220        .execute(&self.pool)
221        .await?;
222        Ok(result.rows_affected())
223    }
224
225    /// Two-phase cleanup: delete expired rows, then NULL-ify stale embeddings.
226    ///
227    /// Phase 1: DELETE rows where `expires_at <= now`.
228    /// Phase 2: UPDATE rows where `embedding_model != current_embedding_model` to NULL out
229    ///          the embedding columns. Exact-match data (`cache_key`, `response`) is preserved.
230    ///
231    /// Returns the total number of rows affected (deleted + updated).
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if either database operation fails.
236    #[tracing::instrument(name = "memory.cache.cleanup", skip_all, fields(current_model = %current_embedding_model))]
237    pub async fn cleanup(&self, current_embedding_model: &str) -> Result<u64, MemoryError> {
238        let now = unix_now();
239        let deleted = zeph_db::query(sql!("DELETE FROM response_cache WHERE expires_at <= ?"))
240            .bind(now)
241            .execute(&self.pool)
242            .await?
243            .rows_affected();
244
245        let updated = zeph_db::query(sql!(
246            "UPDATE response_cache \
247             SET embedding = NULL, embedding_model = NULL, embedding_ts = NULL \
248             WHERE embedding IS NOT NULL AND embedding_model != ?"
249        ))
250        .bind(current_embedding_model)
251        .execute(&self.pool)
252        .await?
253        .rows_affected();
254
255        Ok(deleted + updated)
256    }
257
258    /// Delete expired cache entries. Returns the number of rows deleted.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the database delete fails.
263    #[tracing::instrument(name = "memory.cache.cleanup_expired", skip_all)]
264    pub async fn cleanup_expired(&self) -> Result<u64, MemoryError> {
265        let now = unix_now();
266        let result = zeph_db::query(sql!("DELETE FROM response_cache WHERE expires_at <= ?"))
267            .bind(now)
268            .execute(&self.pool)
269            .await?;
270        Ok(result.rows_affected())
271    }
272
273    /// Compute a deterministic cache key from the last user message and model name using blake3.
274    ///
275    /// The key intentionally ignores conversation history so that identical user messages
276    /// produce cache hits regardless of what preceded them. This is the desired behavior for
277    /// a short-TTL response cache, but it means context-dependent questions (e.g. "Explain
278    /// this") may return a cached response from a different context. The TTL bounds staleness.
279    #[must_use]
280    pub fn compute_key(last_user_message: &str, model: &str) -> String {
281        let mut hasher = blake3::Hasher::new();
282        let content = last_user_message.as_bytes();
283        hasher.update(&(content.len() as u64).to_le_bytes());
284        hasher.update(content);
285        let model_bytes = model.as_bytes();
286        hasher.update(&(model_bytes.len() as u64).to_le_bytes());
287        hasher.update(model_bytes);
288        hasher.finalize().to_hex().to_string()
289    }
290}
291
292fn unix_now() -> i64 {
293    std::time::SystemTime::now()
294        .duration_since(std::time::UNIX_EPOCH)
295        .unwrap_or_default()
296        .as_secs()
297        .cast_signed()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::store::SqliteStore;
304
305    async fn test_cache() -> ResponseCache {
306        let store = SqliteStore::new(":memory:").await.unwrap();
307        ResponseCache::new(store.pool().clone(), 3600)
308    }
309
310    #[tokio::test]
311    async fn cache_miss_returns_none() {
312        let cache = test_cache().await;
313        let result = cache.get("nonexistent").await.unwrap();
314        assert!(result.is_none());
315    }
316
317    #[tokio::test]
318    async fn cache_put_and_get_roundtrip() {
319        let cache = test_cache().await;
320        cache.put("key1", "response text", "gpt-4").await.unwrap();
321        let result = cache.get("key1").await.unwrap();
322        assert_eq!(result.as_deref(), Some("response text"));
323    }
324
325    #[tokio::test]
326    async fn cache_expired_entry_returns_none() {
327        let store = SqliteStore::new(":memory:").await.unwrap();
328        let cache = ResponseCache::new(store.pool().clone(), 0);
329        // ttl=0 means expires_at == now, which fails the > check
330        cache.put("key1", "response", "model").await.unwrap();
331        // Immediately expired (expires_at = now + 0 = now, query checks > now)
332        let result = cache.get("key1").await.unwrap();
333        assert!(result.is_none());
334    }
335
336    #[tokio::test]
337    async fn cleanup_expired_removes_entries() {
338        let store = SqliteStore::new(":memory:").await.unwrap();
339        let cache = ResponseCache::new(store.pool().clone(), 0);
340        cache.put("key1", "response", "model").await.unwrap();
341        let deleted = cache.cleanup_expired().await.unwrap();
342        assert!(deleted > 0);
343    }
344
345    #[tokio::test]
346    async fn cleanup_does_not_remove_valid_entries() {
347        let cache = test_cache().await;
348        cache.put("key1", "response", "model").await.unwrap();
349        let deleted = cache.cleanup_expired().await.unwrap();
350        assert_eq!(deleted, 0);
351        let result = cache.get("key1").await.unwrap();
352        assert!(result.is_some());
353    }
354
355    #[test]
356    fn compute_key_deterministic() {
357        let k1 = ResponseCache::compute_key("hello", "gpt-4");
358        let k2 = ResponseCache::compute_key("hello", "gpt-4");
359        assert_eq!(k1, k2);
360    }
361
362    #[test]
363    fn compute_key_different_for_different_content() {
364        assert_ne!(
365            ResponseCache::compute_key("hello", "gpt-4"),
366            ResponseCache::compute_key("world", "gpt-4")
367        );
368    }
369
370    #[test]
371    fn compute_key_different_for_different_model() {
372        assert_ne!(
373            ResponseCache::compute_key("hello", "gpt-4"),
374            ResponseCache::compute_key("hello", "gpt-3.5")
375        );
376    }
377
378    #[test]
379    fn compute_key_empty_message() {
380        let k = ResponseCache::compute_key("", "model");
381        assert!(!k.is_empty());
382    }
383
384    #[tokio::test]
385    async fn ttl_extreme_value_does_not_overflow() {
386        let store = SqliteStore::new(":memory:").await.unwrap();
387        // Use u64::MAX - 1 as TTL; without capping this would overflow i64.
388        let cache = ResponseCache::new(store.pool().clone(), u64::MAX - 1);
389        // Should not panic or produce a negative expires_at.
390        cache.put("key1", "response", "model").await.unwrap();
391        // Entry should be retrievable (far-future expiry).
392        let result = cache.get("key1").await.unwrap();
393        assert_eq!(result.as_deref(), Some("response"));
394    }
395
396    #[tokio::test]
397    async fn insert_or_replace_updates_existing_entry() {
398        let cache = test_cache().await;
399        cache.put("key1", "first response", "gpt-4").await.unwrap();
400        cache.put("key1", "second response", "gpt-4").await.unwrap();
401        let result = cache.get("key1").await.unwrap();
402        assert_eq!(result.as_deref(), Some("second response"));
403    }
404
405    // --- Semantic cache tests ---
406
407    #[tokio::test]
408    async fn test_semantic_get_empty_cache() {
409        let cache = test_cache().await;
410        let result = cache
411            .get_semantic(&[1.0, 0.0], "model-a", 0.9, 10)
412            .await
413            .unwrap();
414        assert!(result.is_none());
415    }
416
417    #[tokio::test]
418    async fn test_semantic_get_identical_embedding() {
419        let cache = test_cache().await;
420        let embedding = vec![1.0_f32, 0.0, 0.0];
421        cache
422            .put_with_embedding("k1", "response-a", "m1", &embedding, "model-a")
423            .await
424            .unwrap();
425        let result = cache
426            .get_semantic(&embedding, "model-a", 0.9, 10)
427            .await
428            .unwrap();
429        assert!(result.is_some());
430        let (resp, score) = result.unwrap();
431        assert_eq!(resp, "response-a");
432        assert!(
433            (score - 1.0).abs() < 1e-5,
434            "expected score ~1.0, got {score}"
435        );
436    }
437
438    #[tokio::test]
439    async fn test_semantic_get_orthogonal_vectors() {
440        let cache = test_cache().await;
441        // Store [1, 0, 0]
442        cache
443            .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0, 0.0], "model-a")
444            .await
445            .unwrap();
446        // Query with [0, 1, 0] — perpendicular, similarity ~0.0
447        let result = cache
448            .get_semantic(&[0.0, 1.0, 0.0], "model-a", 0.5, 10)
449            .await
450            .unwrap();
451        assert!(result.is_none(), "orthogonal vectors should not hit");
452    }
453
454    #[tokio::test]
455    async fn test_semantic_get_similar_above_threshold() {
456        let cache = test_cache().await;
457        let stored = vec![1.0_f32, 0.1, 0.0];
458        cache
459            .put_with_embedding("k1", "response-a", "m1", &stored, "model-a")
460            .await
461            .unwrap();
462        // Very similar vector — should exceed 0.9 threshold
463        let query = vec![1.0_f32, 0.05, 0.0];
464        let result = cache
465            .get_semantic(&query, "model-a", 0.9, 10)
466            .await
467            .unwrap();
468        assert!(
469            result.is_some(),
470            "similar vector should hit at threshold 0.9"
471        );
472    }
473
474    #[tokio::test]
475    async fn test_semantic_get_similar_below_threshold() {
476        let cache = test_cache().await;
477        // Store [1, 0, 0]
478        cache
479            .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0, 0.0], "model-a")
480            .await
481            .unwrap();
482        // Store [0.7, 0.7, 0] — ~45 degrees off, cosine ~0.7
483        let query = vec![0.0_f32, 1.0, 0.0];
484        let result = cache
485            .get_semantic(&query, "model-a", 0.95, 10)
486            .await
487            .unwrap();
488        assert!(
489            result.is_none(),
490            "dissimilar vector should not hit at high threshold"
491        );
492    }
493
494    #[tokio::test]
495    async fn test_semantic_get_max_candidates_limit() {
496        let cache = test_cache().await;
497        // Insert 5 entries with identical embeddings
498        for i in 0..5_u8 {
499            cache
500                .put_with_embedding(
501                    &format!("k{i}"),
502                    &format!("response-{i}"),
503                    "m1",
504                    &[1.0, 0.0],
505                    "model-a",
506                )
507                .await
508                .unwrap();
509        }
510        // With max_candidates=2, we only see 2 rows, but still get a hit since they match.
511        let result = cache
512            .get_semantic(&[1.0, 0.0], "model-a", 0.9, 2)
513            .await
514            .unwrap();
515        assert!(result.is_some());
516    }
517
518    #[tokio::test]
519    async fn test_semantic_get_ignores_expired() {
520        let store = crate::store::SqliteStore::new(":memory:").await.unwrap();
521        // TTL=0 → immediately expired
522        let cache = ResponseCache::new(store.pool().clone(), 0);
523        cache
524            .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0], "model-a")
525            .await
526            .unwrap();
527        let result = cache
528            .get_semantic(&[1.0, 0.0], "model-a", 0.9, 10)
529            .await
530            .unwrap();
531        assert!(result.is_none(), "expired entries should not be returned");
532    }
533
534    #[tokio::test]
535    async fn test_semantic_get_filters_by_embedding_model() {
536        let cache = test_cache().await;
537        // Store entry with model-a
538        cache
539            .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0], "model-a")
540            .await
541            .unwrap();
542        // Query with model-b — should not find it
543        let result = cache
544            .get_semantic(&[1.0, 0.0], "model-b", 0.9, 10)
545            .await
546            .unwrap();
547        assert!(result.is_none(), "wrong embedding model should not match");
548    }
549
550    #[tokio::test]
551    async fn test_put_with_embedding_roundtrip() {
552        let cache = test_cache().await;
553        let embedding = vec![0.5_f32, 0.5, 0.707];
554        cache
555            .put_with_embedding(
556                "key1",
557                "semantic response",
558                "gpt-4",
559                &embedding,
560                "embed-model",
561            )
562            .await
563            .unwrap();
564        // Exact-match still works
565        let exact = cache.get("key1").await.unwrap();
566        assert_eq!(exact.as_deref(), Some("semantic response"));
567        // Semantic lookup works too
568        let semantic = cache
569            .get_semantic(&embedding, "embed-model", 0.99, 10)
570            .await
571            .unwrap();
572        assert!(semantic.is_some());
573        let (resp, score) = semantic.unwrap();
574        assert_eq!(resp, "semantic response");
575        assert!((score - 1.0).abs() < 1e-5);
576    }
577
578    #[tokio::test]
579    async fn test_invalidate_embeddings_for_model() {
580        let cache = test_cache().await;
581        cache
582            .put_with_embedding("k1", "resp", "m1", &[1.0, 0.0], "model-a")
583            .await
584            .unwrap();
585        let updated = cache
586            .invalidate_embeddings_for_model("model-a")
587            .await
588            .unwrap();
589        assert_eq!(updated, 1);
590        // Exact match still works after invalidation
591        let exact = cache.get("k1").await.unwrap();
592        assert_eq!(exact.as_deref(), Some("resp"));
593        // Semantic lookup should return nothing
594        let semantic = cache
595            .get_semantic(&[1.0, 0.0], "model-a", 0.9, 10)
596            .await
597            .unwrap();
598        assert!(semantic.is_none());
599    }
600
601    #[tokio::test]
602    async fn test_cleanup_nulls_stale_embeddings() {
603        let cache = test_cache().await;
604        cache
605            .put_with_embedding("k1", "resp", "m1", &[1.0, 0.0], "model-old")
606            .await
607            .unwrap();
608        let affected = cache.cleanup("model-new").await.unwrap();
609        assert!(affected > 0, "should have updated stale embedding row");
610        // Row survives (exact match preserved)
611        let exact = cache.get("k1").await.unwrap();
612        assert_eq!(exact.as_deref(), Some("resp"));
613        // Semantic lookup with old model returns nothing
614        let semantic = cache
615            .get_semantic(&[1.0, 0.0], "model-old", 0.9, 10)
616            .await
617            .unwrap();
618        assert!(semantic.is_none());
619    }
620
621    #[tokio::test]
622    async fn test_cleanup_deletes_expired() {
623        let store = crate::store::SqliteStore::new(":memory:").await.unwrap();
624        let cache = ResponseCache::new(store.pool().clone(), 0);
625        cache.put("k1", "resp", "m1").await.unwrap();
626        let affected = cache.cleanup("model-a").await.unwrap();
627        assert!(affected > 0);
628        let result = cache.get("k1").await.unwrap();
629        assert!(result.is_none());
630    }
631
632    #[tokio::test]
633    async fn test_cleanup_preserves_valid() {
634        let cache = test_cache().await;
635        cache
636            .put_with_embedding("k1", "resp", "m1", &[1.0, 0.0], "model-current")
637            .await
638            .unwrap();
639        let affected = cache.cleanup("model-current").await.unwrap();
640        assert_eq!(affected, 0, "valid entries should not be affected");
641        let semantic = cache
642            .get_semantic(&[1.0, 0.0], "model-current", 0.9, 10)
643            .await
644            .unwrap();
645        assert!(semantic.is_some());
646    }
647
648    // --- Corrupted BLOB tests ---
649    // These tests verify that get_semantic() gracefully handles corrupt embedding BLOBs
650    // stored directly in the database (bypassing put_with_embedding), simulating real-world
651    // scenarios such as disk errors, interrupted writes, or migration bugs.
652    //
653    // Note: NaN f32 values from garbage-but-valid-length BLOBs (length divisible by 4) are
654    // handled safely by IEEE 754 semantics — NaN > x is always false, so best_score is never
655    // updated and the row is silently skipped without panic.
656
657    /// Helper: insert a row with a raw (potentially corrupt) embedding BLOB via SQL.
658    async fn insert_corrupt_blob(pool: &DbPool, key: &str, blob: &[u8]) {
659        let now = unix_now();
660        let expires_at = now + 3600;
661        zeph_db::query(
662            sql!("INSERT INTO response_cache \
663             (cache_key, response, model, created_at, expires_at, embedding, embedding_model, embedding_ts) \
664             VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
665        )
666        .bind(key)
667        .bind("corrupt-response")
668        .bind("m1")
669        .bind(now)
670        .bind(expires_at)
671        .bind(blob)
672        .bind("model-a")
673        .bind(now)
674        .execute(pool)
675        .await
676        .unwrap();
677    }
678
679    #[tokio::test]
680    async fn test_semantic_get_corrupted_blob_odd_length() {
681        // A BLOB of 5 bytes is not a multiple of 4 and is skipped by the length guard.
682        // Verify that get_semantic returns Ok(None) without panicking.
683        let store = SqliteStore::new(":memory:").await.unwrap();
684        let pool = store.pool().clone();
685        let cache = ResponseCache::new(pool.clone(), 3600);
686
687        insert_corrupt_blob(&pool, "corrupt-key", &[0xAB, 0xCD, 0xEF, 0x01, 0x02]).await;
688
689        let result = cache
690            .get_semantic(&[1.0, 0.0, 0.0], "model-a", 0.9, 10)
691            .await
692            .unwrap();
693        assert!(
694            result.is_none(),
695            "corrupt odd-length BLOB must yield Ok(None)"
696        );
697    }
698
699    #[tokio::test]
700    async fn test_semantic_get_corrupted_blob_skips_to_valid() {
701        // Insert one corrupt row (5 bytes) and one valid row with an embedding identical to
702        // the query. Verify that the corrupt row is silently skipped and the valid row is
703        // returned, proving the for loop continues after a deserialization failure.
704        let store = SqliteStore::new(":memory:").await.unwrap();
705        let pool = store.pool().clone();
706        let cache = ResponseCache::new(pool.clone(), 3600);
707
708        // Corrupt row — odd-length BLOB
709        insert_corrupt_blob(&pool, "corrupt-key", &[0x01, 0x02, 0x03]).await;
710
711        // Valid row — embedding [1.0, 0.0, 0.0] stored via the normal path
712        let valid_embedding = vec![1.0_f32, 0.0, 0.0];
713        cache
714            .put_with_embedding(
715                "valid-key",
716                "valid-response",
717                "m1",
718                &valid_embedding,
719                "model-a",
720            )
721            .await
722            .unwrap();
723
724        let result = cache
725            .get_semantic(&valid_embedding, "model-a", 0.9, 10)
726            .await
727            .unwrap();
728        assert!(
729            result.is_some(),
730            "valid row must be returned despite corrupt sibling"
731        );
732        let (resp, cache_score) = result.unwrap();
733        assert_eq!(resp, "valid-response");
734        assert!(
735            (cache_score - 1.0).abs() < 1e-5,
736            "identical vectors must yield score ~1.0, got {cache_score}"
737        );
738    }
739
740    #[tokio::test]
741    async fn test_semantic_get_empty_blob() {
742        // An empty BLOB (0 bytes): length % 4 == 0, so the guard passes and produces an empty
743        // f32 slice. cosine_similarity returns 0.0 for mismatched lengths, which is below the
744        // 0.9 threshold. Verify Ok(None) is returned without panicking.
745        let store = SqliteStore::new(":memory:").await.unwrap();
746        let pool = store.pool().clone();
747        let cache = ResponseCache::new(pool.clone(), 3600);
748
749        insert_corrupt_blob(&pool, "empty-blob-key", &[]).await;
750
751        let result = cache
752            .get_semantic(&[1.0, 0.0], "model-a", 0.9, 10)
753            .await
754            .unwrap();
755        assert!(
756            result.is_none(),
757            "empty BLOB must yield Ok(None) at threshold 0.9"
758        );
759    }
760
761    #[tokio::test]
762    async fn test_semantic_get_all_blobs_corrupted() {
763        // All rows have corrupt BLOBs of various invalid lengths:
764        // 1, 3, 5, 7 bytes (odd) and 6 bytes (even but not a multiple of 4).
765        // Verify that get_semantic returns Ok(None) — all rows gracefully skipped.
766        let store = SqliteStore::new(":memory:").await.unwrap();
767        let pool = store.pool().clone();
768        let cache = ResponseCache::new(pool.clone(), 3600);
769
770        let corrupt_blobs: &[&[u8]] = &[
771            &[0x01],                                     // 1 byte
772            &[0x01, 0x02, 0x03],                         // 3 bytes
773            &[0x01, 0x02, 0x03, 0x04, 0x05],             // 5 bytes
774            &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07], // 7 bytes
775            &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06], // 6 bytes (even, not multiple of 4 — REC-1)
776        ];
777        for (i, blob) in corrupt_blobs.iter().enumerate() {
778            insert_corrupt_blob(&pool, &format!("corrupt-{i}"), blob).await;
779        }
780
781        let result = cache
782            .get_semantic(&[1.0, 0.0, 0.0], "model-a", 0.9, 10)
783            .await
784            .unwrap();
785        assert!(result.is_none(), "all corrupt BLOBs must yield Ok(None)");
786    }
787
788    // --- Dimension mismatch tests (issue #2034) ---
789
790    #[tokio::test]
791    async fn test_semantic_get_dimension_mismatch_returns_none() {
792        // Store dim=3, query dim=2 — cosine_similarity returns 0.0 for length mismatch.
793        // threshold=0.01 ensures 0.0 is below the bar (CRIT-01 fix verification).
794        let cache = test_cache().await;
795        cache
796            .put_with_embedding("k1", "resp-3d", "m1", &[1.0, 0.0, 0.0], "model-a")
797            .await
798            .unwrap();
799        let result = cache
800            .get_semantic(&[1.0, 0.0], "model-a", 0.01, 10)
801            .await
802            .unwrap();
803        assert!(
804            result.is_none(),
805            "dimension mismatch must not produce a hit"
806        );
807    }
808
809    #[tokio::test]
810    async fn test_semantic_get_dimension_mismatch_query_longer() {
811        // Inverse case: store dim=2, query dim=3 — mismatch handling must be symmetric.
812        let cache = test_cache().await;
813        cache
814            .put_with_embedding("k1", "resp-2d", "m1", &[1.0, 0.0], "model-a")
815            .await
816            .unwrap();
817        let result = cache
818            .get_semantic(&[1.0, 0.0, 0.0], "model-a", 0.01, 10)
819            .await
820            .unwrap();
821        assert!(
822            result.is_none(),
823            "query longer than stored embedding must not produce a hit"
824        );
825    }
826
827    #[tokio::test]
828    async fn test_semantic_get_mixed_dimensions_picks_correct_match() {
829        // Store entries at dim=2 and dim=3. Query with dim=3 must return only the dim=3 entry.
830        // The dim=2 entry scores 0.0 (mismatch) and must not interfere.
831        let cache = test_cache().await;
832        cache
833            .put_with_embedding("k-2d", "resp-2d", "m1", &[1.0, 0.0], "model-a")
834            .await
835            .unwrap();
836        cache
837            .put_with_embedding("k-3d", "resp-3d", "m1", &[1.0, 0.0, 0.0], "model-a")
838            .await
839            .unwrap();
840        let result = cache
841            .get_semantic(&[1.0, 0.0, 0.0], "model-a", 0.9, 10)
842            .await
843            .unwrap();
844        assert!(result.is_some(), "matching dim=3 entry should be returned");
845        let (response, score) = result.unwrap();
846        assert_eq!(response, "resp-3d", "wrong entry returned");
847        assert!(
848            (score - 1.0).abs() < 1e-5,
849            "expected score ~1.0 for identical vectors, got {score}"
850        );
851    }
852}