//! Embedding slot registry: maps `(EntityKind, slot_name)` to HNSW index state. //! //! The [`EmbeddingSlotRegistry`] is the central authority for embedding slot //! configuration. It is constructed from the schema at `TidalDB::open()` time //! and provides the lookup path that the embedding lifecycle operations use //! to find the correct index for a given entity kind and slot name. //! //! Each entity type can define up to 4 embedding slots (per Entity Model //! Specification). Each slot has its own HNSW index, dimensionality, //! quantization level, and source (external vs. database-managed). use std::collections::HashMap; use super::{QuantizationLevel, VectorError, VectorIndex, VectorIndexConfig}; use crate::schema::EntityKind; // --------------------------------------------------------------------------- // Vector backend selection (production HNSW vs. brute-force) // --------------------------------------------------------------------------- /// Minimum live-vector count for a slot before the production HNSW engine /// (`UsearchIndex`) is preferred over the exact `BruteForceIndex`. /// /// `BruteForceIndex` computes an L2 distance against every stored vector under /// an `RwLock` read on each search — exact, but O(n) per query and serialized /// against writes. It is the right choice for tiny slots (no graph-build cost, /// exact results) but blows past the latency budget at scale. `UsearchIndex` /// (HNSW) is the mandated production engine (`CODING_GUIDELINES` §4: "126K+ QPS", /// §8: "ANN retrieval at 1M vectors <10ms p99"). Gating on slot size needs NO /// external config plumbing: at process restart, `rebuild_from_store` already /// knows each slot's vector count, so a slot that crossed this threshold is /// rebuilt as HNSW automatically, while a small slot stays exact. The threshold /// is deliberately conservative — well inside the range where brute force is /// still fast — so correctness-sensitive small slots keep exact results. pub(crate) const USEARCH_MIN_VECTORS: usize = 10_000; /// Build the vector index backend for a slot, gating on the expected vector /// `count`: `UsearchIndex` (production HNSW) at or above [`USEARCH_MIN_VECTORS`], /// `BruteForceIndex` (exact) below it. /// /// When the `USearch` backend is selected, capacity is reserved up front /// (`UsearchIndex::add` requires a prior `reserve`), so the subsequent inserts /// cannot fail for lack of capacity. If `UsearchIndex` construction OR the /// reservation fails for any /// reason, this DEGRADES to `BruteForceIndex` with a `tracing::warn!` rather than /// propagating — losing the latency win is acceptable; losing the slot entirely /// (and therefore all ANN results for the kind) is not. This is the single place /// that picks the engine; the write-path auto-register and the rebuild path both /// route through it so the choice cannot drift. pub(crate) fn build_slot_index(dimensions: usize, count: usize) -> Box { let config = VectorIndexConfig { dimensions, ..VectorIndexConfig::default() }; if count < USEARCH_MIN_VECTORS { return Box::new(super::BruteForceIndex::new(config)); } match super::UsearchIndex::new(config.clone()) { Ok(index) => { // USearch's `add` requires capacity reserved up front. Reserve the // expected count so the rebuild's inserts all fit; the write path // grows it further as needed. if let Err(e) = index.reserve(count) { tracing::warn!( dimensions, count, error = %e, "USearch reserve failed; falling back to brute-force for this slot" ); return Box::new(super::BruteForceIndex::new(config)); } tracing::info!( dimensions, count, "using USearch (HNSW) backend for embedding slot" ); Box::new(index) } Err(e) => { tracing::warn!( dimensions, count, error = %e, "USearch index construction failed; falling back to brute-force for this slot" ); Box::new(super::BruteForceIndex::new(config)) } } } /// Number of bytes per vector component at a given quantization level. /// /// Used by [`EmbeddingSlotRegistry::index_stats`] to estimate index footprint /// from the slot's *registered* precision rather than assuming f16. const fn bytes_per_component(quantization: QuantizationLevel) -> u64 { match quantization { QuantizationLevel::F32 => 4, QuantizationLevel::F16 => 2, QuantizationLevel::Int8 => 1, } } // --------------------------------------------------------------------------- // HNSW default parameters (single source of truth) // --------------------------------------------------------------------------- /// Default HNSW connectivity (M parameter): maximum bi-directional links per node. /// /// M=16 is the sweet spot for quality vs. memory (Malkov & Yashunin, 2018). /// This is the single authoritative source for the default; both [`HnswParams`] /// and the vector index config derive their default `connectivity` from it. pub const DEFAULT_CONNECTIVITY: usize = 16; /// Default HNSW construction-time beam width (`ef_construction`). /// /// `ef_construction=400` raises recall@10 to >0.99 at 100K vectors (128D) with /// ~2× build overhead vs. ef=200 — acceptable for a write-once index. Grid search /// at 100K vectors / 128D confirmed recall@10 ≈ 0.993 vs. 0.978 for ef=200 /// (see `docs/profiling/usearch-tuning.md`). Single authoritative source. pub const DEFAULT_EF_CONSTRUCTION: usize = 400; /// Default HNSW search-time beam width (`ef_search`). Can be overridden per-query. /// Single authoritative source for the default. pub const DEFAULT_EF_SEARCH: usize = 200; // --------------------------------------------------------------------------- // HNSW parameters // --------------------------------------------------------------------------- /// HNSW graph parameters for an embedding slot. /// /// These are set at slot registration time and used to construct the HNSW /// index. They cannot be changed without rebuilding the index. #[derive(Debug, Clone)] pub struct HnswParams { /// Maximum number of bi-directional links per node (M parameter). /// Higher = better recall, more memory. Default: [`DEFAULT_CONNECTIVITY`]. pub connectivity: usize, /// Beam width during index construction. Higher = better graph quality, /// slower builds. Default: [`DEFAULT_EF_CONSTRUCTION`]. pub ef_construction: usize, /// Default beam width during search. Can be overridden per-query. /// Default: [`DEFAULT_EF_SEARCH`]. pub ef_search: usize, } impl Default for HnswParams { fn default() -> Self { // All defaults and their tuning rationale live on the DEFAULT_* consts // above — the single source of truth. Do not inline literals here. Self { connectivity: DEFAULT_CONNECTIVITY, ef_construction: DEFAULT_EF_CONSTRUCTION, ef_search: DEFAULT_EF_SEARCH, } } } // --------------------------------------------------------------------------- // Embedding source // --------------------------------------------------------------------------- /// Source of an embedding slot's vectors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EmbeddingSource { /// Provided by the application via `write_item()` or `write_user()`. External, /// Computed and maintained by tidalDB (e.g., user preference vector, /// creator catalog embedding). DatabaseManaged, } // --------------------------------------------------------------------------- // Slot state // --------------------------------------------------------------------------- /// State for a single embedding slot. /// /// Combines the HNSW index with its configuration metadata. The index is /// boxed behind the [`VectorIndex`] trait for swappability between /// `UsearchIndex` (production) and `BruteForceIndex` (testing/small data). pub struct EmbeddingSlotState { /// The HNSW index for this slot. pub index: Box, /// Number of dimensions for this slot. pub dimensions: usize, /// Quantization level used in the HNSW index. pub quantization: QuantizationLevel, /// Whether this embedding is externally provided or database-managed. pub source: EmbeddingSource, /// HNSW graph parameters. pub params: HnswParams, } // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- /// Registry of all embedding slots across all entity types. /// /// Constructed from the schema at `TidalDB::open()` time. Each entity type /// can define up to 4 embedding slots (per Entity Model Specification). /// /// # Example /// /// ```text /// Item "content" -> 1536d, f16, External, M=16 /// Item "visual" -> 512d, f16, External, M=16 /// User "preference" -> 1536d, f16, DatabaseManaged, M=16 /// ``` pub struct EmbeddingSlotRegistry { /// Two-level map: `EntityKind -> (slot_name -> state)`. The nested form lets /// `get`/`get_mut` probe the inner map with a borrowed `&str`, avoiding the /// per-call `String` heap allocation that a flat `(EntityKind, String)` key /// would force on the per-query / per-write hot path. Slot counts are tiny /// (<=4 per kind), so the extra indirection is negligible. slots: HashMap>, } impl EmbeddingSlotRegistry { /// Create an empty registry. #[must_use] pub fn new() -> Self { Self { slots: HashMap::new(), } } /// Register an embedding slot. /// /// # Errors /// /// Returns [`VectorError::Backend`] if a slot with the same /// `(entity_kind, slot_name)` is already registered. pub fn register( &mut self, entity_kind: EntityKind, slot_name: String, state: EmbeddingSlotState, ) -> Result<(), VectorError> { let inner = self.slots.entry(entity_kind).or_default(); if inner.contains_key(&slot_name) { return Err(VectorError::Backend(format!( "slot already registered: ({entity_kind}, \"{slot_name}\")" ))); } inner.insert(slot_name, state); Ok(()) } /// Look up an embedding slot by entity kind and slot name. /// /// Returns `None` if the slot is not registered. Probes the inner map with a /// borrowed `&str` — no allocation on this hot path. #[must_use] pub fn get(&self, entity_kind: EntityKind, slot_name: &str) -> Option<&EmbeddingSlotState> { self.slots.get(&entity_kind)?.get(slot_name) } /// Look up an embedding slot mutably. Zero-alloc inner probe (see [`Self::get`]). pub fn get_mut( &mut self, entity_kind: EntityKind, slot_name: &str, ) -> Option<&mut EmbeddingSlotState> { self.slots.get_mut(&entity_kind)?.get_mut(slot_name) } /// List all slot names for a given entity kind. /// /// The returned names are in arbitrary order (`HashMap` iteration order). #[must_use] pub fn slots_for(&self, entity_kind: EntityKind) -> Vec<&str> { self.slots .get(&entity_kind) .map(|inner| inner.keys().map(String::as_str).collect()) .unwrap_or_default() } /// Total number of registered slots across all entity kinds. #[must_use] pub fn slot_count(&self) -> usize { self.slots.values().map(HashMap::len).sum() } /// Return the total vector count and estimated byte size across all slots. /// /// Byte size estimate: `count * dimensions * bytes_per_component`, where /// `bytes_per_component` is derived from each slot's *registered* quantization /// (F32 = 4, F16 = 2, Int8 = 1). The previous implementation hardcoded 2 /// (f16), which undercounted by 2x for the F32 slots that production actually /// registers (see `db::items::write_item_embedding`). This is still a lower /// bound that excludes HNSW graph overhead. #[must_use] pub fn index_stats(&self) -> (u64, u64) { let mut total_vectors: u64 = 0; let mut total_bytes: u64 = 0; for inner in self.slots.values() { for slot in inner.values() { let count = slot.index.len() as u64; let dim = slot.dimensions as u64; let bytes_per_component = bytes_per_component(slot.quantization); total_vectors = total_vectors.saturating_add(count); // Saturating arithmetic: this is a lower-bound footprint estimate, // so saturating to u64::MAX at absurd scale is the correct semantic // — it can never panic in debug nor silently wrap in release. total_bytes = total_bytes.saturating_add( count .saturating_mul(dim) .saturating_mul(bytes_per_component), ); } } (total_vectors, total_bytes) } /// Rebuild every embedding slot index for `entity_kind` from durable storage. /// /// The entity store is the source of truth for full-precision f32 embeddings: /// each one is persisted under `Tag::Meta` with the `EMB:{slot}` suffix (see /// [`crate::storage::vector::embedding_store_key`]). The in-memory ANN index, /// by contrast, is *derived* state that does not survive a process restart. /// Without this rebuild, ANN search silently returns nothing after every /// reopen until each entity's embedding is written again -- a documented /// requirement that previously had no implementation. /// /// This method scans `storage` for `EMB:` keys, deserializes each embedding, /// lazily registers the slot the first time it is seen (deriving the /// dimensionality from the stored vector and matching the precision/source of /// the write path -- F32, [`EmbeddingSource::External`]), and inserts the /// vector into the slot's index. It is idempotent: re-running it over the same /// store re-inserts the same `(id, vector)` pairs. /// /// `schema` is accepted so future precision/source declarations can override /// the write-path defaults; today [`crate::schema::EmbeddingSlotDef`] carries /// only `name`/`entity_kind`/`dimensions`, so it is used purely to scope which /// slot names are expected for `entity_kind`. /// /// Returns the number of vectors inserted across all slots for this kind. /// /// # Backend selection /// /// Each slot's backend is chosen by [`build_slot_index`] from the slot's live /// vector count: a slot at or above [`USEARCH_MIN_VECTORS`] is rebuilt as the /// production HNSW engine (`UsearchIndex`); smaller slots stay exact /// (`BruteForceIndex`). This is the live path that makes `UsearchIndex` /// reachable in production after a restart, with no config plumbing. /// /// # Fault isolation /// /// A single un-indexable embedding (un-deserializable, zero-dimension, or a /// dimensionality that mismatches its slot) is SKIPPED with a `tracing::warn!` /// and counted, not propagated — one poison row must never abort the rebuild /// for an entire entity kind (which would leave ANN search empty for every /// good embedding too). The skipped count is logged at the end. /// /// # Errors /// /// - [`VectorError::Io`] if a storage scan entry fails to read (a whole-scan /// failure is genuinely unrecoverable, so it still propagates). /// - [`VectorError::Backend`] if slot registration fails. #[allow(clippy::too_many_lines)] pub(crate) fn rebuild_from_store( &mut self, entity_kind: EntityKind, storage: &dyn crate::storage::StorageEngine, schema: &crate::schema::Schema, ) -> Result { use std::collections::HashSet; use super::lifecycle::ops::ARCHIVED_PREFIX; use crate::storage::{Tag, keys::parse_key}; // Suffix marker that distinguishes embedding keys from other Tag::Meta keys. const EMB_PREFIX: &[u8] = b"EMB:"; // Schema-declared dimensions for this kind, keyed by slot name. Used only // as a cross-check against the authoritative stored header. let declared_dims: HashMap<&str, usize> = schema .embedding_slots() .iter() .filter(|s| s.entity_kind == entity_kind) .map(|s| (s.name.as_str(), s.dimensions)) .collect(); // Single scan: tombstones (ARCHIVED:) and embeddings (EMB:) are interleaved // under Tag::Meta in lexicographic order, and a tombstone may appear AFTER // its embedding, so we cannot decide an embedding's fate the moment we see // it. Instead, one pass partitions the keyspace into the archived set and // the embedding candidates (already deserialized), then we filter and index // afterwards. This replaces the previous two full O(N) store scans, and the // candidate owns its slot `String` so the archived-set lookup borrows it // rather than allocating a throwaway `String` per embedding key. let mut archived: HashSet<(u64, String)> = HashSet::new(); let mut candidates: Vec<(crate::schema::EntityId, String, Vec)> = Vec::new(); // Count of embeddings skipped due to a per-row fault (un-deserializable, // zero-dim, or un-indexable). Reported at the end so a degraded rebuild is // observable rather than silent. let mut skipped = 0usize; for entry in storage.scan_prefix(&[]) { let (key, value) = entry.map_err(|e| { VectorError::Io(std::io::Error::other(format!( "embedding rebuild scan failed: {e}" ))) })?; // Only Tag::Meta keys carry embeddings or archive tombstones. let Some((entity_id, Tag::Meta, suffix)) = parse_key(&key) else { continue; }; if let Some(slot_bytes) = suffix.strip_prefix(ARCHIVED_PREFIX) { // A non-UTF-8 slot name cannot have been produced by the writer; skip. if let Ok(slot_name) = std::str::from_utf8(slot_bytes) { archived.insert((entity_id.as_u64(), slot_name.to_string())); } continue; } let Some(slot_bytes) = suffix.strip_prefix(EMB_PREFIX) else { // Plain metadata (title, etc.) — not an embedding or tombstone. continue; }; // A non-UTF-8 slot name cannot have been produced by // embedding_store_key; skip it rather than abort the rebuild. let Ok(slot_name) = std::str::from_utf8(slot_bytes) else { tracing::warn!( entity_id = entity_id.as_u64(), "skipping embedding key with non-UTF-8 slot name during rebuild" ); continue; }; // Deserialize the source-of-truth vector. A single un-deserializable // embedding (corrupt header, hand-edited store) must NOT abort the // rebuild for the whole entity kind — that would leave ANN search empty // for every good embedding too. Isolate the fault: warn, count it, and // skip this one row. The durable value is untouched; the next write // re-inserts it. (W: one poison row must not brick the kind.) let vector = match super::deserialize_embedding(&value) { Ok(v) => v, Err(e) => { tracing::warn!( entity_id = entity_id.as_u64(), slot = slot_name, error = %e, "skipping un-deserializable embedding during rebuild" ); skipped += 1; continue; } }; candidates.push((entity_id, slot_name.to_string(), vector)); } // Group the surviving (non-archived, non-degenerate) embeddings by slot so // each slot's vector count is known before its backend is built. The count // drives the backend selection (USearch HNSW for large slots, brute force // for small) AND the capacity reservation, with NO external config plumbing. let mut by_slot: HashMap)>> = HashMap::new(); for (entity_id, slot_name, vector) in candidates { // Skip embeddings that were soft-deleted (archived). The source-of-truth // value is intentionally retained on disk, but it must not re-enter the // ANN index — that is the whole point of the durable tombstone. if archived.contains(&(entity_id.as_u64(), slot_name.clone())) { continue; } // Reject a zero-dimension vector. The write path can never produce one // (`l2_normalize` rejects an empty vector before anything is stored), // but `serialize_embedding(&[])` is a valid 4-byte payload that // `deserialize_embedding` accepts, so a corrupt / hand-crafted store // could carry one. A 0-dim slot makes `l2_distance_sq` return 0.0 for // every query, so the entity would tie for nearest on every search and // pollute results. Skip it loudly. (W: corrupt 0-dim slot.) if vector.is_empty() { tracing::warn!( entity_id = entity_id.as_u64(), slot = slot_name.as_str(), "skipping zero-dimension embedding during rebuild (corrupt store)" ); skipped += 1; continue; } by_slot .entry(slot_name) .or_default() .push((entity_id, vector)); } let mut inserted = 0usize; for (slot_name, entries) in by_slot { // The slot's dimensionality is the FIRST stored vector's length; any // later vector of a different length is fault-isolated below. Cross- // check against the schema declaration (advisory only — the stored // header is authoritative for what was actually written). let Some((_, first_vec)) = entries.first() else { continue; }; let dimensions = first_vec.len(); if let Some(&declared) = declared_dims.get(slot_name.as_str()) && declared != dimensions { tracing::warn!( slot = slot_name.as_str(), declared, stored = dimensions, "embedding dimension mismatch between schema and stored vector during rebuild; \ using stored dimensionality" ); } // Lazily register the slot on first sight, routing the backend through // the size-gated factory: a slot with >= USEARCH_MIN_VECTORS live // vectors is rebuilt as the production HNSW (USearch) engine; smaller // slots stay exact (brute force). This is the live path that makes // USearch reachable in production restarts — no config flag required. if self.get(entity_kind, &slot_name).is_none() { let state = EmbeddingSlotState { index: build_slot_index(dimensions, entries.len()), dimensions, quantization: QuantizationLevel::F32, source: EmbeddingSource::External, params: HnswParams::default(), }; self.register(entity_kind, slot_name.clone(), state)?; } let slot = self.get(entity_kind, &slot_name).ok_or_else(|| { VectorError::Backend(format!( "slot ({entity_kind}, \"{slot_name}\") missing immediately after registration" )) })?; for (entity_id, vector) in entries { // Fault-isolate the per-embedding insert: a single row whose // dimensionality differs from the slot's (e.g. a schema-revision // mismatch that still self-deserializes) returns DimensionMismatch. // Skip it loudly rather than abort the kind's rebuild — the // hundreds of thousands of good embeddings for the same slot must // still be indexed. (W: one poison row must not brick the kind.) if let Err(e) = slot.index.insert(entity_id.as_u64(), &vector) { tracing::warn!( entity_id = entity_id.as_u64(), slot = slot_name.as_str(), error = %e, "skipping un-indexable embedding during rebuild" ); skipped += 1; continue; } inserted += 1; } } if inserted > 0 || skipped > 0 { tracing::info!( entity_kind = %entity_kind, vectors = inserted, skipped, "embedding indexes rebuilt from durable storage" ); } Ok(inserted) } } impl Default for EmbeddingSlotRegistry { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::storage::engine::StorageEngine; use crate::{ schema::EntityKind, storage::vector::{BruteForceIndex, QuantizationLevel, VectorIndexConfig}, }; /// Helper: create a slot state with the given dimensions. fn make_slot(dimensions: usize, source: EmbeddingSource) -> EmbeddingSlotState { let config = VectorIndexConfig { dimensions, ..VectorIndexConfig::default() }; EmbeddingSlotState { index: Box::new(BruteForceIndex::new(config)), dimensions, quantization: QuantizationLevel::F16, source, params: HnswParams::default(), } } #[test] fn registry_register_and_lookup() { let mut registry = EmbeddingSlotRegistry::new(); let state = make_slot(1536, EmbeddingSource::External); registry .register(EntityKind::Item, "content".into(), state) .unwrap(); let slot = registry.get(EntityKind::Item, "content"); assert!(slot.is_some()); let slot = slot.unwrap(); assert_eq!(slot.dimensions, 1536); assert_eq!(slot.source, EmbeddingSource::External); } #[test] fn registry_duplicate_slot_fails() { let mut registry = EmbeddingSlotRegistry::new(); let state1 = make_slot(1536, EmbeddingSource::External); let state2 = make_slot(1536, EmbeddingSource::External); registry .register(EntityKind::Item, "content".into(), state1) .unwrap(); let result = registry.register(EntityKind::Item, "content".into(), state2); assert!(result.is_err()); } #[test] fn registry_different_entity_kinds_same_name() { let mut registry = EmbeddingSlotRegistry::new(); let state_item = make_slot(1536, EmbeddingSource::External); let state_user = make_slot(1536, EmbeddingSource::DatabaseManaged); registry .register(EntityKind::Item, "content".into(), state_item) .unwrap(); registry .register(EntityKind::User, "content".into(), state_user) .unwrap(); let item_slot = registry.get(EntityKind::Item, "content").unwrap(); let user_slot = registry.get(EntityKind::User, "content").unwrap(); assert_eq!(item_slot.source, EmbeddingSource::External); assert_eq!(user_slot.source, EmbeddingSource::DatabaseManaged); } #[test] fn registry_slots_for_entity_kind() { let mut registry = EmbeddingSlotRegistry::new(); for name in &["content", "visual", "audio"] { let state = make_slot(128, EmbeddingSource::External); registry .register(EntityKind::Item, (*name).to_string(), state) .unwrap(); } let mut slots = registry.slots_for(EntityKind::Item); slots.sort_unstable(); // HashMap order is nondeterministic assert_eq!(slots.len(), 3); assert!(slots.contains(&"content")); assert!(slots.contains(&"visual")); assert!(slots.contains(&"audio")); // No user slots let user_slots = registry.slots_for(EntityKind::User); assert!(user_slots.is_empty()); } #[test] fn registry_nonexistent_slot_returns_none() { let registry = EmbeddingSlotRegistry::new(); assert!(registry.get(EntityKind::Item, "content").is_none()); } #[test] fn registry_slot_count() { let mut registry = EmbeddingSlotRegistry::new(); assert_eq!(registry.slot_count(), 0); registry .register( EntityKind::Item, "content".into(), make_slot(1536, EmbeddingSource::External), ) .unwrap(); assert_eq!(registry.slot_count(), 1); registry .register( EntityKind::User, "preference".into(), make_slot(1536, EmbeddingSource::DatabaseManaged), ) .unwrap(); assert_eq!(registry.slot_count(), 2); } #[test] fn registry_get_mut() { let mut registry = EmbeddingSlotRegistry::new(); registry .register( EntityKind::Item, "content".into(), make_slot(1536, EmbeddingSource::External), ) .unwrap(); let slot = registry.get_mut(EntityKind::Item, "content").unwrap(); // Mutate dimensions to prove we have mutable access slot.dimensions = 512; let slot = registry.get(EntityKind::Item, "content").unwrap(); assert_eq!(slot.dimensions, 512); } #[test] fn index_stats_empty_registry() { let registry = EmbeddingSlotRegistry::new(); let (vectors, bytes) = registry.index_stats(); assert_eq!(vectors, 0); assert_eq!(bytes, 0); } #[test] fn index_stats_with_vectors() { let mut registry = EmbeddingSlotRegistry::new(); let dim = 128; let state = make_slot(dim, EmbeddingSource::External); registry .register(EntityKind::Item, "content".into(), state) .unwrap(); // Insert a vector. let slot = registry.get(EntityKind::Item, "content").unwrap(); let embedding = vec![0.1_f32; dim]; slot.index.insert(1, &embedding).unwrap(); let (vectors, bytes) = registry.index_stats(); assert_eq!(vectors, 1); // 1 vector * 128 dims * 2 bytes (f16) = 256 bytes assert_eq!(bytes, 256); } #[test] fn registry_default_is_empty() { let registry = EmbeddingSlotRegistry::default(); assert_eq!(registry.slot_count(), 0); } #[test] fn hnsw_params_default() { let params = HnswParams::default(); assert_eq!(params.connectivity, 16); assert_eq!(params.ef_construction, 400); assert_eq!(params.ef_search, 200); } #[test] fn index_stats_uses_registered_precision_for_f32() { // Regression: index_stats previously hardcoded f16 (*2), undercounting // the F32 slots that production actually registers by 2x. let mut registry = EmbeddingSlotRegistry::new(); let dim = 128; let config = VectorIndexConfig { dimensions: dim, ..VectorIndexConfig::default() }; let state = EmbeddingSlotState { index: Box::new(BruteForceIndex::new(config)), dimensions: dim, quantization: QuantizationLevel::F32, source: EmbeddingSource::External, params: HnswParams::default(), }; registry .register(EntityKind::Item, "content".into(), state) .unwrap(); let slot = registry.get(EntityKind::Item, "content").unwrap(); slot.index.insert(1, &vec![0.1_f32; dim]).unwrap(); let (vectors, bytes) = registry.index_stats(); assert_eq!(vectors, 1); // 1 vector * 128 dims * 4 bytes (f32) = 512 bytes. assert_eq!(bytes, 512); } // ------------------------------------------------------------------- // rebuild_from_store // ------------------------------------------------------------------- #[test] fn rebuild_from_store_repopulates_index_and_is_searchable() { use crate::{ schema::{DecaySpec, EntityId, SchemaBuilder}, storage::{ memory::InMemoryBackend, vector::{embedding_store_key, serialize_embedding}, }, }; // Schema declaring a single Item embedding slot (dimensions cross-checked // against the stored header during rebuild). let dim = 4; let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder.embedding_slot("content", EntityKind::Item, dim); let schema = builder.build().expect("valid schema"); // Pre-populate a store with durable EMB:content embeddings, exactly as // the write path would. Vectors are NOT normalized here on purpose: // rebuild stores whatever is durable, the source of truth. let storage = InMemoryBackend::new(); let vectors: [(u64, [f32; 4]); 3] = [ (1, [1.0, 0.0, 0.0, 0.0]), (2, [0.0, 1.0, 0.0, 0.0]), (3, [0.0, 0.0, 1.0, 0.0]), ]; for (id, v) in &vectors { let key = embedding_store_key(EntityId::new(*id), "content"); storage.put(&key, &serialize_embedding(v)).unwrap(); } // A non-embedding Tag::Meta key must be ignored by the scan. let meta_key = crate::storage::encode_key(EntityId::new(1), crate::storage::Tag::Meta, b"title"); storage.put(&meta_key, b"hello").unwrap(); // Rebuild an empty registry from the store. let mut registry = EmbeddingSlotRegistry::new(); let inserted = registry .rebuild_from_store(EntityKind::Item, &storage, &schema) .expect("rebuild succeeds"); assert_eq!(inserted, 3, "all three embeddings should be re-indexed"); let slot = registry .get(EntityKind::Item, "content") .expect("slot registered during rebuild"); assert_eq!(slot.dimensions, dim); assert_eq!(slot.quantization, QuantizationLevel::F32); assert_eq!(slot.source, EmbeddingSource::External); assert_eq!(slot.index.len(), 3); // ANN search must now return the rebuilt vectors (the whole point of the // fix: ANN search returned nothing after reopen before this existed). let results = slot.index.search(&[1.0, 0.0, 0.0, 0.0], 1, 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].id, 1, "nearest neighbor of e0 is vector id 1"); } #[test] fn rebuild_skips_soft_deleted_embedding() { // Finding-1 regression: a soft delete must survive a restart. We write // an embedding through the real lifecycle path, soft-delete it (writing a // durable tombstone), then rebuild a *fresh* registry from the same store // — simulating a process restart — and assert the vector is absent from // both the index and ANN search results. use crate::{ schema::{DecaySpec, EntityId, SchemaBuilder}, storage::{ memory::InMemoryBackend, vector::{BruteForceIndex, VectorIndexConfig, delete_embedding, insert_embedding}, }, }; let dim = 4; let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder.embedding_slot("content", EntityKind::Item, dim); let schema = builder.build().expect("valid schema"); let storage = InMemoryBackend::new(); // A scratch index used only to drive the live write/delete path; it is // discarded before the rebuild (the rebuild constructs its own). let scratch = BruteForceIndex::new(VectorIndexConfig { dimensions: dim, ..VectorIndexConfig::default() }); // Two embeddings; soft-delete the first, keep the second. insert_embedding( EntityId::new(1), "content", &[1.0, 0.0, 0.0, 0.0], dim, &scratch, &storage, ) .unwrap(); insert_embedding( EntityId::new(2), "content", &[0.0, 1.0, 0.0, 0.0], dim, &scratch, &storage, ) .unwrap(); delete_embedding(EntityId::new(1), "content", &scratch, &storage, false).unwrap(); drop(scratch); // Simulate restart: fresh registry rebuilt from durable storage. let mut registry = EmbeddingSlotRegistry::new(); let inserted = registry .rebuild_from_store(EntityKind::Item, &storage, &schema) .expect("rebuild succeeds"); // Only the non-archived embedding (id 2) is re-indexed. assert_eq!(inserted, 1, "soft-deleted embedding must not be re-indexed"); let slot = registry .get(EntityKind::Item, "content") .expect("slot registered during rebuild"); assert_eq!(slot.index.len(), 1); // ANN search must not surface the soft-deleted vector even when queried // with its own embedding. let results = slot.index.search(&[1.0, 0.0, 0.0, 0.0], 10, 0).unwrap(); assert!( results.iter().all(|r| r.id != 1), "soft-deleted vector 1 resurfaced after rebuild" ); } #[test] fn rebuild_from_store_empty_store_inserts_nothing() { use crate::{ schema::{DecaySpec, SchemaBuilder}, storage::memory::InMemoryBackend, }; let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder.embedding_slot("content", EntityKind::Item, 4); let schema = builder.build().expect("valid schema"); let storage = InMemoryBackend::new(); let mut registry = EmbeddingSlotRegistry::new(); let inserted = registry .rebuild_from_store(EntityKind::Item, &storage, &schema) .expect("rebuild succeeds on empty store"); assert_eq!(inserted, 0); // No slot is registered when there is nothing durable to index. assert!(registry.get(EntityKind::Item, "content").is_none()); } // ------------------------------------------------------------------- // build_slot_index backend selection (USearch wiring) — finding 5 // ------------------------------------------------------------------- /// CRITICAL regression: the size-gated factory must build the production /// HNSW (`UsearchIndex`) at/above the threshold and the exact `BruteForceIndex` /// below it, and BOTH must be functional. Before the fix `USearch` was wired /// into nothing and every slot was brute force. #[test] fn build_slot_index_selects_backend_by_count() { // Below threshold: exact brute force, functional. let small = build_slot_index(4, 0); small.insert(1, &[1.0, 0.0, 0.0, 0.0]).unwrap(); small.insert(2, &[0.0, 1.0, 0.0, 0.0]).unwrap(); let r = small.search(&[1.0, 0.0, 0.0, 0.0], 1, 0).unwrap(); assert_eq!(r[0].id, 1, "brute-force backend must return the nearest"); // At threshold: USearch (HNSW), with capacity reserved by the factory so // inserts succeed without a manual `reserve`. Searchable end-to-end — // proving the production engine is actually reachable, not dead code. let big = build_slot_index(4, USEARCH_MIN_VECTORS); big.insert(10, &[1.0, 0.0, 0.0, 0.0]).unwrap(); big.insert(20, &[0.0, 1.0, 0.0, 0.0]).unwrap(); let r = big.search(&[1.0, 0.0, 0.0, 0.0], 1, big.len()).unwrap(); assert_eq!( r[0].id, 10, "USearch backend must be constructed AND searchable at the threshold" ); } // ------------------------------------------------------------------- // rebuild fault isolation — findings 8 and 13 // ------------------------------------------------------------------- /// W regression: a single un-indexable embedding (a different dimension under /// the same slot) must NOT abort the rebuild — the good embeddings for the /// kind are still indexed and searchable; only the poison row is skipped. #[test] fn rebuild_isolates_dimension_mismatch_and_zero_dim() { use crate::{ schema::{DecaySpec, EntityId, SchemaBuilder}, storage::{ memory::InMemoryBackend, vector::{embedding_store_key, serialize_embedding}, }, }; let dim = 4; let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder.embedding_slot("content", EntityKind::Item, dim); let schema = builder.build().expect("valid schema"); let storage = InMemoryBackend::new(); // Two valid 4-D embeddings. for (id, v) in &[ (1u64, [1.0f32, 0.0, 0.0, 0.0]), (2u64, [0.0f32, 1.0, 0.0, 0.0]), ] { let key = embedding_store_key(EntityId::new(*id), "content"); storage.put(&key, &serialize_embedding(v)).unwrap(); } // One poison row: a 2-D vector under the SAME slot. Its stored header is // self-consistent (deserializes fine) but its dimension differs from the // slot's (4) → DimensionMismatch on insert. It must be skipped, not abort. let bad_key = embedding_store_key(EntityId::new(3), "content"); storage .put(&bad_key, &serialize_embedding(&[1.0, 1.0])) .unwrap(); // One zero-dimension row (corrupt store): a valid 4-byte payload that // deserializes to an empty vector. Must be skipped (finding 13). let empty_key = embedding_store_key(EntityId::new(4), "content"); storage.put(&empty_key, &serialize_embedding(&[])).unwrap(); let mut registry = EmbeddingSlotRegistry::new(); let inserted = registry .rebuild_from_store(EntityKind::Item, &storage, &schema) .expect("rebuild must not abort on a poison row"); assert_eq!( inserted, 2, "the two valid embeddings are indexed; the mismatch and zero-dim rows are skipped" ); let slot = registry .get(EntityKind::Item, "content") .expect("slot registered from the valid embeddings"); assert_eq!( slot.dimensions, dim, "the slot must take the valid (4-D) dimensionality, not the corrupt 0-dim row" ); assert_eq!(slot.index.len(), 2); // The two valid embeddings are searchable; the bad ids never resurface. let results = slot.index.search(&[1.0, 0.0, 0.0, 0.0], 10, 0).unwrap(); assert!( results.iter().all(|r| r.id != 3 && r.id != 4), "poison rows (ids 3, 4) must not be indexed" ); assert!( results.iter().any(|r| r.id == 1), "valid embedding 1 must be searchable" ); } }