1use zeph_db::DbPool;
10#[allow(unused_imports)]
11use zeph_db::sql;
12
13use crate::error::MemoryError;
14
15pub 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 cache.put("key1", "response", "model").await.unwrap();
331 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 let cache = ResponseCache::new(store.pool().clone(), u64::MAX - 1);
389 cache.put("key1", "response", "model").await.unwrap();
391 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 #[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 cache
443 .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0, 0.0], "model-a")
444 .await
445 .unwrap();
446 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 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 cache
479 .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0, 0.0], "model-a")
480 .await
481 .unwrap();
482 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 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 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 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 cache
539 .put_with_embedding("k1", "response-a", "m1", &[1.0, 0.0], "model-a")
540 .await
541 .unwrap();
542 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 let exact = cache.get("key1").await.unwrap();
566 assert_eq!(exact.as_deref(), Some("semantic response"));
567 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 let exact = cache.get("k1").await.unwrap();
592 assert_eq!(exact.as_deref(), Some("resp"));
593 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 let exact = cache.get("k1").await.unwrap();
612 assert_eq!(exact.as_deref(), Some("resp"));
613 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 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 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 let store = SqliteStore::new(":memory:").await.unwrap();
705 let pool = store.pool().clone();
706 let cache = ResponseCache::new(pool.clone(), 3600);
707
708 insert_corrupt_blob(&pool, "corrupt-key", &[0x01, 0x02, 0x03]).await;
710
711 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 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 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], &[0x01, 0x02, 0x03], &[0x01, 0x02, 0x03, 0x04, 0x05], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06], ];
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 #[tokio::test]
791 async fn test_semantic_get_dimension_mismatch_returns_none() {
792 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 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 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}