Skip to main content

khive_runtime/
embedder_registry.rs

1//! EmbedderRegistry — pack-extensible embedding provider surface.
2//!
3//! Packs implement [`EmbedderProvider`] and register custom models via
4//! [`crate::KhiveRuntime::register_embedder`]. Built-in lattice models are pre-registered
5//! during runtime construction and require no opt-in.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use lattice_embed::{
12    CachedEmbeddingService, EmbeddingModel, EmbeddingService, NativeEmbeddingService,
13};
14use tokio::sync::OnceCell;
15
16use crate::error::{RuntimeError, RuntimeResult};
17
18/// A source that can produce an [`EmbeddingService`] by name.
19///
20/// Packs implement this trait to register custom embedding backends.
21/// The runtime calls [`build`](EmbedderProvider::build) lazily — once per
22/// process per model — and caches the result. Subsequent calls to
23/// `KhiveRuntime::embedder(name)` are cheap.
24///
25/// Built-in lattice models are registered automatically via
26/// [`LatticeEmbedderProvider`]; packs need not re-register them.
27#[async_trait]
28pub trait EmbedderProvider: Send + Sync {
29    /// Stable, case-sensitive name for this embedder.
30    ///
31    /// Must be unique across all registered providers. The name is used as
32    /// the key in `KhiveRuntime::embedder(name)` lookups and as the storage
33    /// table suffix for vector indices. Use the model's canonical short form
34    /// (e.g. `"all-minilm-l6-v2"`, `"my-custom-encoder"`).
35    fn name(&self) -> &str;
36
37    /// Output vector dimension for this embedder.
38    ///
39    /// Must be consistent with what [`build`](Self::build) produces.
40    /// The runtime uses this to pre-register the vector store columns.
41    fn dimensions(&self) -> usize;
42
43    /// Construct the underlying [`EmbeddingService`].
44    ///
45    /// Called at most once per process. The result is cached in a
46    /// [`OnceCell`]; concurrent callers block on the first call and share
47    /// the result thereafter.
48    async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>>;
49}
50
51/// An entry in the [`EmbedderRegistry`] combining a provider with its
52/// lazy-initialized service.
53pub(crate) struct EmbedderEntry {
54    provider: Arc<dyn EmbedderProvider>,
55    cell: Arc<OnceCell<Arc<dyn EmbeddingService>>>,
56}
57
58impl Clone for EmbedderEntry {
59    fn clone(&self) -> Self {
60        Self {
61            provider: Arc::clone(&self.provider),
62            cell: Arc::clone(&self.cell),
63        }
64    }
65}
66
67/// Registry of named [`EmbedderProvider`] instances.
68///
69/// Built during `KhiveRuntime` construction and optionally extended by packs
70/// via [`crate::KhiveRuntime::register_embedder`]. The registry is internally
71/// reference-counted so `KhiveRuntime::clone()` shares the same providers
72/// and cached service instances.
73#[derive(Clone, Default)]
74pub struct EmbedderRegistry {
75    entries: HashMap<String, EmbedderEntry>,
76}
77
78impl EmbedderRegistry {
79    /// Create an empty registry.
80    pub fn new() -> Self {
81        Self {
82            entries: HashMap::new(),
83        }
84    }
85
86    /// Register a provider.
87    ///
88    /// If a provider with the same [`name`](EmbedderProvider::name) already
89    /// exists, it is replaced (last-writer wins) and any cached service is
90    /// discarded, since pack registration order is not guaranteed and packs
91    /// may legitimately override a default model under the same name.
92    /// Callers needing strict collision detection should check
93    /// [`names`](Self::names) before registering.
94    pub fn register<P: EmbedderProvider + 'static>(&mut self, provider: P) {
95        let name = provider.name().to_owned();
96        self.entries.insert(
97            name,
98            EmbedderEntry {
99                provider: Arc::new(provider),
100                cell: Arc::new(OnceCell::new()),
101            },
102        );
103    }
104
105    /// Look up a provider by name.
106    pub fn get_provider(&self, name: &str) -> Option<&dyn EmbedderProvider> {
107        self.entries.get(name).map(|e| e.provider.as_ref())
108    }
109
110    /// Returns `true` if a provider with this name is registered.
111    pub fn contains(&self, name: &str) -> bool {
112        self.entries.contains_key(name)
113    }
114
115    /// Names of all registered providers, in unspecified order.
116    pub fn names(&self) -> Vec<String> {
117        self.entries.keys().cloned().collect()
118    }
119
120    /// Return a cloned entry for `name` without holding any lock.
121    ///
122    /// The caller can then call [`EmbedderEntry::resolve`] without holding
123    /// a lock — this avoids holding a `RwLockGuard` across `await` points.
124    /// Returns `None` if `name` is not registered.
125    pub(crate) fn get_entry(&self, name: &str) -> Option<EmbedderEntry> {
126        self.entries.get(name).cloned()
127    }
128
129    /// Lazily resolve a registered provider to its live [`EmbeddingService`].
130    ///
131    /// Returns [`RuntimeError::UnknownModel`] if `name` is not registered.
132    /// The first call for a given name triggers [`EmbedderProvider::build`];
133    /// subsequent calls return the cached `Arc`.
134    ///
135    /// Prefer [`crate::KhiveRuntime::embedder`] over calling this directly from pack
136    /// handlers — the runtime method handles alias resolution and error mapping.
137    pub async fn get_service(&self, name: &str) -> RuntimeResult<Arc<dyn EmbeddingService>> {
138        let entry = self
139            .entries
140            .get(name)
141            .ok_or_else(|| RuntimeError::UnknownModel(name.to_string()))?
142            .clone();
143
144        entry.resolve().await
145    }
146}
147
148impl EmbedderEntry {
149    /// Lazily initialise and return the embedding service for this entry.
150    ///
151    /// The `OnceCell` guarantees that `build` is called at most once even
152    /// under concurrent access. Callers hold no external lock while awaiting.
153    ///
154    /// Returns `RuntimeError` if `build()` fails, rather than panicking.
155    pub(crate) async fn resolve(self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
156        // `OnceCell` has no fallible init, so failure is handled manually; a losing
157        // racer's build() result is simply discarded since both are equivalent.
158        if let Some(svc) = self.cell.get() {
159            return Ok(Arc::clone(svc));
160        }
161        let svc = self.provider.build().await.map_err(|e| {
162            crate::error::RuntimeError::Internal(format!(
163                "EmbedderProvider '{}' build() failed: {e}",
164                self.provider.name()
165            ))
166        })?;
167        // A losing `set` (raced by another task) is fine: the two results are equivalent.
168        let _ = self.cell.set(Arc::clone(&svc));
169        Ok(svc)
170    }
171}
172
173// ── LatticeEmbedderProvider ───────────────────────────────────────────────────
174
175/// Adapter that wraps a [`lattice_embed::EmbeddingModel`] as an
176/// [`EmbedderProvider`].
177///
178/// All built-in models (MiniLM, paraphrase-multilingual, BGE variants, etc.)
179/// are registered as `LatticeEmbedderProvider` instances during
180/// `KhiveRuntime` construction. External callers do not need to use this type
181/// unless they are constructing a custom registry from scratch.
182pub struct LatticeEmbedderProvider {
183    model: EmbeddingModel,
184    /// Cached `to_string()` result so `name()` can return `&str`.
185    name: String,
186}
187
188impl LatticeEmbedderProvider {
189    /// Create a new provider wrapping the given lattice model.
190    pub fn new(model: EmbeddingModel) -> Self {
191        let name = model.to_string();
192        Self { model, name }
193    }
194}
195
196#[async_trait]
197impl EmbedderProvider for LatticeEmbedderProvider {
198    fn name(&self) -> &str {
199        &self.name
200    }
201
202    fn dimensions(&self) -> usize {
203        self.model.dimensions()
204    }
205
206    async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
207        let native = Arc::new(NativeEmbeddingService::with_model(self.model));
208        let cached = CachedEmbeddingService::with_default_cache(native);
209        Ok(Arc::new(cached) as Arc<dyn EmbeddingService>)
210    }
211}
212
213// ── Unit tests ────────────────────────────────────────────────────────────────
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::sync::atomic::{AtomicUsize, Ordering};
219
220    struct ConstVecProvider {
221        name: String,
222        dims: usize,
223        build_calls: Arc<AtomicUsize>,
224    }
225
226    impl ConstVecProvider {
227        fn new(name: &str, dims: usize) -> Self {
228            Self {
229                name: name.to_owned(),
230                dims,
231                build_calls: Arc::new(AtomicUsize::new(0)),
232            }
233        }
234    }
235
236    /// A trivial embedding service that returns a constant vector of `1.0`s.
237    /// The `model` parameter is ignored — this service always returns the
238    /// same synthetic vector regardless of which model is requested.
239    struct ConstVecService {
240        dims: usize,
241    }
242
243    #[async_trait]
244    impl EmbeddingService for ConstVecService {
245        async fn embed(
246            &self,
247            texts: &[String],
248            _model: EmbeddingModel,
249        ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
250            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
251        }
252
253        fn supports_model(&self, _model: EmbeddingModel) -> bool {
254            true
255        }
256
257        fn name(&self) -> &'static str {
258            "const-vec-service"
259        }
260    }
261
262    #[async_trait]
263    impl EmbedderProvider for ConstVecProvider {
264        fn name(&self) -> &str {
265            &self.name
266        }
267
268        fn dimensions(&self) -> usize {
269            self.dims
270        }
271
272        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
273            self.build_calls.fetch_add(1, Ordering::SeqCst);
274            Ok(Arc::new(ConstVecService { dims: self.dims }))
275        }
276    }
277
278    #[test]
279    fn register_and_get_provider_round_trip() {
280        let mut reg = EmbedderRegistry::new();
281        reg.register(ConstVecProvider::new("mock-384", 384));
282
283        assert!(reg.contains("mock-384"), "registered name must be present");
284        let provider = reg.get_provider("mock-384").expect("provider must exist");
285        assert_eq!(provider.name(), "mock-384");
286        assert_eq!(provider.dimensions(), 384);
287    }
288
289    #[test]
290    fn duplicate_name_last_wins() {
291        let mut reg = EmbedderRegistry::new();
292        reg.register(ConstVecProvider::new("shared", 128));
293        reg.register(ConstVecProvider::new("shared", 256));
294
295        let provider = reg.get_provider("shared").expect("provider must exist");
296        assert_eq!(
297            provider.dimensions(),
298            256,
299            "last registration must win; expected dims=256"
300        );
301    }
302
303    #[test]
304    fn names_returns_all_registered() {
305        let mut reg = EmbedderRegistry::new();
306        reg.register(ConstVecProvider::new("model-a", 64));
307        reg.register(ConstVecProvider::new("model-b", 128));
308        reg.register(ConstVecProvider::new("model-c", 256));
309
310        let mut names = reg.names();
311        names.sort();
312        assert_eq!(names, vec!["model-a", "model-b", "model-c"]);
313    }
314
315    #[tokio::test]
316    async fn get_service_unknown_name_returns_error() {
317        let reg = EmbedderRegistry::new();
318        let result = reg.get_service("does-not-exist").await;
319        let err = result.err().expect("expected Err for unknown name, got Ok");
320        assert!(
321            matches!(err, RuntimeError::UnknownModel(ref n) if n == "does-not-exist"),
322            "expected UnknownModel, got {err:?}"
323        );
324    }
325
326    #[tokio::test]
327    async fn get_service_calls_build_once() {
328        let counter = Arc::new(AtomicUsize::new(0));
329        let provider = ConstVecProvider {
330            name: "cached-model".to_owned(),
331            dims: 32,
332            build_calls: Arc::clone(&counter),
333        };
334        let mut reg = EmbedderRegistry::new();
335        reg.register(provider);
336
337        let _ = reg.get_service("cached-model").await.unwrap();
338        let _ = reg.get_service("cached-model").await.unwrap();
339        let _ = reg.get_service("cached-model").await.unwrap();
340
341        assert_eq!(
342            counter.load(Ordering::SeqCst),
343            1,
344            "build must be called exactly once regardless of get_service call count"
345        );
346    }
347}