Skip to main content

zeph_memory/
embedding_registry.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Generic embedding registry backed by Qdrant.
5//!
6//! Provides deduplication through content-hash delta tracking and collection-level
7//! embedding-model change detection.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use tokio::sync::RwLock;
15
16use futures::StreamExt as _;
17use qdrant_client::qdrant::{PointStruct, value::Kind};
18
19use crate::QdrantOps;
20use crate::vector_store::{VectorStore, VectorStoreError};
21
22/// Boxed future returned by an embedding function.
23pub type EmbedFuture = Pin<
24    Box<dyn Future<Output = Result<Vec<f32>, Box<dyn std::error::Error + Send + Sync>>> + Send>,
25>;
26
27/// Domain type that can be stored in an [`EmbeddingRegistry`].
28///
29/// Implement this trait for any struct that should be embedded and persisted in Qdrant.
30/// The registry uses [`key`](Self::key) and [`content_hash`](Self::content_hash) to
31/// detect which items need to be re-embedded on each [`EmbeddingRegistry::sync`] call.
32pub trait Embeddable: Send + Sync {
33    /// Unique string key used for point-ID generation and delta tracking.
34    fn key(&self) -> &str;
35
36    /// BLAKE3 hex hash of all semantically relevant fields.
37    ///
38    /// When this hash changes between syncs the item's embedding is recomputed.
39    fn content_hash(&self) -> String;
40
41    /// Text that will be passed to the embedding model.
42    fn embed_text(&self) -> &str;
43
44    /// Full JSON payload to store in Qdrant alongside the vector.
45    ///
46    /// **Must** include a `"key"` field equal to [`Self::key()`] so
47    /// [`EmbeddingRegistry`] can recover items on scroll.
48    fn to_payload(&self) -> serde_json::Value;
49}
50
51/// Counters returned by [`EmbeddingRegistry::sync`].
52#[derive(Debug, Default, Clone)]
53pub struct SyncStats {
54    pub added: usize,
55    pub updated: usize,
56    pub removed: usize,
57    pub unchanged: usize,
58}
59
60/// Errors produced by [`EmbeddingRegistry`].
61#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum EmbeddingRegistryError {
64    #[error("vector store error: {0}")]
65    VectorStore(#[from] VectorStoreError),
66
67    #[error("embedding error: {0}")]
68    Embedding(String),
69
70    #[error("serialization error: {0}")]
71    Serialization(String),
72
73    #[error("dimension probe failed: {0}")]
74    DimensionProbe(String),
75}
76
77impl From<Box<qdrant_client::QdrantError>> for EmbeddingRegistryError {
78    fn from(e: Box<qdrant_client::QdrantError>) -> Self {
79        Self::VectorStore(VectorStoreError::Collection(e.to_string()))
80    }
81}
82
83impl From<serde_json::Error> for EmbeddingRegistryError {
84    fn from(e: serde_json::Error) -> Self {
85        Self::Serialization(e.to_string())
86    }
87}
88
89// Ollama appends :latest when no tag is specified; treat the two as equivalent.
90fn normalize_model_name(name: &str) -> &str {
91    name.strip_suffix(":latest").unwrap_or(name)
92}
93
94/// Probe the embedding dimension via [`crate::embed_probe::probe_vector_size`], adapting its
95/// error into [`EmbeddingRegistryError::DimensionProbe`].
96///
97/// No timeout is applied here — `EmbeddingRegistry` has never bounded this call, unlike
98/// `zeph-index`'s indexer (which has its own 15s startup timeout).
99async fn probe_dimension(
100    embed_fn: &impl Fn(&str) -> EmbedFuture,
101) -> Result<u64, EmbeddingRegistryError> {
102    crate::embed_probe::probe_vector_size(embed_fn("dimension probe"), None)
103        .await
104        .map_err(|e| EmbeddingRegistryError::DimensionProbe(e.to_string()))
105}
106
107/// Returns `true` when any stored point uses a model name that is semantically different
108/// from `config_model` after normalizing `:latest` suffixes.
109///
110/// A missing `embedding_model` field (legacy points from pre-#3395 sessions) is treated as a
111/// mismatch: the vector was produced by an unknown model and must be regenerated.
112fn model_has_changed(
113    existing: &HashMap<String, HashMap<String, String>>,
114    config_model: &str,
115) -> bool {
116    if config_model.is_empty() {
117        return false;
118    }
119    existing
120        .values()
121        .any(|stored| match stored.get("embedding_model") {
122            Some(m) => normalize_model_name(m) != normalize_model_name(config_model),
123            // Absent field means the point was written before the model was recorded; treat as mismatch.
124            None => true,
125        })
126}
127
128/// Generic Qdrant-backed embedding registry.
129///
130/// Owns a [`QdrantOps`] instance, a collection name and a UUID namespace for
131/// deterministic point IDs (uuid v5).  The in-memory `hashes` map enables
132/// O(1) delta detection between syncs.
133///
134/// The `cached_dim` field caches the collection's vector dimension after the first successful
135/// [`sync`](Self::sync) so that [`search_raw`](Self::search_raw) can validate the query vector
136/// dimension without an extra Qdrant round-trip on every call.  When a mismatch is detected,
137/// `search_raw` returns [`EmbeddingRegistryError::DimensionProbe`] instead of silently issuing a
138/// gRPC search that would return near-zero cosine scores (Qdrant gRPC behaviour on dim mismatch).
139#[derive(Clone)]
140pub struct EmbeddingRegistry {
141    ops: QdrantOps,
142    collection: String,
143    namespace: uuid::Uuid,
144    hashes: HashMap<String, String>,
145    /// Maximum number of embedding requests dispatched concurrently during a sync.
146    pub concurrency: usize,
147    /// Vector dimension confirmed during the last successful `sync`.  Shared via `Arc` so
148    /// `Clone` works without invalidating the cached value across cloned instances.
149    cached_dim: Arc<RwLock<Option<u64>>>,
150}
151
152impl std::fmt::Debug for EmbeddingRegistry {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("EmbeddingRegistry")
155            .field("collection", &self.collection)
156            .finish_non_exhaustive()
157    }
158}
159
160impl EmbeddingRegistry {
161    /// Create a registry wrapping an existing [`QdrantOps`] connection.
162    #[must_use]
163    pub fn new(ops: QdrantOps, collection: impl Into<String>, namespace: uuid::Uuid) -> Self {
164        Self {
165            ops,
166            collection: collection.into(),
167            namespace,
168            hashes: HashMap::new(),
169            concurrency: 4,
170            cached_dim: Arc::new(RwLock::new(None)),
171        }
172    }
173
174    /// Sync `items` into Qdrant, computing a content-hash delta to avoid
175    /// unnecessary re-embedding.  Re-creates the collection when the embedding
176    /// model changes.
177    ///
178    /// `on_progress`, when provided, is called after each successful embed+upsert with
179    /// `(completed, total)` counts so callers can display progress indicators.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`EmbeddingRegistryError`] on Qdrant or embedding failures.
184    #[tracing::instrument(name = "memory.embed_registry.sync", skip_all, err)]
185    pub async fn sync<T: Embeddable>(
186        &mut self,
187        items: &[T],
188        embedding_model: &str,
189        embed_fn: impl Fn(&str) -> EmbedFuture,
190        on_progress: Option<Box<dyn Fn(usize, usize) + Send>>,
191    ) -> Result<SyncStats, EmbeddingRegistryError> {
192        let mut stats = SyncStats::default();
193
194        self.ensure_collection(&embed_fn).await?;
195
196        let existing = self
197            .ops
198            .scroll_all(&self.collection, "key")
199            .await
200            .map_err(|e| {
201                EmbeddingRegistryError::VectorStore(VectorStoreError::Scroll(e.to_string()))
202            })?;
203
204        let mut current: HashMap<String, (String, &T)> = HashMap::with_capacity(items.len());
205        for item in items {
206            current.insert(item.key().to_owned(), (item.content_hash(), item));
207        }
208
209        let model_changed = model_has_changed(&existing, embedding_model);
210
211        if model_changed {
212            tracing::warn!("embedding model changed to '{embedding_model}', recreating collection");
213            self.recreate_collection(&embed_fn).await?;
214        }
215
216        let work_items = build_work_set(
217            &current,
218            &existing,
219            model_changed,
220            &mut stats,
221            &mut self.hashes,
222        );
223
224        // Pre-create futures, point IDs, and payloads before taking the mutable borrow on
225        // self.hashes to avoid a double-borrow on `self`.
226        let work_with_futures: Vec<(String, String, EmbedFuture, String, serde_json::Value)> =
227            work_items
228                .into_iter()
229                .map(|(key, hash, item)| {
230                    let text = item.embed_text().to_owned();
231                    let fut = embed_fn(&text);
232                    let point_id = self.point_id(&key);
233                    let payload = item.to_payload();
234                    (key, hash, fut, point_id, payload)
235                })
236                .collect();
237
238        let points_to_upsert = embed_and_collect_points(
239            work_with_futures,
240            on_progress,
241            &existing,
242            embedding_model,
243            self.concurrency,
244            &mut stats,
245            &mut self.hashes,
246        )
247        .await?;
248
249        if !points_to_upsert.is_empty() {
250            self.ops
251                .upsert(&self.collection, points_to_upsert)
252                .await
253                .map_err(|e| {
254                    EmbeddingRegistryError::VectorStore(VectorStoreError::Upsert(e.to_string()))
255                })?;
256        }
257
258        let orphan_ids: Vec<qdrant_client::qdrant::PointId> = existing
259            .keys()
260            .filter(|key| !current.contains_key(*key))
261            .map(|key| qdrant_client::qdrant::PointId::from(self.point_id(key).as_str()))
262            .collect();
263
264        if !orphan_ids.is_empty() {
265            stats.removed = orphan_ids.len();
266            self.ops
267                .delete_by_ids(&self.collection, orphan_ids)
268                .await
269                .map_err(|e| {
270                    EmbeddingRegistryError::VectorStore(VectorStoreError::Delete(e.to_string()))
271                })?;
272        }
273
274        tracing::info!(
275            added = stats.added,
276            updated = stats.updated,
277            removed = stats.removed,
278            unchanged = stats.unchanged,
279            collection = &self.collection,
280            "embeddings synced"
281        );
282
283        Ok(stats)
284    }
285
286    /// Search the collection, returning raw scored Qdrant points.
287    ///
288    /// Validates that the query vector dimension matches the collection before issuing the gRPC
289    /// call.  Qdrant gRPC silently returns near-zero cosine scores (~0.022) when dimensions
290    /// mismatch instead of returning an error — this guard prevents that silent failure.
291    ///
292    /// The dimension is checked against the cache populated by the most recent [`sync`](Self::sync)
293    /// call.  If no sync has occurred (cache is `None`) the check is skipped to avoid blocking
294    /// reads before the first sync.
295    ///
296    /// Consumers map the payloads to their domain types.
297    ///
298    /// `limit` is clamped to `[1, `[`MAX_SEARCH_LIMIT`](crate::MAX_SEARCH_LIMIT)`]` before
299    /// being forwarded to Qdrant (issue #6553) — the bound is enforced here rather than
300    /// relying on every caller to clamp before calling. A one-shot `tracing::warn!` fires
301    /// the first time this actually reduces the requested value.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`EmbeddingRegistryError::DimensionProbe`] when the query vector dimension does not
306    /// match the stored collection dimension.  Returns [`EmbeddingRegistryError::Embedding`] if the
307    /// embed function fails, or [`EmbeddingRegistryError::VectorStore`] on Qdrant search failure.
308    #[tracing::instrument(name = "memory.embed_registry.search_raw", skip_all, err)]
309    pub async fn search_raw(
310        &self,
311        query: &str,
312        limit: usize,
313        embed_fn: impl Fn(&str) -> EmbedFuture,
314    ) -> Result<Vec<crate::ScoredVectorPoint>, EmbeddingRegistryError> {
315        static CLAMP_WARNED: std::sync::atomic::AtomicBool =
316            std::sync::atomic::AtomicBool::new(false);
317        crate::warn_if_search_limit_clamped("EmbeddingRegistry::search_raw", limit, &CLAMP_WARNED);
318        let limit = limit.clamp(1, crate::MAX_SEARCH_LIMIT);
319        let query_vec = embed_fn(query)
320            .await
321            .map_err(|e| EmbeddingRegistryError::Embedding(e.to_string()))?;
322
323        // Guard: Qdrant gRPC returns near-zero cosine scores when the query vector dimension
324        // does not match the stored collection dimension (issue #3418).  Check the cache first
325        // (populated by sync); fall back to a live Qdrant probe only when the cache is empty.
326        let collection_dim: Option<u64> = *self.cached_dim.read().await;
327
328        let collection_dim = if collection_dim.is_some() {
329            collection_dim
330        } else {
331            // Cache miss: ask Qdrant directly (first search before any sync), then populate cache.
332            let probed = self
333                .ops
334                .get_collection_vector_size(&self.collection)
335                .await
336                .map_err(|e| {
337                    EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
338                })?;
339            if let Some(d) = probed {
340                self.set_cached_dim(d).await;
341            }
342            probed
343        };
344
345        if let Some(stored_dim) = collection_dim {
346            // Safe: a Vec<f32> with 4B+ elements is impossible in practice on any 64-bit platform.
347            let query_dim = query_vec.len() as u64;
348            if query_dim != stored_dim {
349                return Err(EmbeddingRegistryError::DimensionProbe(format!(
350                    "query vector dimension {query_dim} does not match collection '{}' \
351                     dimension {stored_dim}; re-run sync to rebuild the collection",
352                    self.collection
353                )));
354            }
355        }
356
357        let Ok(limit_u64) = u64::try_from(limit) else {
358            return Ok(Vec::new());
359        };
360
361        let results = self
362            .ops
363            .search(&self.collection, query_vec, limit_u64, None)
364            .await
365            .map_err(|e| {
366                EmbeddingRegistryError::VectorStore(VectorStoreError::Search(e.to_string()))
367            })?;
368
369        let scored: Vec<crate::ScoredVectorPoint> = results
370            .into_iter()
371            .map(|point| {
372                let payload: HashMap<String, serde_json::Value> = point
373                    .payload
374                    .into_iter()
375                    .filter_map(|(k, v)| {
376                        let json_val = match v.kind? {
377                            Kind::StringValue(s) => serde_json::Value::String(s),
378                            Kind::IntegerValue(i) => serde_json::Value::Number(i.into()),
379                            Kind::BoolValue(b) => serde_json::Value::Bool(b),
380                            Kind::DoubleValue(d) => {
381                                serde_json::Number::from_f64(d).map(serde_json::Value::Number)?
382                            }
383                            _ => return None,
384                        };
385                        Some((k, json_val))
386                    })
387                    .collect();
388
389                let id = match point.id.and_then(|pid| pid.point_id_options) {
390                    Some(qdrant_client::qdrant::point_id::PointIdOptions::Uuid(u)) => u,
391                    Some(qdrant_client::qdrant::point_id::PointIdOptions::Num(n)) => n.to_string(),
392                    None => String::new(),
393                };
394
395                crate::ScoredVectorPoint {
396                    id,
397                    score: point.score,
398                    payload,
399                }
400            })
401            .collect();
402
403        Ok(scored)
404    }
405
406    fn point_id(&self, key: &str) -> String {
407        uuid::Uuid::new_v5(&self.namespace, key.as_bytes()).to_string()
408    }
409
410    /// Retrieve stored vectors for a bounded set of keys via a single Qdrant `get_points`
411    /// round-trip (e.g. the candidate IDs returned by a prior [`Self::search_raw`] call).
412    ///
413    /// Keys with no matching point, or whose point carries no dense vector, are silently
414    /// omitted from the result — callers should treat a missing key as "vector unavailable"
415    /// rather than an error.
416    ///
417    /// # Errors
418    ///
419    /// Returns [`EmbeddingRegistryError::VectorStore`] if the underlying Qdrant call fails.
420    #[tracing::instrument(name = "memory.embed_registry.get_vectors_by_keys", skip_all, err)]
421    pub async fn get_vectors_by_keys(
422        &self,
423        keys: &[String],
424    ) -> Result<HashMap<String, Vec<f32>>, EmbeddingRegistryError> {
425        if keys.is_empty() {
426            return Ok(HashMap::new());
427        }
428        let id_to_key: HashMap<String, String> =
429            keys.iter().map(|k| (self.point_id(k), k.clone())).collect();
430        let ids: Vec<String> = id_to_key.keys().cloned().collect();
431        let points = self.ops.get_points(&self.collection, ids).await?;
432        Ok(points
433            .into_iter()
434            .filter_map(|p| id_to_key.get(&p.id).map(|k| (k.clone(), p.vector)))
435            .collect())
436    }
437
438    #[tracing::instrument(
439        name = "memory.embed_registry.ensure_collection",
440        skip_all,
441        err,
442        level = "debug"
443    )]
444    async fn ensure_collection(
445        &self,
446        embed_fn: &impl Fn(&str) -> EmbedFuture,
447    ) -> Result<(), EmbeddingRegistryError> {
448        if !self
449            .ops
450            .collection_exists(&self.collection)
451            .await
452            .map_err(|e| {
453                EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
454            })?
455        {
456            // Collection does not exist — probe once and create.
457            let vector_size = probe_dimension(embed_fn).await?;
458            self.ops
459                .ensure_collection(&self.collection, vector_size)
460                .await
461                .map_err(|e| {
462                    EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
463                })?;
464            tracing::info!(
465                collection = &self.collection,
466                dimensions = vector_size,
467                "created Qdrant collection"
468            );
469            self.set_cached_dim(vector_size).await;
470            return Ok(());
471        }
472
473        let existing_size = self
474            .ops
475            .get_collection_vector_size(&self.collection)
476            .await
477            .map_err(|e| {
478                EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
479            })?;
480
481        let vector_size = probe_dimension(embed_fn).await?;
482
483        if existing_size == Some(vector_size) {
484            self.set_cached_dim(vector_size).await;
485            return Ok(());
486        }
487
488        tracing::warn!(
489            collection = &self.collection,
490            existing = ?existing_size,
491            required = vector_size,
492            "vector dimension mismatch, recreating collection"
493        );
494        self.ops
495            .delete_collection(&self.collection)
496            .await
497            .map_err(|e| {
498                EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
499            })?;
500        self.ops
501            .ensure_collection(&self.collection, vector_size)
502            .await
503            .map_err(|e| {
504                EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
505            })?;
506        tracing::info!(
507            collection = &self.collection,
508            dimensions = vector_size,
509            "created Qdrant collection"
510        );
511        self.set_cached_dim(vector_size).await;
512
513        Ok(())
514    }
515
516    /// Store `dim` in the dimension cache so `search_raw` can validate without a Qdrant round-trip.
517    #[tracing::instrument(
518        name = "memory.embed_registry.set_cached_dim",
519        skip_all,
520        level = "debug"
521    )]
522    async fn set_cached_dim(&self, dim: u64) {
523        *self.cached_dim.write().await = Some(dim);
524    }
525
526    #[tracing::instrument(
527        name = "memory.embed_registry.recreate_collection",
528        skip_all,
529        err,
530        level = "debug"
531    )]
532    async fn recreate_collection(
533        &self,
534        embed_fn: &impl Fn(&str) -> EmbedFuture,
535    ) -> Result<(), EmbeddingRegistryError> {
536        if self
537            .ops
538            .collection_exists(&self.collection)
539            .await
540            .map_err(|e| {
541                EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
542            })?
543        {
544            self.ops
545                .delete_collection(&self.collection)
546                .await
547                .map_err(|e| {
548                    EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
549                })?;
550            tracing::info!(
551                collection = &self.collection,
552                "deleted collection for recreation"
553            );
554        }
555        self.ensure_collection(embed_fn).await
556    }
557}
558
559/// Determine which items need embedding and update stats for unchanged ones.
560///
561/// Returns a list of `(key, hash, item)` triples that require re-embedding.  Items whose
562/// stored hash matches the current hash are counted as `unchanged` in `stats` and their
563/// hashes are pre-populated in the `hashes` map.
564fn build_work_set<'a, T: Embeddable>(
565    current: &HashMap<String, (String, &'a T)>,
566    existing: &HashMap<String, HashMap<String, String>>,
567    model_changed: bool,
568    stats: &mut SyncStats,
569    hashes: &mut HashMap<String, String>,
570) -> Vec<(String, String, &'a T)> {
571    let mut work_items: Vec<(String, String, &'a T)> = Vec::new();
572    for (key, (hash, item)) in current {
573        let needs_update = if let Some(stored) = existing.get(key) {
574            model_changed || stored.get("content_hash").is_some_and(|h| h != hash)
575        } else {
576            true
577        };
578
579        if needs_update {
580            work_items.push((key.clone(), hash.clone(), *item));
581        } else {
582            stats.unchanged += 1;
583            hashes.insert(key.clone(), hash.clone());
584        }
585    }
586    work_items
587}
588
589/// Await each pre-created embed future and collect the resulting Qdrant points.
590///
591/// `work_items` is `(key, hash, embed_future, point_id, item_payload)` — point IDs and payloads
592/// must be pre-computed to avoid a double-borrow on the `EmbeddingRegistry` when `hashes` is
593/// mutably borrowed.
594///
595/// Processes futures with bounded concurrency (`concurrency` parameter).  Calls `on_progress`
596/// after each successful embed.  Updates `stats.added`/`stats.updated` and `hashes` in place.
597///
598/// Returns a `Vec<PointStruct>` ready for upsert, or an error if payload serialization fails.
599#[tracing::instrument(
600    name = "memory.embed_registry.embed_and_collect_points",
601    skip_all,
602    err,
603    level = "debug"
604)]
605#[allow(clippy::too_many_arguments)]
606async fn embed_and_collect_points(
607    work_items: Vec<(String, String, EmbedFuture, String, serde_json::Value)>,
608    on_progress: Option<Box<dyn Fn(usize, usize) + Send>>,
609    existing: &HashMap<String, HashMap<String, String>>,
610    embedding_model: &str,
611    concurrency: usize,
612    stats: &mut SyncStats,
613    hashes: &mut HashMap<String, String>,
614) -> Result<Vec<PointStruct>, EmbeddingRegistryError> {
615    let total = work_items.len();
616    // Clamp concurrency to at least 1: buffer_unordered(0) silently skips all futures.
617    let concurrency = concurrency.max(1);
618
619    // Stream results as they complete so on_progress fires in real time, not after collect.
620    let mut stream =
621        futures::stream::iter(work_items.into_iter().map(
622            |(key, hash, fut, point_id, payload)| async move {
623                (key, hash, fut.await, point_id, payload)
624            },
625        ))
626        .buffer_unordered(concurrency);
627
628    let mut points_to_upsert = Vec::new();
629    let mut completed: usize = 0;
630    while let Some((key, hash, result, point_id, mut payload)) = stream.next().await {
631        let vector = match result {
632            Ok(v) => v,
633            Err(e) => {
634                tracing::warn!("failed to embed item '{key}': {e:#}");
635                continue;
636            }
637        };
638
639        if let Some(obj) = payload.as_object_mut() {
640            obj.insert(
641                "content_hash".into(),
642                serde_json::Value::String(hash.clone()),
643            );
644            obj.insert(
645                "embedding_model".into(),
646                serde_json::Value::String(embedding_model.to_owned()),
647            );
648        }
649        let payload_map = QdrantOps::json_to_payload(payload)?;
650
651        points_to_upsert.push(PointStruct::new(point_id, vector, payload_map));
652
653        if existing.contains_key(&key) {
654            stats.updated += 1;
655        } else {
656            stats.added += 1;
657        }
658        hashes.insert(key, hash);
659
660        completed += 1;
661        if let Some(ref cb) = on_progress {
662            cb(completed, total);
663        }
664    }
665    Ok(points_to_upsert)
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn normalize_no_suffix() {
674        assert_eq!(normalize_model_name("foo"), "foo");
675    }
676
677    #[test]
678    fn normalize_strips_latest() {
679        assert_eq!(normalize_model_name("foo:latest"), "foo");
680    }
681
682    #[test]
683    fn normalize_other_tag_unchanged() {
684        assert_eq!(normalize_model_name("foo:v2"), "foo:v2");
685    }
686
687    struct TestItem {
688        k: String,
689        text: String,
690    }
691
692    impl Embeddable for TestItem {
693        fn key(&self) -> &str {
694            &self.k
695        }
696
697        fn content_hash(&self) -> String {
698            let mut hasher = blake3::Hasher::new();
699            hasher.update(self.text.as_bytes());
700            hasher.finalize().to_hex().to_string()
701        }
702
703        fn embed_text(&self) -> &str {
704            &self.text
705        }
706
707        fn to_payload(&self) -> serde_json::Value {
708            serde_json::json!({"key": self.k, "text": self.text})
709        }
710    }
711
712    fn make_item(k: &str, text: &str) -> TestItem {
713        TestItem {
714            k: k.into(),
715            text: text.into(),
716        }
717    }
718
719    #[test]
720    fn registry_new_valid_url() {
721        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
722        let ns = uuid::Uuid::from_bytes([0u8; 16]);
723        let reg = EmbeddingRegistry::new(ops, "test_col", ns);
724        let dbg = format!("{reg:?}");
725        assert!(dbg.contains("EmbeddingRegistry"));
726        assert!(dbg.contains("test_col"));
727    }
728
729    #[test]
730    fn embeddable_content_hash_deterministic() {
731        let item = make_item("key", "some text");
732        assert_eq!(item.content_hash(), item.content_hash());
733    }
734
735    #[test]
736    fn embeddable_content_hash_changes() {
737        let a = make_item("key", "text a");
738        let b = make_item("key", "text b");
739        assert_ne!(a.content_hash(), b.content_hash());
740    }
741
742    #[test]
743    fn embeddable_payload_contains_key() {
744        let item = make_item("my-key", "desc");
745        let payload = item.to_payload();
746        assert_eq!(payload["key"], "my-key");
747    }
748
749    #[test]
750    fn sync_stats_default() {
751        let s = SyncStats::default();
752        assert_eq!(s.added, 0);
753        assert_eq!(s.updated, 0);
754        assert_eq!(s.removed, 0);
755        assert_eq!(s.unchanged, 0);
756    }
757
758    #[test]
759    fn sync_stats_debug() {
760        let s = SyncStats {
761            added: 1,
762            updated: 2,
763            removed: 3,
764            unchanged: 4,
765        };
766        let dbg = format!("{s:?}");
767        assert!(dbg.contains("added"));
768    }
769
770    #[tokio::test]
771    async fn search_raw_embed_fail_returns_error() {
772        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
773        let ns = uuid::Uuid::from_bytes([0u8; 16]);
774        let reg = EmbeddingRegistry::new(ops, "test", ns);
775        let embed_fn = |_: &str| -> EmbedFuture {
776            Box::pin(async {
777                Err(Box::new(std::io::Error::other("fail"))
778                    as Box<dyn std::error::Error + Send + Sync>)
779            })
780        };
781        let result = reg.search_raw("query", 5, embed_fn).await;
782        assert!(result.is_err());
783    }
784
785    /// Validates the dimension mismatch guard in `search_raw` (issue #3418).
786    ///
787    /// When the cached collection dimension differs from the query vector dimension,
788    /// `search_raw` must return `Err(EmbeddingRegistryError::DimensionProbe)` instead of
789    /// issuing a gRPC search that would silently return near-zero cosine scores.
790    #[tokio::test]
791    async fn search_raw_dimension_mismatch_returns_error() {
792        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
793        let ns = uuid::Uuid::from_bytes([0u8; 16]);
794        let reg = EmbeddingRegistry::new(ops, "test_dim_guard", ns);
795
796        // Simulate that the collection was created with 4-dim vectors.
797        reg.set_cached_dim(4).await;
798
799        // Query with a 2-dim vector (different model / dimension).
800        let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
801        let result = reg.search_raw("query", 5, embed_fn).await;
802        assert!(
803            matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
804            "expected DimensionProbe error on dimension mismatch, got: {result:?}"
805        );
806    }
807
808    /// Validates that `search_raw` does not reject a correctly-dimensioned query.
809    ///
810    /// When the cached dimension matches the query vector, the guard must pass and
811    /// the error (if any) comes from the Qdrant network call — not from the guard itself.
812    #[tokio::test]
813    async fn search_raw_matching_dimension_passes_guard() {
814        let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap(); // unreachable — forces network error
815        let ns = uuid::Uuid::from_bytes([0u8; 16]);
816        let reg = EmbeddingRegistry::new(ops, "test_dim_pass", ns);
817
818        // Simulate a 2-dim collection.
819        reg.set_cached_dim(2).await;
820
821        // Query with a matching 2-dim vector.
822        let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
823        let result = reg.search_raw("query", 5, embed_fn).await;
824        // The guard passes; the error is from the unreachable Qdrant instance.
825        assert!(
826            !matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
827            "guard must not fire when dimensions match"
828        );
829    }
830
831    /// Issue #6553: an oversized `limit` must be clamped to `MAX_SEARCH_LIMIT` before it
832    /// reaches the `u64` conversion / Qdrant call, rather than forwarded as-is. `QdrantOps`
833    /// has no test seam (concrete gRPC client, not a trait object), so this cannot assert the
834    /// clamped *count* end-to-end the way `EmbeddingStore::search`/`search_collection` and
835    /// `ReasoningMemory::retrieve_by_embedding` do (those accept an injectable `VectorStore`).
836    /// It does assert, via the one-shot `tracing::warn!`, that the clamp logic actually ran
837    /// with the oversized value rather than being skipped or short-circuited — a stronger
838    /// signal than "did not panic" alone (critic finding M5).
839    #[tokio::test]
840    #[tracing_test::traced_test]
841    async fn search_raw_oversized_limit_does_not_panic() {
842        let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap(); // unreachable — forces network error
843        let ns = uuid::Uuid::from_bytes([0u8; 16]);
844        let reg = EmbeddingRegistry::new(ops, "test_oversized_limit", ns);
845        reg.set_cached_dim(2).await;
846
847        let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
848        let result = reg.search_raw("query", usize::MAX, embed_fn).await;
849        assert!(
850            !matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
851            "clamp must not corrupt the dimension guard"
852        );
853        assert!(
854            logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
855            "expected the one-shot clamp warning to fire for an oversized limit"
856        );
857    }
858
859    #[tokio::test]
860    async fn get_vectors_by_keys_empty_input_short_circuits() {
861        // Unreachable Qdrant instance: if this didn't short-circuit it would error/hang.
862        let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap();
863        let ns = uuid::Uuid::from_bytes([0u8; 16]);
864        let reg = EmbeddingRegistry::new(ops, "test_empty_keys", ns);
865        let result = reg.get_vectors_by_keys(&[]).await.unwrap();
866        assert!(result.is_empty());
867    }
868
869    #[tokio::test]
870    async fn get_vectors_by_keys_unreachable_qdrant_errors() {
871        let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap();
872        let ns = uuid::Uuid::from_bytes([0u8; 16]);
873        let reg = EmbeddingRegistry::new(ops, "test_vec_fetch", ns);
874        let keys = vec!["skill-a".to_string(), "skill-b".to_string()];
875        let result = reg.get_vectors_by_keys(&keys).await;
876        assert!(
877            matches!(result, Err(EmbeddingRegistryError::VectorStore(_))),
878            "expected VectorStore error, got: {result:?}"
879        );
880    }
881
882    #[tokio::test]
883    async fn sync_with_unreachable_qdrant_fails() {
884        let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap();
885        let ns = uuid::Uuid::from_bytes([0u8; 16]);
886        let mut reg = EmbeddingRegistry::new(ops, "test", ns);
887        let items = vec![make_item("k", "text")];
888        let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![0.1_f32, 0.2]) }) };
889        let result = reg.sync(&items, "model", embed_fn, None).await;
890        assert!(result.is_err());
891    }
892
893    // ── model_has_changed unit tests ──────────────────────────────────────────
894
895    fn make_existing(model: &str) -> HashMap<String, HashMap<String, String>> {
896        let mut point = HashMap::new();
897        point.insert("embedding_model".to_owned(), model.to_owned());
898        let mut map = HashMap::new();
899        map.insert("k1".to_owned(), point);
900        map
901    }
902
903    #[test]
904    fn model_has_changed_latest_vs_bare_is_false() {
905        // Root cause of #2894: stored ":latest" suffix must not trigger recreation.
906        let existing = make_existing("nomic-embed-text-v2-moe:latest");
907        assert!(!model_has_changed(&existing, "nomic-embed-text-v2-moe"));
908    }
909
910    #[test]
911    fn model_has_changed_same_model_is_false() {
912        let existing = make_existing("nomic-embed-text-v2-moe");
913        assert!(!model_has_changed(&existing, "nomic-embed-text-v2-moe"));
914    }
915
916    #[test]
917    fn model_has_changed_different_model_is_true() {
918        let existing = make_existing("all-minilm");
919        assert!(model_has_changed(&existing, "nomic-embed-text-v2-moe"));
920    }
921
922    #[test]
923    fn model_has_changed_empty_existing_is_false() {
924        assert!(!model_has_changed(&HashMap::new(), "any-model"));
925    }
926
927    #[test]
928    fn model_has_changed_absent_field_with_config_model_is_true() {
929        // Legacy points have no embedding_model field; treat as mismatch to force recreation.
930        let mut point = HashMap::new();
931        point.insert("content_hash".to_owned(), "abc".to_owned());
932        let mut map = HashMap::new();
933        map.insert("k1".to_owned(), point);
934        assert!(model_has_changed(&map, "nomic-embed-text-v2-moe"));
935    }
936
937    #[test]
938    fn model_has_changed_absent_field_with_empty_config_model_is_false() {
939        let mut point = HashMap::new();
940        point.insert("content_hash".to_owned(), "abc".to_owned());
941        let mut map = HashMap::new();
942        map.insert("k1".to_owned(), point);
943        assert!(!model_has_changed(&map, ""));
944    }
945
946    // ── concurrency guard ─────────────────────────────────────────────────────
947
948    #[test]
949    fn concurrency_zero_clamped_to_one() {
950        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
951        let ns = uuid::Uuid::from_bytes([0u8; 16]);
952        let mut reg = EmbeddingRegistry::new(ops, "test", ns);
953        reg.concurrency = 0;
954        // Clamp is applied inside sync; verify the field itself can be set to 0
955        // and the guard converts it to 1 without panicking (tested via field value).
956        assert_eq!(reg.concurrency.max(1), 1);
957    }
958
959    // ── integration tests (require live Qdrant via testcontainers) ────────────
960
961    /// Test: `on_progress` fires once per successfully embedded item with correct counts.
962    #[tokio::test]
963    #[ignore = "requires Docker for Qdrant"]
964    async fn on_progress_called_once_per_successful_embed() {
965        use std::sync::{
966            Arc,
967            atomic::{AtomicUsize, Ordering},
968        };
969        use testcontainers::GenericImage;
970        use testcontainers::core::{ContainerPort, WaitFor};
971        use testcontainers::runners::AsyncRunner;
972
973        let container = GenericImage::new("qdrant/qdrant", "v1.16.0")
974            .with_wait_for(WaitFor::message_on_stdout("gRPC listening"))
975            .with_wait_for(WaitFor::seconds(1))
976            .with_exposed_port(ContainerPort::Tcp(6334))
977            .start()
978            .await
979            .unwrap();
980        let port = container.get_host_port_ipv4(6334).await.unwrap();
981        let ops = QdrantOps::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
982        let ns = uuid::Uuid::new_v4();
983        let mut reg = EmbeddingRegistry::new(ops, "test_progress", ns);
984
985        let items = [
986            make_item("a", "alpha"),
987            make_item("b", "beta"),
988            make_item("c", "gamma"),
989        ];
990        let call_count = Arc::new(AtomicUsize::new(0));
991        let last_done = Arc::new(AtomicUsize::new(0));
992        let last_total = Arc::new(AtomicUsize::new(0));
993        let cc = Arc::clone(&call_count);
994        let ld = Arc::clone(&last_done);
995        let lt = Arc::clone(&last_total);
996
997        let embed_fn =
998            |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![0.1_f32, 0.2, 0.3, 0.4]) }) };
999        let on_progress: Option<Box<dyn Fn(usize, usize) + Send>> =
1000            Some(Box::new(move |completed, total| {
1001                cc.fetch_add(1, Ordering::SeqCst);
1002                ld.store(completed, Ordering::SeqCst);
1003                lt.store(total, Ordering::SeqCst);
1004            }));
1005
1006        let stats = reg
1007            .sync(&items, "test-model", embed_fn, on_progress)
1008            .await
1009            .unwrap();
1010        let n = stats.added + stats.updated;
1011
1012        assert_eq!(
1013            call_count.load(Ordering::SeqCst),
1014            n,
1015            "on_progress call count"
1016        );
1017        assert_eq!(last_done.load(Ordering::SeqCst), n, "last completed");
1018        assert_eq!(last_total.load(Ordering::SeqCst), n, "total");
1019    }
1020
1021    /// Test: when one embed fails, the batch continues and only successful items are upserted.
1022    #[tokio::test]
1023    #[ignore = "requires Docker for Qdrant"]
1024    async fn partial_embed_failure_skips_failed_item() {
1025        use testcontainers::GenericImage;
1026        use testcontainers::core::{ContainerPort, WaitFor};
1027        use testcontainers::runners::AsyncRunner;
1028
1029        let container = GenericImage::new("qdrant/qdrant", "v1.16.0")
1030            .with_wait_for(WaitFor::message_on_stdout("gRPC listening"))
1031            .with_wait_for(WaitFor::seconds(1))
1032            .with_exposed_port(ContainerPort::Tcp(6334))
1033            .start()
1034            .await
1035            .unwrap();
1036        let port = container.get_host_port_ipv4(6334).await.unwrap();
1037        let ops = QdrantOps::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
1038        let ns = uuid::Uuid::new_v4();
1039        let mut reg = EmbeddingRegistry::new(ops, "test_partial", ns);
1040
1041        // Item whose embed_text contains "fail" will cause the embed_fn to return Err.
1042        let items = [
1043            make_item("ok1", "ok text"),
1044            make_item("fail", "fail text"),
1045            make_item("ok2", "ok text 2"),
1046        ];
1047
1048        let embed_fn = |text: &str| -> EmbedFuture {
1049            if text.contains("fail") {
1050                Box::pin(async {
1051                    Err(Box::new(std::io::Error::other("injected failure"))
1052                        as Box<dyn std::error::Error + Send + Sync>)
1053                })
1054            } else {
1055                Box::pin(async { Ok(vec![0.1_f32, 0.2, 0.3, 0.4]) })
1056            }
1057        };
1058
1059        // sync must return Ok — individual failures are warned and skipped.
1060        let stats = reg
1061            .sync(&items, "test-model", embed_fn, None)
1062            .await
1063            .unwrap();
1064        assert_eq!(
1065            stats.added, 2,
1066            "two items should be upserted, failed one skipped"
1067        );
1068    }
1069
1070    /// Validates the full dimension-mismatch guard path against a live Qdrant instance (issue #3418).
1071    ///
1072    /// Creates a collection with 4-dim vectors via `sync`, then attempts a search with a 2-dim
1073    /// query vector.  The guard in `search_raw` must return `Err(DimensionProbe)` before any
1074    /// gRPC call reaches Qdrant, preventing the silent near-zero cosine score failure.
1075    #[tokio::test]
1076    #[ignore = "requires Docker for Qdrant"]
1077    async fn search_raw_dimension_mismatch_returns_error_live() {
1078        use testcontainers::GenericImage;
1079        use testcontainers::core::{ContainerPort, WaitFor};
1080        use testcontainers::runners::AsyncRunner;
1081
1082        let container = GenericImage::new("qdrant/qdrant", "v1.16.0")
1083            .with_wait_for(WaitFor::message_on_stdout("gRPC listening"))
1084            .with_wait_for(WaitFor::seconds(1))
1085            .with_exposed_port(ContainerPort::Tcp(6334))
1086            .start()
1087            .await
1088            .unwrap();
1089        let port = container.get_host_port_ipv4(6334).await.unwrap();
1090        let ops = QdrantOps::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
1091        let ns = uuid::Uuid::new_v4();
1092        let mut reg = EmbeddingRegistry::new(ops, "test_dim_guard_live", ns);
1093
1094        // Sync with 4-dim vectors so the collection and cache are established.
1095        let items = [make_item("a", "alpha")];
1096        let embed_fn_4d =
1097            |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0, 0.0, 0.0]) }) };
1098        reg.sync(&items, "model-4d", embed_fn_4d, None)
1099            .await
1100            .unwrap();
1101
1102        // Search with a 2-dim query (simulates a model switch without re-sync).
1103        let embed_fn_2d = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
1104        let result = reg.search_raw("query", 5, embed_fn_2d).await;
1105        assert!(
1106            matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
1107            "dimension mismatch must return DimensionProbe error, not silent near-zero scores; got: {result:?}"
1108        );
1109    }
1110}