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::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
9use std::sync::{Arc, OnceLock};
10
11use async_trait::async_trait;
12use lattice_embed::{
13    CachedEmbeddingService, EmbeddingModel, EmbeddingRole, EmbeddingService,
14    NativeEmbeddingService, DEFAULT_MAX_BATCH_SIZE, MAX_TEXT_BYTES,
15};
16use tokio::sync::{mpsc, Notify, OnceCell};
17
18use crate::error::{RuntimeError, RuntimeResult};
19
20const ADMISSION_WAITING: u8 = 1;
21const ADMISSION_ACCEPTED: u8 = 2;
22
23struct EmbeddingAdmission {
24    deadline: khive_storage::RequestReadDeadline,
25    request: khive_storage::RequestReadContext,
26    state: AtomicU8,
27    changed: Notify,
28}
29
30impl EmbeddingAdmission {
31    fn expired(&self) -> bool {
32        tokio::time::Instant::now() >= self.deadline.async_at()
33            || self.request.stop_reason().is_some()
34    }
35}
36
37tokio::task_local! {
38    static EMBEDDING_ADMISSION: Arc<EmbeddingAdmission>;
39}
40
41/// Bound only the built-in worker's pre-admission wait, above the lattice error
42/// boundary. Providers that never enter that wait retain their own semantics.
43pub(crate) async fn with_embedding_admission<T>(
44    future: impl std::future::Future<Output = lattice_embed::Result<T>>,
45) -> RuntimeResult<T> {
46    let deadline = khive_storage::effective_request_read_deadline(
47        khive_storage::RequestReadDeadline::after(khive_storage::request_read_timeout_from_env()),
48    );
49    let timeout = deadline
50        .async_at()
51        .saturating_duration_since(tokio::time::Instant::now());
52    let admission = Arc::new(EmbeddingAdmission {
53        deadline,
54        request: khive_storage::capture_request_read_context(),
55        state: AtomicU8::new(0),
56        changed: Notify::new(),
57    });
58    EMBEDDING_ADMISSION
59        .scope(Arc::clone(&admission), async move {
60            tokio::pin!(future);
61            let stopped = async {
62                tokio::select! {
63                    _ = tokio::time::sleep_until(deadline.async_at()) => {},
64                    _ = admission.request.clone().wait_for_stop() => {},
65                }
66            };
67            tokio::pin!(stopped);
68            let mut bound_expired = false;
69            loop {
70                tokio::select! {
71                    // A ready cache hit precedes admission, including an expired bound.
72                    biased;
73                    result = &mut future => return result.map_err(RuntimeError::from),
74                    _ = admission.changed.notified() => {},
75                    _ = &mut stopped, if !bound_expired => bound_expired = true,
76                }
77                match admission.state.load(Ordering::Acquire) {
78                    ADMISSION_ACCEPTED => return future.await.map_err(RuntimeError::from),
79                    ADMISSION_WAITING if bound_expired => {
80                        return Err(RuntimeError::Storage(
81                            khive_storage::StorageError::AdmissionTimeout {
82                                operation: "embedding admission".into(),
83                                timeout_ms: u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
84                                pool_identity: None,
85                            },
86                        ));
87                    }
88                    // A provider may enter the owned adapter later. Keep the
89                    // original expired bound without timing out its other work.
90                    _ => {}
91                }
92            }
93        })
94        .await
95}
96
97#[derive(Clone, Copy)]
98enum EmbeddingCall {
99    Generic,
100    Query,
101    Passage,
102}
103
104const EMBEDDING_QUEUE_CAPACITY: usize = 32;
105const EMBEDDING_MAX_JOB_BYTES: usize = DEFAULT_MAX_BATCH_SIZE * MAX_TEXT_BYTES;
106// 32 queue slots × the normal 128-text batch × 32 KiB/text = 128 MiB in flight.
107const EMBEDDING_QUEUE_BYTE_BUDGET: usize = EMBEDDING_QUEUE_CAPACITY * 128 * MAX_TEXT_BYTES;
108
109struct InFlightBytes {
110    counter: Arc<AtomicUsize>,
111    bytes: usize,
112}
113
114impl InFlightBytes {
115    fn reserve(
116        counter: Arc<AtomicUsize>,
117        byte_budget: usize,
118        bytes: usize,
119    ) -> lattice_embed::Result<Self> {
120        let mut current = counter.load(Ordering::Acquire);
121        loop {
122            let Some(next) = current.checked_add(bytes) else {
123                return Err(lattice_embed::EmbedError::Internal(format!(
124                    "embedding worker byte budget exceeded: in-flight byte count overflowed the {byte_budget}-byte budget"
125                )));
126            };
127            if next > byte_budget {
128                return Err(lattice_embed::EmbedError::Internal(format!(
129                    "embedding worker byte budget exceeded: {current} in flight + {bytes} job bytes > {byte_budget}"
130                )));
131            }
132            match counter.compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Acquire)
133            {
134                Ok(_) => return Ok(Self { counter, bytes }),
135                Err(observed) => current = observed,
136            }
137        }
138    }
139}
140
141impl Drop for InFlightBytes {
142    fn drop(&mut self) {
143        let previous = self.counter.fetch_sub(self.bytes, Ordering::AcqRel);
144        debug_assert!(previous >= self.bytes, "embedding byte counter underflow");
145    }
146}
147
148struct EmbeddingJob {
149    texts: Vec<String>,
150    model: EmbeddingModel,
151    call: EmbeddingCall,
152    reply: tokio::sync::oneshot::Sender<lattice_embed::Result<Vec<Vec<f32>>>>,
153    _in_flight: InFlightBytes,
154}
155
156/// Bounds non-cancellable native inference to one worker and a fixed queue.
157/// Callers can detach safely because closed queued jobs are skipped before inference.
158pub(crate) struct BlockingEmbeddingService<S> {
159    inner: Arc<S>,
160    worker: OnceLock<Result<mpsc::Sender<EmbeddingJob>, String>>,
161    in_flight_bytes: Arc<AtomicUsize>,
162    byte_budget: usize,
163}
164
165impl<S> BlockingEmbeddingService<S> {
166    pub(crate) fn new(inner: Arc<S>) -> Self {
167        Self {
168            inner,
169            worker: OnceLock::new(),
170            in_flight_bytes: Arc::new(AtomicUsize::new(0)),
171            byte_budget: EMBEDDING_QUEUE_BYTE_BUDGET,
172        }
173    }
174
175    #[cfg(test)]
176    fn with_byte_budget(inner: Arc<S>, byte_budget: usize) -> Self {
177        Self {
178            inner,
179            worker: OnceLock::new(),
180            in_flight_bytes: Arc::new(AtomicUsize::new(0)),
181            byte_budget,
182        }
183    }
184}
185
186impl<S: EmbeddingService + 'static> BlockingEmbeddingService<S> {
187    fn input_bytes(texts: &[String]) -> lattice_embed::Result<usize> {
188        if texts.is_empty() {
189            return Err(lattice_embed::EmbedError::InvalidInput(
190                "no texts provided".to_owned(),
191            ));
192        }
193        let input_bytes = texts.iter().try_fold(0usize, |total, text| {
194            total.checked_add(text.len()).ok_or_else(|| {
195                lattice_embed::EmbedError::InvalidInput(format!(
196                    "embedding job input exceeds the {EMBEDDING_MAX_JOB_BYTES}-byte maximum"
197                ))
198            })
199        })?;
200        if input_bytes > EMBEDDING_MAX_JOB_BYTES {
201            return Err(lattice_embed::EmbedError::InvalidInput(format!(
202                "embedding job input is {input_bytes} bytes; maximum is {EMBEDDING_MAX_JOB_BYTES} bytes"
203            )));
204        }
205        if texts.len() > DEFAULT_MAX_BATCH_SIZE {
206            return Err(lattice_embed::EmbedError::InvalidInput(format!(
207                "batch size {} exceeds maximum {DEFAULT_MAX_BATCH_SIZE}",
208                texts.len()
209            )));
210        }
211        if let Some(text) = texts.iter().find(|text| text.len() > MAX_TEXT_BYTES) {
212            return Err(lattice_embed::EmbedError::TextTooLong {
213                length: text.len(),
214                max: MAX_TEXT_BYTES,
215            });
216        }
217        Ok(input_bytes)
218    }
219
220    fn worker(&self) -> lattice_embed::Result<&mpsc::Sender<EmbeddingJob>> {
221        self.worker
222            .get_or_init(|| {
223                let (sender, receiver) = mpsc::channel(EMBEDDING_QUEUE_CAPACITY);
224                let inner = Arc::clone(&self.inner);
225                let runtime = tokio::runtime::Handle::current();
226                std::thread::Builder::new()
227                    .name("khive-embedding".to_owned())
228                    .spawn(move || Self::run_worker(inner, runtime, receiver))
229                    .map(|_| sender)
230                    .map_err(|error| error.to_string())
231            })
232            .as_ref()
233            .map_err(|error| lattice_embed::EmbedError::Internal(error.clone()))
234    }
235
236    fn run_worker(
237        inner: Arc<S>,
238        runtime: tokio::runtime::Handle,
239        mut receiver: mpsc::Receiver<EmbeddingJob>,
240    ) {
241        while let Some(job) = receiver.blocking_recv() {
242            if job.reply.is_closed() {
243                continue;
244            }
245            let result = runtime.block_on(async {
246                match job.call {
247                    EmbeddingCall::Generic => inner.embed(&job.texts, job.model).await,
248                    EmbeddingCall::Query => inner.embed_query(&job.texts, job.model).await,
249                    EmbeddingCall::Passage => inner.embed_passage(&job.texts, job.model).await,
250                }
251            });
252            let _ = job.reply.send(result);
253        }
254    }
255
256    async fn run(
257        &self,
258        texts: &[String],
259        model: EmbeddingModel,
260        call: EmbeddingCall,
261    ) -> lattice_embed::Result<Vec<Vec<f32>>> {
262        let input_bytes = Self::input_bytes(texts)?;
263        let sender = self.worker()?;
264        let admission = EMBEDDING_ADMISSION.try_with(Arc::clone).ok();
265        let permit = if let Some(admission) = &admission {
266            admission.state.store(ADMISSION_WAITING, Ordering::Release);
267            admission.changed.notify_one();
268            if admission.expired() {
269                return std::future::pending().await;
270            }
271            sender.reserve().await.map_err(|_| {
272                lattice_embed::EmbedError::Internal(
273                    "embedding worker channel is disconnected".to_owned(),
274                )
275            })?
276        } else {
277            // Direct trait callers have no RuntimeResult boundary at which to
278            // report a typed admission timeout, so retain finite fail-fast admission.
279            sender.try_reserve().map_err(|error| match error {
280                mpsc::error::TrySendError::Full(_) => {
281                    lattice_embed::EmbedError::Internal("embedding worker queue is full".to_owned())
282                }
283                mpsc::error::TrySendError::Closed(_) => lattice_embed::EmbedError::Internal(
284                    "embedding worker channel is disconnected".to_owned(),
285                ),
286            })?
287        };
288        // Capacity and expiry can become ready in one poll. Do not publish a
289        // job after the original bound, even when reserve() returned a permit.
290        if admission
291            .as_ref()
292            .is_some_and(|admission| admission.expired())
293        {
294            return std::future::pending().await;
295        }
296        let in_flight = InFlightBytes::reserve(
297            Arc::clone(&self.in_flight_bytes),
298            self.byte_budget,
299            input_bytes,
300        )?;
301        let (reply, receiver) = tokio::sync::oneshot::channel();
302        let job = EmbeddingJob {
303            texts: texts.to_vec(),
304            model,
305            call,
306            reply,
307            _in_flight: in_flight,
308        };
309        if let Some(admission) = &admission {
310            if admission.expired() {
311                return std::future::pending().await;
312            }
313            admission.state.store(ADMISSION_ACCEPTED, Ordering::Release);
314            admission.changed.notify_one();
315        }
316        permit.send(job);
317        receiver
318            .await
319            .map_err(|error| lattice_embed::EmbedError::Internal(error.to_string()))?
320    }
321}
322
323#[async_trait]
324impl<S: EmbeddingService + 'static> EmbeddingService for BlockingEmbeddingService<S> {
325    async fn embed(
326        &self,
327        texts: &[String],
328        model: EmbeddingModel,
329    ) -> lattice_embed::Result<Vec<Vec<f32>>> {
330        self.run(texts, model, EmbeddingCall::Generic).await
331    }
332
333    async fn embed_with_role(
334        &self,
335        texts: &[String],
336        model: EmbeddingModel,
337        role: EmbeddingRole,
338    ) -> lattice_embed::Result<Vec<Vec<f32>>> {
339        // The outer cache delegates raw caller text here; preparation belongs
340        // to the native service, after the caller-input admission checks.
341        let call = match role {
342            EmbeddingRole::Generic => EmbeddingCall::Generic,
343            EmbeddingRole::Query => EmbeddingCall::Query,
344            EmbeddingRole::Passage => EmbeddingCall::Passage,
345            _ => {
346                return Err(lattice_embed::EmbedError::InvalidInput(
347                    "unsupported embedding role".to_owned(),
348                ))
349            }
350        };
351        self.run(texts, model, call).await
352    }
353
354    async fn embed_query(
355        &self,
356        texts: &[String],
357        model: EmbeddingModel,
358    ) -> lattice_embed::Result<Vec<Vec<f32>>> {
359        self.run(texts, model, EmbeddingCall::Query).await
360    }
361
362    async fn embed_passage(
363        &self,
364        texts: &[String],
365        model: EmbeddingModel,
366    ) -> lattice_embed::Result<Vec<Vec<f32>>> {
367        self.run(texts, model, EmbeddingCall::Passage).await
368    }
369
370    fn model_config(&self, model: EmbeddingModel) -> lattice_embed::ModelConfig {
371        self.inner.model_config(model)
372    }
373
374    fn supports_model(&self, model: EmbeddingModel) -> bool {
375        self.inner.supports_model(model)
376    }
377
378    fn name(&self) -> &'static str {
379        self.inner.name()
380    }
381}
382
383/// A source that can produce an [`EmbeddingService`] by name.
384///
385/// Packs implement this trait to register custom embedding backends.
386/// The runtime calls [`build`](EmbedderProvider::build) lazily — once per
387/// process per model — and caches the result. Subsequent calls to
388/// `KhiveRuntime::embedder(name)` are cheap.
389///
390/// Built-in lattice models are registered automatically via
391/// [`LatticeEmbedderProvider`]; packs need not re-register them.
392#[async_trait]
393pub trait EmbedderProvider: Send + Sync {
394    /// Stable, case-sensitive name for this embedder.
395    ///
396    /// Must be unique across all registered providers. The name is used as
397    /// the key in `KhiveRuntime::embedder(name)` lookups and as the storage
398    /// table suffix for vector indices. Use the model's canonical short form
399    /// (e.g. `"all-minilm-l6-v2"`, `"my-custom-encoder"`).
400    fn name(&self) -> &str;
401
402    /// Output vector dimension for this embedder.
403    ///
404    /// Must be consistent with what [`build`](Self::build) produces.
405    /// The runtime uses this to pre-register the vector store columns.
406    fn dimensions(&self) -> usize;
407
408    /// Construct the underlying [`EmbeddingService`].
409    ///
410    /// Called at most once per process. The result is cached in a
411    /// [`OnceCell`]; concurrent callers block on the first call and share
412    /// the result thereafter.
413    async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>>;
414}
415
416/// An entry in the [`EmbedderRegistry`] combining a provider with its
417/// lazy-initialized service.
418pub(crate) struct EmbedderEntry {
419    provider: Arc<dyn EmbedderProvider>,
420    cell: Arc<OnceCell<Arc<dyn EmbeddingService>>>,
421    serving: Arc<AtomicBool>,
422    /// Only the runtime's built-in lattice provider has an audited document
423    /// preparation path. Pack replacements, even under a built-in name, do not.
424    audited_document_preparation: bool,
425}
426
427impl Clone for EmbedderEntry {
428    fn clone(&self) -> Self {
429        Self {
430            provider: Arc::clone(&self.provider),
431            cell: Arc::clone(&self.cell),
432            serving: Arc::clone(&self.serving),
433            audited_document_preparation: self.audited_document_preparation,
434        }
435    }
436}
437
438/// Registry of named [`EmbedderProvider`] instances.
439///
440/// Built during `KhiveRuntime` construction and optionally extended by packs
441/// via [`crate::KhiveRuntime::register_embedder`]. The registry is internally
442/// reference-counted so `KhiveRuntime::clone()` shares the same providers
443/// and cached service instances.
444#[derive(Clone, Default)]
445pub struct EmbedderRegistry {
446    entries: HashMap<String, EmbedderEntry>,
447    ordered_names: Vec<String>,
448}
449
450impl EmbedderRegistry {
451    /// Create an empty registry.
452    pub fn new() -> Self {
453        Self {
454            entries: HashMap::new(),
455            ordered_names: Vec::new(),
456        }
457    }
458
459    /// Register a provider, preserving its position for later name enumeration.
460    ///
461    /// Before a provider is selected for resolution, registration with the same
462    /// name replaces it in place. Once selected, duplicate registration returns
463    /// an error and leaves the serving provider unchanged.
464    pub fn register<P: EmbedderProvider + 'static>(&mut self, provider: P) -> RuntimeResult<()> {
465        self.insert(provider, false)
466    }
467
468    /// Register the runtime-owned lattice adapter whose passage preparation is
469    /// audited. This is deliberately not available to pack providers.
470    pub(crate) fn register_builtin(&mut self, provider: LatticeEmbedderProvider) {
471        self.insert(provider, true)
472            .expect("configured built-in embedding models must have unique names");
473    }
474
475    /// Test-only attested provider. The wrapper below owns passage preparation,
476    /// so a fake backend cannot change the bytes whose digest is recorded.
477    #[cfg(feature = "test-internals")]
478    pub fn register_test_audited<P: EmbedderProvider + 'static>(
479        &mut self,
480        model: EmbeddingModel,
481        provider: P,
482    ) {
483        assert_eq!(provider.name(), model.to_string());
484        self.insert(TestAuditedProvider { provider }, true)
485            .expect("test audited provider names must be unique");
486    }
487
488    fn insert<P: EmbedderProvider + 'static>(
489        &mut self,
490        provider: P,
491        audited_document_preparation: bool,
492    ) -> RuntimeResult<()> {
493        let name = provider.name().to_owned();
494        if self
495            .entries
496            .get(&name)
497            .is_some_and(|entry| entry.serving.load(Ordering::Acquire))
498        {
499            return Err(RuntimeError::InvalidInput(format!(
500                "embedding provider `{name}` is already serving"
501            )));
502        }
503        let entry = EmbedderEntry {
504            provider: Arc::new(provider),
505            cell: Arc::new(OnceCell::new()),
506            serving: Arc::new(AtomicBool::new(false)),
507            audited_document_preparation,
508        };
509        if self.entries.contains_key(&name) {
510            self.entries.insert(name, entry);
511        } else {
512            self.ordered_names.push(name.clone());
513            self.entries.insert(name, entry);
514        }
515        Ok(())
516    }
517
518    /// Look up a provider by name.
519    pub fn get_provider(&self, name: &str) -> Option<&dyn EmbedderProvider> {
520        self.entries.get(name).map(|e| e.provider.as_ref())
521    }
522
523    /// Returns `true` if a provider with this name is registered.
524    pub fn contains(&self, name: &str) -> bool {
525        self.entries.contains_key(name)
526    }
527
528    /// Names of all registered providers, in registration order.
529    pub fn names(&self) -> Vec<String> {
530        self.ordered_names.clone()
531    }
532
533    /// Return a cloned entry for `name` without holding any lock.
534    ///
535    /// The caller can then call [`EmbedderEntry::resolve`] without holding
536    /// a lock — this avoids holding a `RwLockGuard` across `await` points.
537    /// It marks the provider as selected for serving so registration cannot
538    /// replace it while a request is resolving the service. Returns `None` if
539    /// `name` is not registered.
540    pub(crate) fn get_entry(&self, name: &str) -> Option<EmbedderEntry> {
541        let entry = self.entries.get(name)?;
542        entry.serving.store(true, Ordering::Release);
543        Some(entry.clone())
544    }
545
546    /// Lazily resolve a registered provider to its live [`EmbeddingService`].
547    ///
548    /// Returns [`RuntimeError::UnknownModel`] if `name` is not registered.
549    /// The first call for a given name triggers [`EmbedderProvider::build`];
550    /// subsequent calls return the cached `Arc`.
551    ///
552    /// Prefer [`crate::KhiveRuntime::embedder`] over calling this directly from pack
553    /// handlers — the runtime method handles alias resolution and error mapping.
554    pub async fn get_service(&self, name: &str) -> RuntimeResult<Arc<dyn EmbeddingService>> {
555        let entry = self
556            .entries
557            .get(name)
558            .ok_or_else(|| RuntimeError::UnknownModel(name.to_string()))?
559            .clone();
560        entry.serving.store(true, Ordering::Release);
561
562        Ok(entry.resolve().await?.0)
563    }
564}
565
566#[cfg(feature = "test-internals")]
567struct TestAuditedProvider<P> {
568    provider: P,
569}
570
571#[cfg(feature = "test-internals")]
572#[async_trait]
573impl<P: EmbedderProvider> EmbedderProvider for TestAuditedProvider<P> {
574    fn name(&self) -> &str {
575        self.provider.name()
576    }
577
578    fn dimensions(&self) -> usize {
579        self.provider.dimensions()
580    }
581
582    async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
583        Ok(Arc::new(TestAuditedService(self.provider.build().await?)))
584    }
585}
586
587/// Deliberately does not delegate `embed_passage`: the lattice trait default
588/// applies the model's document instruction before calling `embed`, exactly
589/// as the audited built-in path does. Test providers supply only fake vectors.
590#[cfg(feature = "test-internals")]
591struct TestAuditedService(Arc<dyn EmbeddingService>);
592
593#[cfg(feature = "test-internals")]
594#[async_trait]
595impl EmbeddingService for TestAuditedService {
596    async fn embed(
597        &self,
598        texts: &[String],
599        model: EmbeddingModel,
600    ) -> lattice_embed::Result<Vec<Vec<f32>>> {
601        self.0.embed(texts, model).await
602    }
603
604    fn supports_model(&self, model: EmbeddingModel) -> bool {
605        self.0.supports_model(model)
606    }
607
608    fn name(&self) -> &'static str {
609        self.0.name()
610    }
611}
612
613impl EmbedderEntry {
614    pub(crate) fn cached_service(&self) -> Option<Arc<dyn EmbeddingService>> {
615        self.cell.get().map(Arc::clone)
616    }
617
618    pub(crate) fn has_audited_document_preparation(&self) -> bool {
619        self.audited_document_preparation
620    }
621
622    /// Lazily initialise and return the embedding service for this entry.
623    ///
624    /// `OnceCell::get_or_try_init` single-flights concurrent cold callers: only
625    /// one of them runs [`EmbedderProvider::build`], and every other waiter
626    /// receives the same result once it completes. Only the caller whose task
627    /// actually ran `build()` gets back `Some(duration)`; every other caller
628    /// (cache hit or wait-for-in-flight-build) gets `None`.
629    ///
630    /// Returns `RuntimeError` if `build()` fails, rather than panicking. On
631    /// failure the cell stays unset, so a later call retries the build.
632    pub(crate) async fn resolve(self) -> RuntimeResult<(Arc<dyn EmbeddingService>, Option<i64>)> {
633        let mut own_init_duration_us: Option<i64> = None;
634        let provider = Arc::clone(&self.provider);
635        let init_duration_us = &mut own_init_duration_us;
636        let svc = self
637            .cell
638            .get_or_try_init(|| async move {
639                let init_start = std::time::Instant::now();
640                let svc = provider.build().await.map_err(|e| {
641                    crate::error::RuntimeError::Internal(format!(
642                        "EmbedderProvider '{}' build() failed: {e}",
643                        provider.name()
644                    ))
645                })?;
646                *init_duration_us = Some(init_start.elapsed().as_micros() as i64);
647                Ok::<_, RuntimeError>(svc)
648            })
649            .await?;
650        Ok((Arc::clone(svc), own_init_duration_us))
651    }
652}
653
654// ── LatticeEmbedderProvider ───────────────────────────────────────────────────
655
656/// Adapter that wraps a [`lattice_embed::EmbeddingModel`] as an
657/// [`EmbedderProvider`].
658///
659/// All built-in models (MiniLM, paraphrase-multilingual, BGE variants, etc.)
660/// are registered as `LatticeEmbedderProvider` instances during
661/// `KhiveRuntime` construction. External callers do not need to use this type
662/// unless they are constructing a custom registry from scratch.
663pub struct LatticeEmbedderProvider {
664    model: EmbeddingModel,
665    /// Cached `to_string()` result so `name()` can return `&str`.
666    name: String,
667}
668
669impl LatticeEmbedderProvider {
670    /// Create a new provider wrapping the given lattice model.
671    pub fn new(model: EmbeddingModel) -> Self {
672        let name = model.to_string();
673        Self { model, name }
674    }
675}
676
677#[async_trait]
678impl EmbedderProvider for LatticeEmbedderProvider {
679    fn name(&self) -> &str {
680        &self.name
681    }
682
683    fn dimensions(&self) -> usize {
684        self.model.dimensions()
685    }
686
687    async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
688        let native = Arc::new(NativeEmbeddingService::with_model(self.model));
689        native.ensure_loaded().await?;
690        Ok(cached_blocking_service(native))
691    }
692}
693
694/// Keep result-cache lookup outside worker admission for every retrieval role.
695fn cached_blocking_service<S: EmbeddingService + 'static>(
696    inner: Arc<S>,
697) -> Arc<dyn EmbeddingService> {
698    let blocking = Arc::new(BlockingEmbeddingService::new(inner));
699    Arc::new(CachedEmbeddingService::with_default_cache(blocking))
700}
701
702#[cfg(test)]
703#[path = "embedding_cache_admission_tests.rs"]
704mod cache_admission_tests;
705
706// ── Unit tests ────────────────────────────────────────────────────────────────
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use std::collections::HashSet;
712    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
713    use std::sync::{Condvar, Mutex};
714    use std::time::Duration;
715    use tokio::sync::Notify;
716
717    struct ConstVecProvider {
718        name: String,
719        dims: usize,
720        build_calls: Arc<AtomicUsize>,
721    }
722
723    impl ConstVecProvider {
724        fn new(name: &str, dims: usize) -> Self {
725            Self {
726                name: name.to_owned(),
727                dims,
728                build_calls: Arc::new(AtomicUsize::new(0)),
729            }
730        }
731    }
732
733    /// A trivial embedding service that returns a constant vector of `1.0`s.
734    /// The `model` parameter is ignored — this service always returns the
735    /// same synthetic vector regardless of which model is requested.
736    pub(super) struct ConstVecService {
737        pub(super) dims: usize,
738    }
739
740    #[async_trait]
741    impl EmbeddingService for ConstVecService {
742        async fn embed(
743            &self,
744            texts: &[String],
745            _model: EmbeddingModel,
746        ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
747            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
748        }
749
750        fn supports_model(&self, _model: EmbeddingModel) -> bool {
751            true
752        }
753
754        fn name(&self) -> &'static str {
755            "const-vec-service"
756        }
757    }
758
759    #[async_trait]
760    impl EmbedderProvider for ConstVecProvider {
761        fn name(&self) -> &str {
762            &self.name
763        }
764
765        fn dimensions(&self) -> usize {
766            self.dims
767        }
768
769        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
770            self.build_calls.fetch_add(1, Ordering::SeqCst);
771            Ok(Arc::new(ConstVecService { dims: self.dims }))
772        }
773    }
774
775    #[test]
776    fn builtin_provider_can_be_replaced_before_resolution_without_reordering() {
777        let model = EmbeddingModel::MultilingualE5Small;
778        let name = model.to_string();
779        let mut registry = EmbedderRegistry::new();
780        registry.register_builtin(LatticeEmbedderProvider::new(model));
781        let original_names = registry.names();
782
783        registry
784            .register(ConstVecProvider::new(&name, model.dimensions()))
785            .expect("pre-serving replacement should succeed");
786        assert_eq!(registry.names(), original_names);
787        assert!(!registry
788            .get_entry(&name)
789            .expect("replacement entry")
790            .has_audited_document_preparation());
791    }
792
793    struct FirstLoadBlockingService {
794        loaded: AtomicBool,
795    }
796
797    #[async_trait]
798    impl EmbeddingService for FirstLoadBlockingService {
799        async fn embed(
800            &self,
801            texts: &[String],
802            _model: EmbeddingModel,
803        ) -> lattice_embed::Result<Vec<Vec<f32>>> {
804            if !self.loaded.swap(true, Ordering::SeqCst) {
805                tokio::task::spawn_blocking(|| {})
806                    .await
807                    .map_err(|error| lattice_embed::EmbedError::Internal(error.to_string()))?;
808            }
809            Ok(texts.iter().map(|_| vec![1.0]).collect())
810        }
811
812        fn supports_model(&self, _model: EmbeddingModel) -> bool {
813            true
814        }
815
816        fn name(&self) -> &'static str {
817            "first-load-blocking-service"
818        }
819    }
820
821    pub(super) struct BlockingTestService {
822        pub(super) calls: Mutex<Vec<String>>,
823        pub(super) entered: AtomicUsize,
824        release: (Mutex<bool>, Condvar),
825        pub(super) thread_ids: Mutex<HashSet<std::thread::ThreadId>>,
826    }
827
828    impl BlockingTestService {
829        pub(super) fn new() -> Self {
830            Self {
831                calls: Mutex::new(Vec::new()),
832                entered: AtomicUsize::new(0),
833                release: (Mutex::new(false), Condvar::new()),
834                thread_ids: Mutex::new(HashSet::new()),
835            }
836        }
837
838        pub(super) fn release(&self) {
839            *self
840                .release
841                .0
842                .lock()
843                .expect("release lock must not be poisoned") = true;
844            self.release.1.notify_all();
845        }
846    }
847
848    #[async_trait]
849    impl EmbeddingService for BlockingTestService {
850        async fn embed(
851            &self,
852            texts: &[String],
853            _model: EmbeddingModel,
854        ) -> lattice_embed::Result<Vec<Vec<f32>>> {
855            let text = texts.first().cloned().unwrap_or_default();
856            self.thread_ids
857                .lock()
858                .expect("thread id lock must not be poisoned")
859                .insert(std::thread::current().id());
860            self.calls
861                .lock()
862                .expect("call lock must not be poisoned")
863                .push(text.clone());
864            self.entered.fetch_add(1, Ordering::Release);
865
866            if text != "later" {
867                let (released, wake) = &self.release;
868                let guard = released.lock().expect("release lock must not be poisoned");
869                let _guard = wake
870                    .wait_while(guard, |released| !*released)
871                    .expect("release lock must not be poisoned");
872            }
873
874            Ok(texts.iter().map(|_| vec![1.0]).collect())
875        }
876
877        fn supports_model(&self, _model: EmbeddingModel) -> bool {
878            true
879        }
880
881        fn name(&self) -> &'static str {
882            "blocking-test-service"
883        }
884    }
885
886    #[test]
887    fn blocking_adapter_first_use_completes_with_single_blocking_thread() {
888        let runtime = tokio::runtime::Builder::new_current_thread()
889            .enable_time()
890            .max_blocking_threads(1)
891            .build()
892            .expect("current-thread runtime must build");
893        let service = BlockingEmbeddingService::new(Arc::new(FirstLoadBlockingService {
894            loaded: AtomicBool::new(false),
895        }));
896
897        let result = runtime.block_on(async {
898            tokio::time::timeout(
899                Duration::from_secs(5),
900                service.embed(&["first use".to_owned()], EmbeddingModel::default()),
901            )
902            .await
903        });
904        runtime.shutdown_timeout(Duration::from_secs(1));
905
906        let embeddings = result
907            .expect("first-use embedding must not exhaust the blocking pool")
908            .expect("first-use embedding must succeed");
909        assert_eq!(embeddings, vec![vec![1.0]]);
910    }
911
912    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
913    async fn blocking_adapter_uses_one_worker_for_concurrent_calls() {
914        const CALL_COUNT: usize = 32;
915        let inner = Arc::new(BlockingTestService::new());
916        let service = Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner)));
917        let mut calls = Vec::with_capacity(CALL_COUNT);
918
919        for index in 0..CALL_COUNT {
920            let service = Arc::clone(&service);
921            calls.push(tokio::spawn(async move {
922                service
923                    .embed(&[format!("request-{index}")], EmbeddingModel::default())
924                    .await
925            }));
926        }
927
928        let _ = tokio::time::timeout(Duration::from_millis(250), async {
929            while inner.entered.load(Ordering::Acquire) < 2 {
930                tokio::task::yield_now().await;
931            }
932        })
933        .await;
934        inner.release();
935
936        for call in calls {
937            call.await
938                .expect("embedding task must not panic")
939                .expect("embedding call must succeed");
940        }
941        assert_eq!(
942            inner
943                .thread_ids
944                .lock()
945                .expect("thread id lock must not be poisoned")
946                .len(),
947            1,
948            "concurrent calls must share one native worker thread"
949        );
950    }
951
952    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
953    async fn blocking_adapter_skips_timed_out_call_and_serves_later_call() {
954        let inner = Arc::new(BlockingTestService::new());
955        let service = Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner)));
956
957        let first_service = Arc::clone(&service);
958        let first = tokio::spawn(async move {
959            first_service
960                .embed(&["first".to_owned()], EmbeddingModel::default())
961                .await
962        });
963        tokio::time::timeout(Duration::from_secs(1), async {
964            while inner.entered.load(Ordering::Acquire) == 0 {
965                tokio::task::yield_now().await;
966            }
967        })
968        .await
969        .expect("first embedding call must enter the native service");
970
971        let abandoned = tokio::time::timeout(
972            Duration::from_millis(50),
973            service.embed(&["abandoned".to_owned()], EmbeddingModel::default()),
974        )
975        .await;
976        let later_service = Arc::clone(&service);
977        let later = tokio::spawn(async move {
978            later_service
979                .embed(&["later".to_owned()], EmbeddingModel::default())
980                .await
981        });
982        inner.release();
983
984        first
985            .await
986            .expect("first embedding task must not panic")
987            .expect("first embedding call must succeed");
988        let later_result = tokio::time::timeout(Duration::from_secs(1), later)
989            .await
990            .expect("later embedding call must be served")
991            .expect("later embedding task must not panic")
992            .expect("later embedding call must succeed");
993
994        assert!(abandoned.is_err(), "queued embedding call must time out");
995        assert_eq!(later_result, vec![vec![1.0]]);
996        assert_eq!(
997            *inner.calls.lock().expect("call lock must not be poisoned"),
998            vec!["first".to_owned(), "later".to_owned()],
999            "the worker must skip a queued call whose receiver is closed"
1000        );
1001    }
1002
1003    pub(super) struct ReleaseWorkerOnDrop(pub(super) Arc<BlockingTestService>);
1004
1005    impl Drop for ReleaseWorkerOnDrop {
1006        fn drop(&mut self) {
1007            self.0.release();
1008        }
1009    }
1010
1011    struct ServiceProvider(Arc<dyn EmbeddingService>);
1012
1013    #[async_trait]
1014    impl EmbedderProvider for ServiceProvider {
1015        fn name(&self) -> &str {
1016            "queue-test"
1017        }
1018        fn dimensions(&self) -> usize {
1019            1
1020        }
1021        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
1022            Ok(Arc::clone(&self.0))
1023        }
1024    }
1025
1026    pub(super) fn queue_runtime(service: Arc<dyn EmbeddingService>) -> crate::KhiveRuntime {
1027        let runtime = crate::KhiveRuntime::memory().expect("memory runtime");
1028        runtime.register_embedder(ServiceProvider(service));
1029        runtime
1030    }
1031
1032    pub(super) async fn poll_once<F: std::future::Future + ?Sized>(
1033        mut future: std::pin::Pin<&mut F>,
1034    ) -> std::task::Poll<F::Output> {
1035        std::future::poll_fn(|cx| std::task::Poll::Ready(future.as_mut().poll(cx))).await
1036    }
1037
1038    pub(super) async fn wait_for_entered(inner: &BlockingTestService, count: usize) {
1039        let watchdog = std::time::Instant::now() + Duration::from_secs(2);
1040        while inner.entered.load(Ordering::Acquire) < count {
1041            assert!(
1042                std::time::Instant::now() < watchdog,
1043                "setup watchdog: native worker did not enter"
1044            );
1045            tokio::task::yield_now().await;
1046        }
1047    }
1048
1049    async fn drive_until_entered<F: std::future::Future + ?Sized>(
1050        mut future: std::pin::Pin<&mut F>,
1051        inner: &BlockingTestService,
1052        count: usize,
1053    ) {
1054        let watchdog = std::time::Instant::now() + Duration::from_secs(2);
1055        while inner.entered.load(Ordering::Acquire) < count {
1056            assert!(
1057                std::time::Instant::now() < watchdog,
1058                "setup watchdog: driven runtime call did not reach the native worker"
1059            );
1060            assert!(
1061                poll_once(future.as_mut()).await.is_pending(),
1062                "held inference must remain pending while the runtime call is driven"
1063            );
1064            tokio::task::yield_now().await;
1065        }
1066    }
1067
1068    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1069    async fn runtime_embedding_waits_for_capacity_and_drains_n_plus_one_calls() {
1070        let inner = Arc::new(BlockingTestService::new());
1071        let _release = ReleaseWorkerOnDrop(Arc::clone(&inner));
1072        let service = Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner)));
1073        let runtime = queue_runtime(service.clone());
1074        runtime
1075            .embedder("queue-test")
1076            .await
1077            .expect("resolve provider before the admission fixture");
1078        let first_text = vec!["first".to_owned()];
1079        let mut first = Box::pin(runtime.embed_batch_with_model("queue-test", &first_text));
1080        assert!(poll_once(first.as_mut()).await.is_pending());
1081        drive_until_entered(first.as_mut(), &inner, 1).await;
1082
1083        let texts: Vec<_> = (0..EMBEDDING_QUEUE_CAPACITY)
1084            .map(|index| vec![format!("queued-{index}")])
1085            .collect();
1086        let mut queued = Vec::new();
1087        for text in &texts {
1088            let mut call = Box::pin(runtime.embed_batch_with_model("queue-test", text));
1089            assert!(
1090                poll_once(call.as_mut()).await.is_pending(),
1091                "the bounded queue must admit its N actual runtime calls"
1092            );
1093            queued.push(call);
1094        }
1095        assert_eq!(
1096            service.in_flight_bytes.load(Ordering::Acquire),
1097            first_text.iter().map(String::len).sum::<usize>()
1098                + texts.iter().flatten().map(String::len).sum::<usize>(),
1099            "the held worker and N queued runtime jobs must own their exact input bytes"
1100        );
1101        let overflow_text = vec!["overflow".to_owned()];
1102        let mut overflow = Box::pin(runtime.embed_batch_with_model("queue-test", &overflow_text));
1103        let overflow_before_release = poll_once(overflow.as_mut()).await;
1104        inner.release();
1105        assert!(
1106            overflow_before_release.is_pending(),
1107            "request N+1 must wait for a slot instead of failing: {overflow_before_release:?}"
1108        );
1109        assert_eq!(first.await.unwrap(), vec![vec![1.0]]);
1110        for call in queued {
1111            assert_eq!(call.await.unwrap(), vec![vec![1.0]]);
1112        }
1113        assert_eq!(overflow.await.unwrap(), vec![vec![1.0]]);
1114        assert_eq!(
1115            inner.entered.load(Ordering::Acquire),
1116            EMBEDDING_QUEUE_CAPACITY + 2
1117        );
1118        assert_eq!(inner.thread_ids.lock().unwrap().len(), 1);
1119    }
1120
1121    #[tokio::test(start_paused = true)]
1122    async fn runtime_embedding_expired_admission_is_retryable_and_never_enqueued() {
1123        let inner = Arc::new(BlockingTestService::new());
1124        let _release = ReleaseWorkerOnDrop(Arc::clone(&inner));
1125        let service = Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner)));
1126        let runtime = queue_runtime(service.clone());
1127        runtime
1128            .embedder("queue-test")
1129            .await
1130            .expect("resolve provider before the admission fixture");
1131        let first_text = vec!["first".to_owned()];
1132        let mut first = Box::pin(runtime.embed_batch_with_model("queue-test", &first_text));
1133        assert!(poll_once(first.as_mut()).await.is_pending());
1134        drive_until_entered(first.as_mut(), &inner, 1).await;
1135        let texts: Vec<_> = (0..EMBEDDING_QUEUE_CAPACITY)
1136            .map(|index| vec![format!("queued-{index}")])
1137            .collect();
1138        let mut queued = Vec::new();
1139        for text in &texts {
1140            let mut call = Box::pin(runtime.embed_batch_with_model("queue-test", text));
1141            assert!(poll_once(call.as_mut()).await.is_pending());
1142            queued.push(call);
1143        }
1144        assert_eq!(
1145            service.in_flight_bytes.load(Ordering::Acquire),
1146            first_text.iter().map(String::len).sum::<usize>()
1147                + texts.iter().flatten().map(String::len).sum::<usize>(),
1148            "the held worker and N queued runtime jobs must own their exact input bytes"
1149        );
1150        let deadline = khive_storage::RequestReadDeadline::after(Duration::from_millis(100));
1151        let mut expired = Box::pin(khive_storage::scope_request_read_deadline_at(
1152            deadline,
1153            runtime.embed_with_model("queue-test", "expired"),
1154        ));
1155        assert!(
1156            poll_once(expired.as_mut()).await.is_pending(),
1157            "an unexpired full-queue call must wait"
1158        );
1159        tokio::time::advance(Duration::from_millis(100)).await;
1160        let result = poll_once(expired.as_mut()).await;
1161        inner.release();
1162        let error = match result {
1163            std::task::Poll::Ready(Err(error)) => error,
1164            other => panic!("expired admission must produce a typed runtime error: {other:?}"),
1165        };
1166        assert!(
1167            matches!(&error, RuntimeError::Storage(khive_storage::StorageError::AdmissionTimeout {
1168            operation, timeout_ms: 100, pool_identity: None,
1169        }) if operation == "embedding admission"),
1170            "{error:?}"
1171        );
1172        assert!(
1173            error.retryable_failure_context().is_some(),
1174            "pre-admission expiry must use the existing retryable classification"
1175        );
1176        let projected = crate::error_projection::runtime_error_value(
1177            error,
1178            crate::DomainDisposition::NotCommitted,
1179        );
1180        assert_eq!(projected["retryable"], true);
1181        assert_eq!(projected["operation"], "embedding admission");
1182        assert_eq!(first.await.unwrap(), vec![vec![1.0]]);
1183        for call in queued {
1184            assert_eq!(call.await.unwrap(), vec![vec![1.0]]);
1185        }
1186        assert!(
1187            !inner
1188                .calls
1189                .lock()
1190                .unwrap()
1191                .iter()
1192                .any(|text| text == "expired"),
1193            "an expired waiter must never reach inference"
1194        );
1195    }
1196
1197    #[tokio::test(start_paused = true)]
1198    async fn runtime_embedding_earlier_absolute_bound_is_not_renewed_when_slot_is_ready() {
1199        let inner = Arc::new(BlockingTestService::new());
1200        let _release = ReleaseWorkerOnDrop(Arc::clone(&inner));
1201        let runtime = queue_runtime(Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner))));
1202        runtime
1203            .embedder("queue-test")
1204            .await
1205            .expect("resolve provider before the admission deadline");
1206        let deadline = khive_storage::RequestReadDeadline::after(Duration::from_millis(100));
1207        tokio::time::advance(Duration::from_millis(100)).await;
1208        let mut call = Box::pin(khive_storage::scope_request_read_deadline_at(
1209            deadline,
1210            runtime.embed_with_model("queue-test", "expired-ready-slot"),
1211        ));
1212        let result = poll_once(call.as_mut()).await;
1213        inner.release();
1214        assert!(
1215            matches!(
1216                result,
1217                std::task::Poll::Ready(Err(RuntimeError::Storage(
1218                    khive_storage::StorageError::AdmissionTimeout { timeout_ms: 0, .. }
1219                )))
1220            ),
1221            "an already-expired original bound must refuse even an available slot: {result:?}"
1222        );
1223        assert_eq!(
1224            inner.entered.load(Ordering::Acquire),
1225            0,
1226            "capacity becoming ready must not bypass expiry"
1227        );
1228    }
1229
1230    #[tokio::test(start_paused = true)]
1231    async fn runtime_embedding_all_six_call_families_refuse_expired_admission() {
1232        let inner = Arc::new(BlockingTestService::new());
1233        let _release = ReleaseWorkerOnDrop(Arc::clone(&inner));
1234        let runtime = queue_runtime(Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner))));
1235        let texts = vec!["later".to_owned()];
1236        for family in 0..6 {
1237            let result = khive_storage::scope_request_read_deadline(Duration::ZERO, async {
1238                match family {
1239                    0 => runtime.embed_with_model("queue-test", "later").await,
1240                    1 => runtime
1241                        .embed_document_with_model_outcome("queue-test", "later")
1242                        .await
1243                        .map(|outcome| outcome.vector),
1244                    2 => runtime.embed_query_with_model("queue-test", "later").await,
1245                    3 => runtime
1246                        .embed_batch_with_model("queue-test", &texts)
1247                        .await
1248                        .map(|vectors| vectors[0].clone()),
1249                    4 => runtime
1250                        .embed_document_batch_with_model("queue-test", &texts)
1251                        .await
1252                        .map(|vectors| vectors[0].clone()),
1253                    _ => runtime
1254                        .embed_query_batch_with_model("queue-test", &texts)
1255                        .await
1256                        .map(|vectors| vectors[0].clone()),
1257                }
1258            })
1259            .await;
1260            assert!(
1261                matches!(
1262                    result,
1263                    Err(RuntimeError::Storage(
1264                        khive_storage::StorageError::AdmissionTimeout { .. }
1265                    ))
1266                ),
1267                "family {family} must reach the actual runtime admission boundary: {result:?}"
1268            );
1269        }
1270        assert_eq!(inner.entered.load(Ordering::Acquire), 0);
1271    }
1272
1273    #[tokio::test(start_paused = true)]
1274    async fn runtime_embedding_admitted_inference_is_not_reported_as_admission_timeout() {
1275        let inner = Arc::new(BlockingTestService::new());
1276        let _release = ReleaseWorkerOnDrop(Arc::clone(&inner));
1277        let runtime = queue_runtime(Arc::new(BlockingEmbeddingService::new(Arc::clone(&inner))));
1278        runtime
1279            .embedder("queue-test")
1280            .await
1281            .expect("resolve provider before the admission fixture");
1282        let mut call = Box::pin(khive_storage::scope_request_read_deadline(
1283            Duration::from_millis(100),
1284            runtime.embed_with_model("queue-test", "first"),
1285        ));
1286        assert!(poll_once(call.as_mut()).await.is_pending());
1287        drive_until_entered(call.as_mut(), &inner, 1).await;
1288        tokio::time::advance(Duration::from_millis(100)).await;
1289        let after_deadline = poll_once(call.as_mut()).await;
1290        inner.release();
1291        assert!(after_deadline.is_pending(),
1292            "already-running inference cannot be called a pre-admission refusal: {after_deadline:?}");
1293        assert_eq!(call.await.unwrap(), vec![1.0]);
1294    }
1295
1296    struct CustomWaitingService {
1297        release: Notify,
1298    }
1299
1300    #[async_trait]
1301    impl EmbeddingService for CustomWaitingService {
1302        async fn embed(
1303            &self,
1304            texts: &[String],
1305            _model: EmbeddingModel,
1306        ) -> lattice_embed::Result<Vec<Vec<f32>>> {
1307            self.release.notified().await;
1308            Ok(texts.iter().map(|_| vec![1.0]).collect())
1309        }
1310        fn supports_model(&self, _model: EmbeddingModel) -> bool {
1311            true
1312        }
1313        fn name(&self) -> &'static str {
1314            "custom-waiting"
1315        }
1316    }
1317
1318    #[tokio::test(start_paused = true)]
1319    async fn runtime_custom_inference_is_not_reported_as_builtin_admission_timeout() {
1320        let inner = Arc::new(CustomWaitingService {
1321            release: Notify::new(),
1322        });
1323        let runtime = queue_runtime(inner.clone());
1324        let mut call = Box::pin(khive_storage::scope_request_read_deadline(
1325            Duration::from_millis(100),
1326            runtime.embed_with_model("queue-test", "custom"),
1327        ));
1328        assert!(poll_once(call.as_mut()).await.is_pending());
1329        tokio::time::advance(Duration::from_millis(100)).await;
1330        let after_deadline = poll_once(call.as_mut()).await;
1331        inner.release.notify_one();
1332        assert!(
1333            after_deadline.is_pending(),
1334            "custom inference did not enter the built-in pre-admission wait: {after_deadline:?}"
1335        );
1336        assert_eq!(call.await.unwrap(), vec![1.0]);
1337    }
1338
1339    #[tokio::test]
1340    async fn blocking_adapter_rejects_oversized_job_before_enqueue() {
1341        let service = BlockingEmbeddingService::new(Arc::new(ConstVecService { dims: 1 }));
1342        let oversized =
1343            "x".repeat(lattice_embed::DEFAULT_MAX_BATCH_SIZE * lattice_embed::MAX_TEXT_BYTES + 1);
1344
1345        let error = service
1346            .embed(&[oversized], EmbeddingModel::default())
1347            .await
1348            .expect_err("an oversized embedding job must be rejected");
1349
1350        assert!(
1351            error.to_string().contains("embedding job input"),
1352            "oversized admission must use the embedding error path: {error}"
1353        );
1354        assert!(
1355            service.worker.get().is_none(),
1356            "oversized work must be rejected before the worker queue is initialized"
1357        );
1358    }
1359
1360    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1361    async fn blocking_adapter_byte_budget_rejects_excess_and_queued_jobs_complete() {
1362        const ADMITTED_JOBS: usize = 4;
1363        let byte_budget = ADMITTED_JOBS * EMBEDDING_MAX_JOB_BYTES;
1364        let inner = Arc::new(BlockingTestService::new());
1365        let service = Arc::new(BlockingEmbeddingService::with_byte_budget(
1366            Arc::clone(&inner),
1367            byte_budget,
1368        ));
1369        let max_texts = Arc::new(vec![
1370            "x".repeat(lattice_embed::MAX_TEXT_BYTES);
1371            lattice_embed::DEFAULT_MAX_BATCH_SIZE
1372        ]);
1373        let mut admitted = Vec::with_capacity(ADMITTED_JOBS);
1374
1375        for _ in 0..ADMITTED_JOBS {
1376            let service = Arc::clone(&service);
1377            let texts = Arc::clone(&max_texts);
1378            admitted.push(tokio::spawn(async move {
1379                service.embed(&texts, EmbeddingModel::default()).await
1380            }));
1381        }
1382        tokio::time::timeout(Duration::from_secs(1), async {
1383            while service.in_flight_bytes.load(Ordering::Acquire) < byte_budget {
1384                tokio::task::yield_now().await;
1385            }
1386        })
1387        .await
1388        .expect("all jobs within the byte budget must be admitted");
1389
1390        let overflow = tokio::time::timeout(
1391            Duration::from_millis(100),
1392            service.embed(&max_texts, EmbeddingModel::default()),
1393        )
1394        .await
1395        .expect("a byte-budget overflow must fail without waiting")
1396        .expect_err("a byte-budget overflow must return an embedding error");
1397
1398        inner.release();
1399        for call in admitted {
1400            call.await
1401                .expect("admitted embedding task must not panic")
1402                .expect("admitted embedding job must complete");
1403        }
1404        assert!(
1405            overflow.to_string().contains("byte budget"),
1406            "byte saturation must use the embedding failure path: {overflow}"
1407        );
1408    }
1409
1410    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1411    async fn blocking_adapter_releases_byte_budget_after_completion_and_skipped_job() {
1412        let byte_budget = "first".len() + "abandoned".len();
1413        let inner = Arc::new(BlockingTestService::new());
1414        let service = Arc::new(BlockingEmbeddingService::with_byte_budget(
1415            Arc::clone(&inner),
1416            byte_budget,
1417        ));
1418
1419        let first_service = Arc::clone(&service);
1420        let first = tokio::spawn(async move {
1421            first_service
1422                .embed(&["first".to_owned()], EmbeddingModel::default())
1423                .await
1424        });
1425        tokio::time::timeout(Duration::from_secs(1), async {
1426            while inner.entered.load(Ordering::Acquire) == 0 {
1427                tokio::task::yield_now().await;
1428            }
1429        })
1430        .await
1431        .expect("first embedding call must occupy the native worker");
1432
1433        let abandoned = tokio::time::timeout(
1434            Duration::from_millis(50),
1435            service.embed(&["abandoned".to_owned()], EmbeddingModel::default()),
1436        )
1437        .await;
1438        assert!(abandoned.is_err(), "queued embedding call must time out");
1439        assert_eq!(
1440            service.in_flight_bytes.load(Ordering::Acquire),
1441            byte_budget,
1442            "running and queued jobs must both consume the byte budget"
1443        );
1444
1445        inner.release();
1446        first
1447            .await
1448            .expect("first embedding task must not panic")
1449            .expect("first embedding call must succeed");
1450        tokio::time::timeout(Duration::from_secs(1), async {
1451            while service.in_flight_bytes.load(Ordering::Acquire) != 0 {
1452                tokio::task::yield_now().await;
1453            }
1454        })
1455        .await
1456        .expect("completed and skipped jobs must release their byte reservations");
1457
1458        let later = service
1459            .embed(&["later".to_owned()], EmbeddingModel::default())
1460            .await
1461            .expect("a later call must succeed after the byte budget is released");
1462        assert_eq!(later, vec![vec![1.0]]);
1463    }
1464
1465    #[test]
1466    fn register_and_get_provider_round_trip() {
1467        let mut reg = EmbedderRegistry::new();
1468        reg.register(ConstVecProvider::new("mock-384", 384))
1469            .expect("first registration should succeed");
1470
1471        assert!(reg.contains("mock-384"), "registered name must be present");
1472        let provider = reg.get_provider("mock-384").expect("provider must exist");
1473        assert_eq!(provider.name(), "mock-384");
1474        assert_eq!(provider.dimensions(), 384);
1475    }
1476
1477    #[tokio::test]
1478    async fn duplicate_registration_preserves_serving_provider() {
1479        let mut reg = EmbedderRegistry::new();
1480        reg.register(ConstVecProvider::new("shared", 128))
1481            .expect("first registration should succeed");
1482        reg.register(ConstVecProvider::new("other", 64))
1483            .expect("unique registration should succeed");
1484        reg.get_service("shared")
1485            .await
1486            .expect("serving provider should build");
1487        let error = reg
1488            .register(ConstVecProvider::new("shared", 256))
1489            .expect_err("duplicate registration should fail");
1490        assert!(
1491            matches!(error, RuntimeError::InvalidInput(message) if message.contains("shared") && message.contains("serving"))
1492        );
1493
1494        let provider = reg.get_provider("shared").expect("provider must exist");
1495        assert_eq!(provider.dimensions(), 128);
1496        assert_eq!(reg.names(), vec!["shared".to_owned(), "other".to_owned()]);
1497    }
1498
1499    #[tokio::test]
1500    async fn runtime_try_register_embedder_returns_duplicate_error() {
1501        let runtime = crate::KhiveRuntime::memory().expect("memory runtime");
1502        runtime
1503            .try_register_embedder(ConstVecProvider::new("first-custom", 128))
1504            .expect("first registration should succeed");
1505        runtime
1506            .try_register_embedder(ConstVecProvider::new("second-custom", 256))
1507            .expect("second registration should succeed");
1508
1509        let expected = runtime.registered_embedding_model_names();
1510        assert!(expected.ends_with(&["first-custom".to_owned(), "second-custom".to_owned()]));
1511        for _ in 0..100 {
1512            assert_eq!(runtime.registered_embedding_model_names(), expected);
1513        }
1514        runtime
1515            .embedder("first-custom")
1516            .await
1517            .expect("first custom provider should build");
1518
1519        let error = runtime
1520            .try_register_embedder(ConstVecProvider::new("first-custom", 512))
1521            .expect_err("duplicate registration should fail");
1522        assert!(
1523            matches!(error, RuntimeError::InvalidInput(message) if message.contains("first-custom") && message.contains("serving"))
1524        );
1525    }
1526
1527    #[test]
1528    fn names_preserve_registration_order_across_calls() {
1529        let mut reg = EmbedderRegistry::new();
1530        for (name, dimensions) in [("e", 5), ("b", 4), ("d", 3), ("a", 2), ("c", 1)] {
1531            reg.register(ConstVecProvider::new(name, dimensions))
1532                .expect("unique registration should succeed");
1533        }
1534
1535        let expected = ["e", "b", "d", "a", "c"].map(|name| name.to_owned());
1536        for _ in 0..100 {
1537            assert_eq!(reg.names(), expected);
1538        }
1539    }
1540
1541    #[test]
1542    fn names_returns_all_registered() {
1543        let mut reg = EmbedderRegistry::new();
1544        reg.register(ConstVecProvider::new("model-a", 64))
1545            .expect("unique registration should succeed");
1546        reg.register(ConstVecProvider::new("model-b", 128))
1547            .expect("unique registration should succeed");
1548        reg.register(ConstVecProvider::new("model-c", 256))
1549            .expect("unique registration should succeed");
1550
1551        let mut names = reg.names();
1552        names.sort();
1553        assert_eq!(names, vec!["model-a", "model-b", "model-c"]);
1554    }
1555
1556    #[tokio::test]
1557    async fn get_service_unknown_name_returns_error() {
1558        let reg = EmbedderRegistry::new();
1559        let result = reg.get_service("does-not-exist").await;
1560        let err = result.err().expect("expected Err for unknown name, got Ok");
1561        assert!(
1562            matches!(err, RuntimeError::UnknownModel(ref n) if n == "does-not-exist"),
1563            "expected UnknownModel, got {err:?}"
1564        );
1565    }
1566
1567    #[tokio::test]
1568    async fn get_service_calls_build_once() {
1569        let counter = Arc::new(AtomicUsize::new(0));
1570        let provider = ConstVecProvider {
1571            name: "cached-model".to_owned(),
1572            dims: 32,
1573            build_calls: Arc::clone(&counter),
1574        };
1575        let mut reg = EmbedderRegistry::new();
1576        reg.register(provider)
1577            .expect("unique registration should succeed");
1578
1579        let _ = reg.get_service("cached-model").await.unwrap();
1580        let _ = reg.get_service("cached-model").await.unwrap();
1581        let _ = reg.get_service("cached-model").await.unwrap();
1582
1583        assert_eq!(
1584            counter.load(Ordering::SeqCst),
1585            1,
1586            "build must be called exactly once regardless of get_service call count"
1587        );
1588    }
1589
1590    struct SlowBuildProvider {
1591        name: String,
1592        dims: usize,
1593        build_calls: Arc<AtomicUsize>,
1594    }
1595
1596    #[async_trait]
1597    impl EmbedderProvider for SlowBuildProvider {
1598        fn name(&self) -> &str {
1599            &self.name
1600        }
1601
1602        fn dimensions(&self) -> usize {
1603            self.dims
1604        }
1605
1606        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
1607            self.build_calls.fetch_add(1, Ordering::SeqCst);
1608            tokio::time::sleep(Duration::from_millis(50)).await;
1609            Ok(Arc::new(ConstVecService { dims: self.dims }))
1610        }
1611    }
1612
1613    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1614    async fn concurrent_cold_resolutions_single_flight_one_build() {
1615        const CALLERS: usize = 16;
1616        let counter = Arc::new(AtomicUsize::new(0));
1617        let mut reg = EmbedderRegistry::new();
1618        reg.register(SlowBuildProvider {
1619            name: "cold-model".to_owned(),
1620            dims: 8,
1621            build_calls: Arc::clone(&counter),
1622        })
1623        .expect("unique registration should succeed");
1624        let reg = Arc::new(reg);
1625
1626        let mut callers = Vec::with_capacity(CALLERS);
1627        for _ in 0..CALLERS {
1628            let reg = Arc::clone(&reg);
1629            callers.push(tokio::spawn(
1630                async move { reg.get_service("cold-model").await },
1631            ));
1632        }
1633
1634        for caller in callers {
1635            let service = caller
1636                .await
1637                .expect("resolution task must not panic")
1638                .expect("every concurrent cold resolution must receive a working service");
1639            assert_eq!(service.name(), "const-vec-service");
1640        }
1641
1642        assert_eq!(
1643            counter.load(Ordering::SeqCst),
1644            1,
1645            "concurrent cold resolutions must share a single in-flight build()"
1646        );
1647    }
1648}