use std::sync::Mutex;
#[cfg(feature = "cache")]
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use super::{EmbedError, EmbeddingConfig, Result, EMBEDDING_DIM, EMBED_MODEL_PATH_ENV};
#[cfg(feature = "cache")]
use crate::cache::CacheStore;
pub trait EmbedClient: Send + Sync {
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
}
#[derive(Serialize)]
struct EmbeddingRequest<'a> {
model: &'a str,
input: Vec<&'a str>,
}
#[derive(Deserialize)]
struct EmbeddingResponse {
data: Vec<EmbeddingData>,
}
#[derive(Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
}
pub struct OpenAIEmbedClient {
config: EmbeddingConfig,
http: reqwest::blocking::Client,
}
impl OpenAIEmbedClient {
pub fn new(config: EmbeddingConfig) -> Result<Self> {
if config.is_local() {
return Err(EmbedError::Unavailable(
"OpenAIEmbedClient requires endpoint=Some(url); for local mode use LocalEmbedClient"
.to_string(),
));
}
if !config.has_api_key() {
return Err(EmbedError::MissingApiKey);
}
let http = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self { config, http })
}
pub fn from_env() -> Result<Self> {
Self::new(EmbeddingConfig::from_env())
}
#[must_use]
pub fn endpoint(&self) -> &str {
self.config
.endpoint
.as_deref()
.expect("OpenAIEmbedClient endpoint is Some (enforced by new())")
}
#[must_use]
pub fn model(&self) -> &str {
&self.config.model
}
}
impl EmbedClient for OpenAIEmbedClient {
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let api_key = self
.config
.api_key
.as_ref()
.ok_or(EmbedError::MissingApiKey)?;
let endpoint = self.config.endpoint.as_deref().ok_or_else(|| {
EmbedError::Unavailable("endpoint is None in remote mode".to_string())
})?;
let url = format!("{endpoint}/embeddings");
let body = EmbeddingRequest {
model: &self.config.model,
input: texts.to_vec(),
};
let resp = self
.http
.post(&url)
.bearer_auth(api_key)
.json(&body)
.send()
.map_err(|e| EmbedError::Unavailable(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(EmbedError::Api {
status: status.as_u16(),
body,
});
}
let parsed: EmbeddingResponse = resp.json()?;
let embeddings: Vec<Vec<f32>> = parsed.data.into_iter().map(|d| d.embedding).collect();
for emb in &embeddings {
if emb.len() != EMBEDDING_DIM {
return Err(EmbedError::DimensionMismatch {
expected: EMBEDDING_DIM,
actual: emb.len(),
});
}
}
Ok(embeddings)
}
}
impl std::fmt::Debug for OpenAIEmbedClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAIEmbedClient")
.field("endpoint", &self.config.endpoint)
.field("model", &self.config.model)
.field("api_key", &"<redacted>")
.finish()
}
}
pub struct LocalEmbedClient {
session: Mutex<ort::session::Session>,
tokenizer: tokenizers::Tokenizer,
model: String,
}
impl LocalEmbedClient {
pub fn new(config: &EmbeddingConfig) -> Result<Self> {
let model_path = config.resolved_model_path();
let tokenizer_path = config.resolved_tokenizer_path();
if !model_path.exists() {
return Err(EmbedError::Unavailable(format!(
"local embedding model not found at {}. \
Place the arctic-embed-xs ONNX model at this path \
(or set the {EMBED_MODEL_PATH_ENV} env var to a custom location).",
model_path.display(),
)));
}
if !tokenizer_path.exists() {
return Err(EmbedError::Unavailable(format!(
"tokenizer not found at {}. \
Place the HuggingFace tokenizer.json co-located with the model.",
tokenizer_path.display(),
)));
}
let session = ort::session::Session::builder()
.map_err(|e| EmbedError::Unavailable(format!("ort session builder failed: {e}")))?
.with_intra_threads(1)
.map_err(|e| EmbedError::Unavailable(format!("ort thread config failed: {e}")))?
.commit_from_file(&model_path)
.map_err(|e| EmbedError::Unavailable(format!("ort model load failed: {e}")))?;
let tokenizer = tokenizers::Tokenizer::from_file(&tokenizer_path)
.map_err(|e| EmbedError::Unavailable(format!("tokenizer load failed: {e}")))?;
Ok(Self {
session: Mutex::new(session),
tokenizer,
model: config.model.clone(),
})
}
#[must_use]
pub fn model(&self) -> &str {
&self.model
}
fn mean_pool(
hidden: &[f32],
attention_mask: &[u32],
seq_len: usize,
hidden_dim: usize,
) -> Vec<f32> {
let mut pooled = vec![0.0f32; hidden_dim];
let mut count = 0u32;
for (i, &mask_val) in attention_mask.iter().enumerate().take(seq_len) {
if mask_val > 0 {
let base = i * hidden_dim;
let end = base + hidden_dim;
for (p, &h) in pooled.iter_mut().zip(&hidden[base..end]) {
*p += h;
}
count += 1;
}
}
if count > 0 {
for v in &mut pooled {
*v /= count as f32;
}
}
pooled
}
fn l2_normalize(vec: &mut [f32]) {
let norm: f32 = vec.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > 0.0 {
for v in vec.iter_mut() {
*v /= norm;
}
}
}
}
impl EmbedClient for LocalEmbedClient {
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let mut results = Vec::with_capacity(texts.len());
for text in texts {
let encoding = self
.tokenizer
.encode(*text, true)
.map_err(|e| EmbedError::Unavailable(format!("tokenization failed: {e}")))?;
let input_ids = encoding.get_ids();
let attention_mask = encoding.get_attention_mask();
let token_type_ids = encoding.get_type_ids();
let seq_len = input_ids.len();
if seq_len == 0 {
return Err(EmbedError::Unavailable(
"tokenization produced empty sequence".to_string(),
));
}
let input_ids_arr = ndarray::Array2::from_shape_vec(
(1, seq_len),
input_ids.iter().map(|&v| v as i64).collect(),
)
.map_err(|e| EmbedError::Unavailable(format!("input_ids ndarray: {e}")))?;
let attention_mask_arr = ndarray::Array2::from_shape_vec(
(1, seq_len),
attention_mask.iter().map(|&v| v as i64).collect(),
)
.map_err(|e| EmbedError::Unavailable(format!("attention_mask ndarray: {e}")))?;
let token_type_ids_arr = ndarray::Array2::from_shape_vec(
(1, seq_len),
token_type_ids.iter().map(|&v| v as i64).collect(),
)
.map_err(|e| EmbedError::Unavailable(format!("token_type_ids ndarray: {e}")))?;
let input_ids_value = ort::value::Value::from_array(input_ids_arr)
.map_err(|e| EmbedError::Unavailable(format!("input_ids value: {e}")))?;
let attention_mask_value = ort::value::Value::from_array(attention_mask_arr)
.map_err(|e| EmbedError::Unavailable(format!("attention_mask value: {e}")))?;
let token_type_ids_value = ort::value::Value::from_array(token_type_ids_arr)
.map_err(|e| EmbedError::Unavailable(format!("token_type_ids value: {e}")))?;
let mut session = self
.session
.lock()
.map_err(|e| EmbedError::Unavailable(format!("session mutex poisoned: {e}")))?;
let inputs = ort::inputs![
&input_ids_value,
&attention_mask_value,
&token_type_ids_value
];
let outputs = session
.run(inputs)
.map_err(|e| EmbedError::Unavailable(format!("ort inference failed: {e}")))?;
let (_shape, hidden_data) = outputs["last_hidden_state"]
.try_extract_tensor::<f32>()
.map_err(|e| EmbedError::Unavailable(format!("ort output extract: {e}")))?;
let hidden_dim = hidden_data.len() / seq_len;
let mut pooled = Self::mean_pool(hidden_data, attention_mask, seq_len, hidden_dim);
Self::l2_normalize(&mut pooled);
if pooled.len() != EMBEDDING_DIM {
return Err(EmbedError::DimensionMismatch {
expected: EMBEDDING_DIM,
actual: pooled.len(),
});
}
results.push(pooled);
}
Ok(results)
}
}
impl std::fmt::Debug for LocalEmbedClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LocalEmbedClient")
.field("model", &self.model)
.finish()
}
}
pub struct MockEmbedClient {
dim: usize,
error: Option<EmbedError>,
}
impl MockEmbedClient {
#[must_use]
pub fn new() -> Self {
Self {
dim: EMBEDDING_DIM,
error: None,
}
}
#[must_use]
pub fn with_dim(dim: usize) -> Self {
Self { dim, error: None }
}
#[must_use]
pub fn with_error(error: EmbedError) -> Self {
Self {
dim: EMBEDDING_DIM,
error: Some(error),
}
}
}
impl Default for MockEmbedClient {
fn default() -> Self {
Self::new()
}
}
impl EmbedClient for MockEmbedClient {
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
if let Some(ref err) = self.error {
return Err(EmbedError::Unavailable(err.to_string()));
}
let mut results = Vec::with_capacity(texts.len());
for text in texts {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(text, &mut hasher);
let seed = std::hash::Hasher::finish(&hasher);
let mut vec = Vec::with_capacity(self.dim);
let mut state = seed;
for _ in 0..self.dim {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let val = ((state >> 33) as f32) / (1u64 << 31) as f32;
vec.push(val);
}
results.push(vec);
}
Ok(results)
}
}
impl std::fmt::Debug for MockEmbedClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MockEmbedClient")
.field("dim", &self.dim)
.field("error", &self.error.is_some())
.finish()
}
}
#[cfg(feature = "cache")]
fn serialize_vec(vec: &[f32]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(vec.len() * 4);
for &v in vec {
bytes.extend_from_slice(&v.to_le_bytes());
}
bytes
}
#[cfg(feature = "cache")]
fn deserialize_vec(bytes: &[u8]) -> Vec<f32> {
if !bytes.len().is_multiple_of(4) {
return Vec::new();
}
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
#[cfg(feature = "cache")]
pub struct CachedEmbedClient {
inner: Box<dyn EmbedClient>,
cache: Arc<dyn CacheStore>,
}
#[cfg(feature = "cache")]
impl CachedEmbedClient {
#[must_use]
pub fn new(inner: Box<dyn EmbedClient>, cache: Arc<dyn CacheStore>) -> Self {
Self { inner, cache }
}
}
#[cfg(feature = "cache")]
impl EmbedClient for CachedEmbedClient {
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let mut results: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
let mut miss_indices: Vec<usize> = Vec::with_capacity(texts.len());
let mut miss_texts: Vec<&str> = Vec::with_capacity(texts.len());
for (i, text) in texts.iter().enumerate() {
let hash = crate::index::hash::compute_content_hash(text.as_bytes());
let key = format!("embed:{hash}");
if let Some(bytes) = self.cache.get(&key) {
let vec = deserialize_vec(&bytes);
if vec.len() == EMBEDDING_DIM {
results.push(vec);
} else {
results.push(Vec::new());
miss_indices.push(i);
miss_texts.push(*text);
}
} else {
results.push(Vec::new());
miss_indices.push(i);
miss_texts.push(*text);
}
}
if !miss_texts.is_empty() {
let embeddings = self.inner.embed(&miss_texts)?;
for (idx, emb) in miss_indices.iter().zip(embeddings) {
let text = texts[*idx];
let hash = crate::index::hash::compute_content_hash(text.as_bytes());
let key = format!("embed:{hash}");
self.cache.set(&key, serialize_vec(&emb));
results[*idx] = emb;
}
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_client_returns_correct_count() {
let client = MockEmbedClient::new();
let texts = ["hello", "world", "foo"];
let result = client.embed(&texts).expect("embed");
assert_eq!(result.len(), 3, "should return one vector per text");
}
#[test]
fn mock_client_returns_correct_dimension() {
let client = MockEmbedClient::new();
let result = client.embed(&["test"]).expect("embed");
assert_eq!(result[0].len(), EMBEDDING_DIM, "dimension should be 384");
}
#[test]
fn mock_client_is_deterministic() {
let client = MockEmbedClient::new();
let a = client.embed(&["hello"]).expect("embed");
let b = client.embed(&["hello"]).expect("embed");
assert_eq!(a, b, "same input should produce same output");
}
#[test]
fn mock_client_different_inputs_differ() {
let client = MockEmbedClient::new();
let a = client.embed(&["hello"]).expect("embed");
let b = client.embed(&["world"]).expect("embed");
assert_ne!(a, b, "different inputs should produce different outputs");
}
#[test]
fn mock_client_with_custom_dim() {
let client = MockEmbedClient::with_dim(128);
let result = client.embed(&["test"]).expect("embed");
assert_eq!(result[0].len(), 128);
}
#[test]
fn mock_client_with_error_returns_error() {
let client =
MockEmbedClient::with_error(EmbedError::Unavailable("service down".to_string()));
let result = client.embed(&["test"]);
assert!(result.is_err(), "should return error");
assert!(result.unwrap_err().to_string().contains("unavailable"));
}
#[test]
fn mock_client_empty_input_returns_empty() {
let client = MockEmbedClient::new();
let result = client.embed(&[]).expect("embed");
assert!(result.is_empty(), "empty input should return empty");
}
#[test]
fn mock_client_default_is_new() {
let client = MockEmbedClient::default();
let result = client.embed(&["x"]).expect("embed");
assert_eq!(result[0].len(), EMBEDDING_DIM);
}
#[test]
fn mock_client_debug_does_not_leak_vectors() {
let client = MockEmbedClient::new();
let s = format!("{client:?}");
assert!(s.contains("MockEmbedClient"));
assert!(s.contains("dim"));
}
#[test]
fn openai_client_new_without_endpoint_returns_error() {
let cfg = EmbeddingConfig::default();
let result = OpenAIEmbedClient::new(cfg);
assert!(result.is_err(), "should error without endpoint");
assert!(
matches!(result.unwrap_err(), EmbedError::Unavailable(_)),
"should return Unavailable for local-mode config"
);
}
#[test]
fn openai_client_new_with_endpoint_but_no_key_returns_missing_key() {
let _lock = crate::embed::ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("CODENEXUS_EMBED_API_KEY");
std::env::remove_var("OPENAI_API_KEY");
let cfg = EmbeddingConfig {
endpoint: Some("https://api.openai.com/v1".to_string()),
..EmbeddingConfig::default()
};
let result = OpenAIEmbedClient::new(cfg);
assert!(result.is_err(), "should error without API key");
assert!(matches!(result.unwrap_err(), EmbedError::MissingApiKey));
}
#[test]
fn openai_client_new_with_endpoint_and_key_succeeds() {
let cfg = EmbeddingConfig {
endpoint: Some("https://api.openai.com/v1".to_string()),
api_key: Some("test-key".to_string()),
..EmbeddingConfig::default()
};
let client = OpenAIEmbedClient::new(cfg).expect("should succeed with endpoint+key");
assert!(client.endpoint().contains("openai.com"));
assert!(!client.model().is_empty());
}
#[test]
fn openai_client_from_env_without_endpoint_errors() {
let _lock = crate::embed::ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("CODENEXUS_EMBED_ENDPOINT");
std::env::remove_var("CODENEXUS_EMBED_API_KEY");
std::env::remove_var("OPENAI_API_KEY");
let result = OpenAIEmbedClient::from_env();
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), EmbedError::Unavailable(_)),
"should return Unavailable when endpoint not set"
);
}
#[test]
fn openai_client_from_env_with_endpoint_and_key_succeeds() {
let _lock = crate::embed::ENV_TEST_LOCK.lock().unwrap();
std::env::set_var("CODENEXUS_EMBED_ENDPOINT", "https://api.openai.com/v1");
std::env::set_var("CODENEXUS_EMBED_API_KEY", "env-key");
let client = OpenAIEmbedClient::from_env().expect("should succeed");
assert_eq!(client.endpoint(), "https://api.openai.com/v1");
std::env::remove_var("CODENEXUS_EMBED_ENDPOINT");
std::env::remove_var("CODENEXUS_EMBED_API_KEY");
}
#[test]
fn openai_client_debug_redacts_api_key() {
let cfg = EmbeddingConfig {
endpoint: Some("https://api.openai.com/v1".to_string()),
api_key: Some("secret-key-12345".to_string()),
..EmbeddingConfig::default()
};
let client = OpenAIEmbedClient::new(cfg).expect("client");
let s = format!("{client:?}");
assert!(s.contains("<redacted>"), "API key must be redacted: {s}");
assert!(
!s.contains("secret-key-12345"),
"API key must not appear in debug: {s}"
);
}
#[test]
fn openai_client_embed_without_key_returns_missing_key() {
let cfg = EmbeddingConfig {
endpoint: Some("http://localhost:1".to_string()), api_key: Some("test".to_string()),
..EmbeddingConfig::default()
};
let client = OpenAIEmbedClient::new(cfg).expect("client");
let result = client.embed(&["test"]);
assert!(result.is_err(), "should fail to connect");
let err = result.unwrap_err();
assert!(
matches!(err, EmbedError::Unavailable(_)),
"expected Unavailable, got: {err}"
);
}
#[test]
fn local_client_new_without_model_returns_unavailable() {
let cfg = EmbeddingConfig::default();
let result = LocalEmbedClient::new(&cfg);
assert!(result.is_err(), "should error when model file is missing");
let err = result.unwrap_err();
assert!(
matches!(err, EmbedError::Unavailable(ref msg) if msg.contains("not found")),
"expected Unavailable with 'not found', got: {err}"
);
}
#[test]
fn local_client_new_with_nonexistent_custom_path_returns_unavailable() {
let cfg = EmbeddingConfig {
model_path: Some(std::path::PathBuf::from("/nonexistent/model.onnx")),
..EmbeddingConfig::default()
};
let result = LocalEmbedClient::new(&cfg);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not found"),
"error should mention 'not found': {msg}"
);
assert!(
msg.contains("/nonexistent/model.onnx"),
"error should mention the path: {msg}"
);
}
#[test]
fn local_client_debug_shows_model_name() {
}
#[test]
fn local_client_mean_pool_correctness() {
let hidden_data = vec![
1.0, 2.0, 100.0, 200.0, 3.0, 4.0,
];
let attention_mask = vec![1u32, 0u32, 1u32];
let pooled = LocalEmbedClient::mean_pool(&hidden_data, &attention_mask, 3, 2);
assert_eq!(pooled, vec![2.0, 3.0]);
}
#[test]
fn local_client_mean_pool_all_masked_returns_zeros() {
let hidden_data = vec![1.0, 2.0, 3.0, 4.0];
let attention_mask = vec![0u32, 0u32];
let pooled = LocalEmbedClient::mean_pool(&hidden_data, &attention_mask, 2, 2);
assert_eq!(pooled, vec![0.0, 0.0]);
}
#[test]
fn local_client_l2_normalize_correctness() {
let mut vec = vec![3.0, 4.0]; LocalEmbedClient::l2_normalize(&mut vec);
assert!((vec[0] - 0.6).abs() < 1e-6, "expected 0.6, got {}", vec[0]);
assert!((vec[1] - 0.8).abs() < 1e-6, "expected 0.8, got {}", vec[1]);
}
#[test]
fn local_client_l2_normalize_zero_vector_noop() {
let mut vec = vec![0.0, 0.0, 0.0];
LocalEmbedClient::l2_normalize(&mut vec);
assert_eq!(vec, vec![0.0, 0.0, 0.0]);
}
#[test]
fn embed_client_trait_object_works() {
let client: Box<dyn EmbedClient> = Box::new(MockEmbedClient::new());
let result = client.embed(&["hello", "world"]).expect("embed");
assert_eq!(result.len(), 2);
}
#[test]
fn embed_client_trait_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Box<dyn EmbedClient>>();
}
#[cfg(feature = "cache")]
mod cached_tests {
use super::*;
use crate::cache::CacheStore;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
struct CountingEmbedClient {
calls: Arc<AtomicUsize>,
recorded: Arc<Mutex<Vec<Vec<String>>>>,
inner: MockEmbedClient,
}
impl CountingEmbedClient {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
recorded: Arc::new(Mutex::new(Vec::new())),
inner: MockEmbedClient::new(),
}
}
}
impl EmbedClient for CountingEmbedClient {
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.recorded
.lock()
.expect("lock")
.push(texts.iter().map(|s| s.to_string()).collect());
self.inner.embed(texts)
}
}
struct MockCache {
store: Mutex<HashMap<String, Vec<u8>>>,
}
impl MockCache {
fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
fn snapshot(&self) -> HashMap<String, Vec<u8>> {
self.store.lock().expect("lock").clone()
}
fn len(&self) -> usize {
self.store.lock().expect("lock").len()
}
}
impl CacheStore for MockCache {
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.store.lock().expect("lock").get(key).cloned()
}
fn set(&self, key: &str, val: Vec<u8>) {
self.store
.lock()
.expect("lock")
.insert(key.to_string(), val);
}
fn invalidate_all(&self) {
self.store.lock().expect("lock").clear();
}
}
#[test]
fn cached_embed_miss_then_hit_skips_inner_call() {
let inner = CountingEmbedClient::new();
let calls = Arc::clone(&inner.calls);
let cached = CachedEmbedClient::new(Box::new(inner), Arc::new(MockCache::new()));
let r1 = cached.embed(&["hello"]).expect("first embed");
let r2 = cached.embed(&["hello"]).expect("second embed (cache hit)");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"inner should be called once; second call must hit cache"
);
assert_eq!(r1, r2, "both calls should return identical vectors");
}
#[test]
fn cached_embed_batch_partial_cache_hit() {
let inner = CountingEmbedClient::new();
let calls = Arc::clone(&inner.calls);
let recorded = Arc::clone(&inner.recorded);
let cache = Arc::new(MockCache::new());
let cached = CachedEmbedClient::new(Box::new(inner), cache.clone());
let primed = cached
.embed(&["cached_text"])
.expect("prime")
.into_iter()
.next()
.expect("one vector");
let prime_calls = calls.load(Ordering::SeqCst);
let results = cached
.embed(&["cached_text", "fresh_text"])
.expect("batch embed");
assert_eq!(results.len(), 2, "should return one vector per input");
assert_eq!(
results[0], primed,
"cached_text result should match the primed vector"
);
let batch_calls = calls.load(Ordering::SeqCst) - prime_calls;
assert_eq!(
batch_calls, 1,
"inner should be called once for the miss only"
);
let last = recorded
.lock()
.expect("lock")
.last()
.cloned()
.unwrap_or_default();
assert_eq!(
last,
vec!["fresh_text".to_string()],
"inner should only receive uncached texts"
);
}
#[test]
fn cached_embed_same_text_same_key() {
let inner = CountingEmbedClient::new();
let cache = Arc::new(MockCache::new());
let cached = CachedEmbedClient::new(Box::new(inner), cache.clone());
cached.embed(&["hello"]).expect("first");
cached.embed(&["hello"]).expect("second");
let snap = cache.snapshot();
assert_eq!(
snap.len(),
1,
"same text should produce same key โ exactly one entry"
);
let key = snap.keys().next().expect("one entry");
assert!(
key.starts_with("embed:"),
"key should start with 'embed:': {key}"
);
}
#[test]
fn cached_embed_different_texts_different_keys() {
let inner = CountingEmbedClient::new();
let cache = Arc::new(MockCache::new());
let cached = CachedEmbedClient::new(Box::new(inner), cache.clone());
cached.embed(&["hello", "world"]).expect("embed");
let snap = cache.snapshot();
assert_eq!(
snap.len(),
2,
"different texts should produce different keys โ two entries"
);
for key in snap.keys() {
assert!(
key.starts_with("embed:"),
"key should start with 'embed:': {key}"
);
}
}
#[test]
fn cached_embed_empty_input_returns_empty() {
let inner = CountingEmbedClient::new();
let calls = Arc::clone(&inner.calls);
let cache = Arc::new(MockCache::new());
let cached = CachedEmbedClient::new(Box::new(inner), cache.clone());
let results = cached.embed(&[]).expect("empty embed");
assert!(results.is_empty(), "empty input should return empty");
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"inner should not be called for empty input"
);
assert_eq!(cache.len(), 0, "cache should remain empty");
}
#[test]
fn cached_embed_stores_correct_vector() {
let inner = CountingEmbedClient::new();
let calls = Arc::clone(&inner.calls);
let cache = Arc::new(MockCache::new());
let cached = CachedEmbedClient::new(Box::new(inner), cache.clone());
let r1 = cached.embed(&["hello"]).expect("first embed");
let v1 = r1.into_iter().next().expect("one vector");
let snap = cache.snapshot();
assert_eq!(snap.len(), 1, "exactly one entry should be cached");
let (key, _val) = snap.iter().next().expect("one entry");
assert!(
key.starts_with("embed:"),
"key should start with 'embed:': {key}"
);
let r2 = cached.embed(&["hello"]).expect("second embed (cache hit)");
let v2 = r2.into_iter().next().expect("one vector");
assert_eq!(
v1, v2,
"cache hit should return the same vector (round-trip correctness)"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"inner should be called once; second call was a cache hit"
);
}
}
}