Skip to main content

mongreldb_core/
embedding.rs

1//! Bounded, cancellable embedding generation over materialized vectors.
2//!
3//! Schema stores portable provider/model identity. Node-local configuration
4//! resolves that identity to model files or remote endpoints. Secrets and
5//! filesystem paths never enter replicated schema.
6
7use std::collections::BTreeMap;
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::{Arc, RwLock};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15pub const DEFAULT_MAX_EMBEDDING_TEXTS: usize = 256;
16pub const DEFAULT_MAX_EMBEDDING_TEXT_BYTES: usize = 64 * 1024;
17pub const DEFAULT_MAX_EMBEDDING_INPUT_BYTES: usize = 1024 * 1024;
18pub const DEFAULT_MAX_EMBEDDING_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum EmbeddingNormalization {
23    #[default]
24    None,
25    L2,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum EmbeddingFailurePolicy {
31    /// Provider failure aborts the source write. Source and vector commit
32    /// atomically or not at all.
33    #[default]
34    AbortWrite,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum EmbeddingGenerationStatus {
40    Ready,
41    Pending,
42    Failed,
43}
44
45/// Immutable cryptographic identity of a semantic embedding model and its preprocessing space.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct EmbeddingProviderRef {
48    pub provider_id: String,
49    pub provider_version: String,
50    pub model_id: String,
51    pub model_version: String,
52    pub model_artifact_sha256: [u8; 32],
53    pub tokenizer_sha256: [u8; 32],
54    pub preprocessing_sha256: [u8; 32],
55    pub dimension: u32,
56    pub normalization: EmbeddingNormalization,
57}
58
59impl EmbeddingProviderRef {
60    pub fn fingerprint_sha256(&self) -> [u8; 32] {
61        let mut hasher = Sha256::new();
62        hasher.update(self.provider_id.as_bytes());
63        hasher.update([0]);
64        hasher.update(self.provider_version.as_bytes());
65        hasher.update([0]);
66        hasher.update(self.model_id.as_bytes());
67        hasher.update([0]);
68        hasher.update(self.model_version.as_bytes());
69        hasher.update([0]);
70        hasher.update(self.model_artifact_sha256);
71        hasher.update(self.tokenizer_sha256);
72        hasher.update(self.preprocessing_sha256);
73        hasher.update(self.dimension.to_le_bytes());
74        hasher.update([self.normalization as u8]);
75        hasher.finalize().into()
76    }
77    pub fn versioned_identity_key(&self) -> (&str, &str, &str, &str) {
78        (
79            &self.provider_id,
80            &self.provider_version,
81            &self.model_id,
82            &self.model_version,
83        )
84    }
85}
86
87/// Durable provenance for a generated embedding value.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct GeneratedEmbeddingMetadata {
90    pub provider_id: String,
91    pub model_id: String,
92    pub model_version: String,
93    pub preprocessing_version: String,
94    pub source_fingerprint: [u8; 32],
95    pub status: EmbeddingGenerationStatus,
96    pub last_error_category: Option<mongreldb_types::errors::ErrorCategory>,
97    pub attempt_count: u32,
98    pub semantic_identity: EmbeddingProviderRef,
99    pub provider_registry_generation: u64,
100}
101
102/// Generated vector and its durable provenance.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct GeneratedEmbeddingValue {
105    pub vector: Vec<f32>,
106    pub metadata: GeneratedEmbeddingMetadata,
107}
108
109/// Portable generated-column contract stored in schema.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct GeneratedEmbeddingSpec {
112    pub provider_id: String,
113    pub model_id: String,
114    pub model_version: String,
115    pub source_columns: Vec<u16>,
116    /// Template placeholders use `{column_name}`. Empty means source values
117    /// joined with one newline in `source_columns` order.
118    pub input_template: String,
119    pub dimension: u32,
120    pub normalization: EmbeddingNormalization,
121    pub failure_policy: EmbeddingFailurePolicy,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
125#[serde(tag = "kind", rename_all = "snake_case")]
126pub enum EmbeddingSource {
127    #[default]
128    SuppliedByApplication,
129    /// Compatibility shape for 0.60.3 Kit clients. The node-local path is
130    /// accepted in memory but never serialized into replicated schema.
131    LocalModel {
132        model_id: String,
133        #[serde(skip)]
134        model_path: std::path::PathBuf,
135    },
136    /// Compatibility shape for explicit Kit embedding calls. Ordinary writes
137    /// do not materialize this legacy source.
138    GeneratedColumn {
139        provider: String,
140    },
141    /// A local provider resolved from node configuration. No path is stored.
142    ConfiguredModel {
143        provider_id: String,
144        model_id: String,
145        model_version: String,
146    },
147    GeneratedColumnSpec {
148        spec: GeneratedEmbeddingSpec,
149    },
150}
151
152impl EmbeddingSource {
153    pub fn label(&self) -> &str {
154        match self {
155            Self::SuppliedByApplication => "supplied_by_application",
156            Self::LocalModel { .. } | Self::ConfiguredModel { .. } => "local_model",
157            Self::GeneratedColumn { .. } | Self::GeneratedColumnSpec { .. } => "generated_column",
158        }
159    }
160
161    pub fn provider_id(&self) -> Option<&str> {
162        match self {
163            Self::SuppliedByApplication => None,
164            Self::LocalModel { model_id, .. } => Some(model_id),
165            Self::GeneratedColumn { provider } => Some(provider),
166            Self::ConfiguredModel { provider_id, .. } => Some(provider_id),
167            Self::GeneratedColumnSpec { spec } => Some(&spec.provider_id),
168        }
169    }
170
171    pub fn model_id(&self) -> Option<&str> {
172        match self {
173            Self::SuppliedByApplication => None,
174            Self::LocalModel { model_id, .. } => Some(model_id),
175            Self::GeneratedColumn { .. } => None,
176            Self::ConfiguredModel { model_id, .. } => Some(model_id),
177            Self::GeneratedColumnSpec { spec } => Some(&spec.model_id),
178        }
179    }
180
181    pub fn model_version(&self) -> Option<&str> {
182        match self {
183            Self::SuppliedByApplication => None,
184            Self::LocalModel { .. } | Self::GeneratedColumn { .. } => None,
185            Self::ConfiguredModel { model_version, .. } => Some(model_version),
186            Self::GeneratedColumnSpec { spec } => Some(&spec.model_version),
187        }
188    }
189
190    pub fn requires_provider(&self) -> bool {
191        !matches!(self, Self::SuppliedByApplication)
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
196pub enum EmbeddingError {
197    #[error("embedding provider {0:?} is not registered")]
198    ProviderNotFound(String),
199    #[error("embedding provider {0:?} is already registered")]
200    ProviderAlreadyRegistered(String),
201    #[error(
202        "embedding provider {provider:?} generation mismatch: expected {expected}, got {actual}"
203    )]
204    ProviderGenerationMismatch {
205        provider: String,
206        expected: u64,
207        actual: u64,
208    },
209    #[error("embedding provider {provider:?} is referenced by {references} schema columns")]
210    ProviderInUse { provider: String, references: usize },
211    #[error("embedding provider {provider:?}: {message}")]
212    ProviderFailed { provider: String, message: String },
213    #[error("embedding source is SuppliedByApplication")]
214    SuppliedByApplication,
215    #[error("embedding provider identity mismatch: expected {expected:?}, got {got:?}")]
216    ProviderIdentityMismatch { expected: String, got: String },
217    #[error("embedding model identity mismatch: expected {expected:?}, got {got:?}")]
218    ModelIdentityMismatch { expected: String, got: String },
219    #[error("embedding model version mismatch: expected {expected:?}, got {got:?}")]
220    ModelVersionMismatch { expected: String, got: String },
221    #[error("embedding provider {provider:?} replacement under versioned identity (provider_version={provider_version:?}, model_id={model_id:?}, model_version={model_version:?}) changed cryptographic semantic fingerprints; bump provider/model version for a new semantic space")]
222    SemanticIdentityImmutable {
223        provider: String,
224        provider_version: String,
225        model_id: String,
226        model_version: String,
227    },
228    #[error("ANN generation semantic identity mismatch for column {column_id}: expected fingerprint {expected:x?}, got {got:x?}")]
229    AnnSemanticIdentityMismatch {
230        column_id: u16,
231        expected: [u8; 32],
232        got: [u8; 32],
233    },
234    #[error("embedding output count mismatch: expected {expected}, got {got}")]
235    OutputCountMismatch { expected: usize, got: usize },
236    #[error("embedding dimension mismatch: expected {expected}, got {got}")]
237    DimensionMismatch { expected: u32, got: u32 },
238    #[error("embedding request exceeds {resource}: requested {requested}, limit {limit}")]
239    LimitExceeded {
240        resource: &'static str,
241        requested: usize,
242        limit: usize,
243    },
244    #[error("embedding output is not finite")]
245    NonFiniteOutput,
246    #[error("embedding output is not L2 normalized")]
247    NormalizationMismatch,
248    #[error("embedding execution cancelled or timed out: {0}")]
249    Execution(String),
250    #[error("no active ANN semantic identity for embedding column {0}")]
251    NoActiveAnnIdentity(u16),
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum ProviderHealth {
256    Ready,
257    Degraded,
258    Unavailable,
259}
260
261/// Where provider work executes.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
263pub enum ProviderExecutionMode {
264    /// Short work that cooperatively checks [`crate::ExecutionControl`].
265    #[default]
266    Cooperative,
267    /// CPU-heavy or blocking local work. The registry runs it on its bounded
268    /// blocking pool.
269    Blocking,
270    /// A genuinely asynchronous remote transport implemented by
271    /// [`EmbeddingProvider::embed_async`].
272    Remote,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub struct EmbeddingLimits {
277    pub max_texts: usize,
278    pub max_text_bytes: usize,
279    pub max_total_input_bytes: usize,
280    pub max_dimension: u32,
281    pub max_output_bytes: usize,
282}
283
284impl Default for EmbeddingLimits {
285    fn default() -> Self {
286        Self {
287            max_texts: DEFAULT_MAX_EMBEDDING_TEXTS,
288            max_text_bytes: DEFAULT_MAX_EMBEDDING_TEXT_BYTES,
289            max_total_input_bytes: DEFAULT_MAX_EMBEDDING_INPUT_BYTES,
290            max_dimension: crate::schema::Schema::MAX_EMBEDDING_DIM,
291            max_output_bytes: DEFAULT_MAX_EMBEDDING_OUTPUT_BYTES,
292        }
293    }
294}
295
296pub struct EmbeddingRequest<'a> {
297    pub texts: &'a [&'a str],
298    pub control: &'a crate::ExecutionControl,
299    pub trace_id: &'a str,
300}
301
302#[derive(Debug, Clone, PartialEq)]
303pub struct EmbeddingResponse {
304    pub vectors: Vec<Vec<f32>>,
305}
306
307pub type EmbeddingFuture<'a> =
308    Pin<Box<dyn Future<Output = Result<EmbeddingResponse, EmbeddingError>> + Send + 'a>>;
309
310pub trait EmbeddingProvider: Send + Sync {
311    fn provider_id(&self) -> &str;
312    fn model_id(&self) -> &str;
313    fn model_version(&self) -> &str;
314    fn dimension(&self) -> u32;
315    fn normalization(&self) -> EmbeddingNormalization;
316    fn preprocessing_version(&self) -> &str;
317    fn provider_version(&self) -> &str {
318        "1"
319    }
320    fn model_artifact_sha256(&self) -> [u8; 32] {
321        let mut hasher = Sha256::new();
322        hasher.update(b"model-artifact:");
323        hasher.update(self.model_id().as_bytes());
324        hasher.update([0]);
325        hasher.update(self.model_version().as_bytes());
326        hasher.finalize().into()
327    }
328    fn tokenizer_sha256(&self) -> [u8; 32] {
329        let mut hasher = Sha256::new();
330        hasher.update(b"tokenizer:");
331        hasher.update(self.model_id().as_bytes());
332        hasher.update([0]);
333        hasher.update(self.preprocessing_version().as_bytes());
334        hasher.finalize().into()
335    }
336    fn preprocessing_sha256(&self) -> [u8; 32] {
337        let mut hasher = Sha256::new();
338        hasher.update(b"preprocessing:");
339        hasher.update(self.preprocessing_version().as_bytes());
340        hasher.update([0]);
341        hasher.update([self.normalization() as u8]);
342        hasher.finalize().into()
343    }
344    fn semantic_identity(&self) -> EmbeddingProviderRef {
345        EmbeddingProviderRef {
346            provider_id: self.provider_id().to_owned(),
347            provider_version: self.provider_version().to_owned(),
348            model_id: self.model_id().to_owned(),
349            model_version: self.model_version().to_owned(),
350            model_artifact_sha256: self.model_artifact_sha256(),
351            tokenizer_sha256: self.tokenizer_sha256(),
352            preprocessing_sha256: self.preprocessing_sha256(),
353            dimension: self.dimension(),
354            normalization: self.normalization(),
355        }
356    }
357    fn execution_mode(&self) -> ProviderExecutionMode {
358        ProviderExecutionMode::Cooperative
359    }
360
361    fn health(&self) -> ProviderHealth {
362        ProviderHealth::Ready
363    }
364
365    /// Controlled synchronous inference used by transactional generated
366    /// columns. Implementations must checkpoint during long work.
367    fn embed(&self, request: EmbeddingRequest<'_>) -> Result<EmbeddingResponse, EmbeddingError>;
368
369    /// Async transport hook for remote providers. The default is suitable only
370    /// for short cooperative local inference; heavy local implementations must
371    /// override this and use a bounded blocking executor.
372    fn embed_async<'a>(&'a self, request: EmbeddingRequest<'a>) -> EmbeddingFuture<'a> {
373        Box::pin(async move { self.embed(request) })
374    }
375}
376
377#[derive(Clone)]
378struct ProviderEntry {
379    generation: u64,
380    provider: Arc<dyn EmbeddingProvider>,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct ProviderStatus {
385    pub provider_id: String,
386    pub model_id: String,
387    pub model_version: String,
388    pub generation: u64,
389    pub health: ProviderHealth,
390    pub semantic_identity: EmbeddingProviderRef,
391}
392
393#[derive(Clone)]
394pub struct EmbeddingProviderRegistry {
395    inner: Arc<RwLock<BTreeMap<String, ProviderEntry>>>,
396    blocking_slots: Arc<tokio::sync::Semaphore>,
397}
398
399impl Default for EmbeddingProviderRegistry {
400    fn default() -> Self {
401        let concurrency = std::thread::available_parallelism()
402            .map(usize::from)
403            .unwrap_or(1)
404            .clamp(1, 32);
405        Self {
406            inner: Arc::new(RwLock::new(BTreeMap::new())),
407            blocking_slots: Arc::new(tokio::sync::Semaphore::new(concurrency)),
408        }
409    }
410}
411
412impl std::fmt::Debug for EmbeddingProviderRegistry {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        f.debug_struct("EmbeddingProviderRegistry")
415            .field("providers", &self.list_ids())
416            .finish()
417    }
418}
419
420impl EmbeddingProviderRegistry {
421    pub fn new() -> Self {
422        Self::default()
423    }
424
425    /// Registry with an explicit blocking-provider concurrency ceiling.
426    pub fn with_max_blocking_concurrency(max_concurrency: usize) -> Self {
427        Self {
428            inner: Arc::new(RwLock::new(BTreeMap::new())),
429            blocking_slots: Arc::new(tokio::sync::Semaphore::new(max_concurrency.max(1))),
430        }
431    }
432
433    pub fn register_new(
434        &self,
435        provider: Arc<dyn EmbeddingProvider>,
436    ) -> Result<u64, EmbeddingError> {
437        let id = provider.provider_id().to_owned();
438        let mut providers = self
439            .inner
440            .write()
441            .unwrap_or_else(|error| error.into_inner());
442        if providers.contains_key(&id) {
443            return Err(EmbeddingError::ProviderAlreadyRegistered(id));
444        }
445        providers.insert(
446            id,
447            ProviderEntry {
448                generation: 1,
449                provider,
450            },
451        );
452        Ok(1)
453    }
454
455    /// Compatibility entry point for 0.60.3 Kit clients. Duplicate IDs keep
456    /// the existing provider instead of silently replacing it.
457    #[deprecated(note = "use register_new and handle duplicate provider IDs")]
458    pub fn register(&self, provider: Arc<dyn EmbeddingProvider>) {
459        let _ = self.register_new(provider);
460    }
461
462    pub fn replace(
463        &self,
464        expected_generation: u64,
465        provider: Arc<dyn EmbeddingProvider>,
466    ) -> Result<u64, EmbeddingError> {
467        let id = provider.provider_id().to_owned();
468        let new_identity = provider.semantic_identity();
469        let mut providers = self
470            .inner
471            .write()
472            .unwrap_or_else(|error| error.into_inner());
473        let entry = providers
474            .get_mut(&id)
475            .ok_or_else(|| EmbeddingError::ProviderNotFound(id.clone()))?;
476        if entry.generation != expected_generation {
477            return Err(EmbeddingError::ProviderGenerationMismatch {
478                provider: id,
479                expected: expected_generation,
480                actual: entry.generation,
481            });
482        }
483        let existing_identity = entry.provider.semantic_identity();
484        if existing_identity.versioned_identity_key() == new_identity.versioned_identity_key()
485            && existing_identity != new_identity
486        {
487            return Err(EmbeddingError::SemanticIdentityImmutable {
488                provider: id,
489                provider_version: new_identity.provider_version,
490                model_id: new_identity.model_id,
491                model_version: new_identity.model_version,
492            });
493        }
494        entry.generation = entry.generation.saturating_add(1);
495        entry.provider = provider;
496        Ok(entry.generation)
497    }
498
499    pub(crate) fn unregister_unreferenced(
500        &self,
501        provider_id: &str,
502        references: usize,
503    ) -> Result<bool, EmbeddingError> {
504        if references != 0 {
505            return Err(EmbeddingError::ProviderInUse {
506                provider: provider_id.to_owned(),
507                references,
508            });
509        }
510        Ok(self
511            .inner
512            .write()
513            .unwrap_or_else(|error| error.into_inner())
514            .remove(provider_id)
515            .is_some())
516    }
517
518    pub fn get(&self, provider_id: &str) -> Option<Arc<dyn EmbeddingProvider>> {
519        self.inner
520            .read()
521            .unwrap_or_else(|error| error.into_inner())
522            .get(provider_id)
523            .map(|entry| Arc::clone(&entry.provider))
524    }
525    pub fn get_with_generation(
526        &self,
527        provider_id: &str,
528    ) -> Option<(u64, Arc<dyn EmbeddingProvider>)> {
529        self.inner
530            .read()
531            .unwrap_or_else(|e| e.into_inner())
532            .get(provider_id)
533            .map(|e| (e.generation, Arc::clone(&e.provider)))
534    }
535    pub fn generation(&self, provider_id: &str) -> Option<u64> {
536        self.inner
537            .read()
538            .unwrap_or_else(|e| e.into_inner())
539            .get(provider_id)
540            .map(|e| e.generation)
541    }
542    pub fn list_ids(&self) -> Vec<String> {
543        self.inner
544            .read()
545            .unwrap_or_else(|error| error.into_inner())
546            .keys()
547            .cloned()
548            .collect()
549    }
550
551    pub fn status(&self, provider_id: &str) -> Option<ProviderStatus> {
552        let providers = self.inner.read().unwrap_or_else(|error| error.into_inner());
553        let entry = providers.get(provider_id)?;
554        Some(ProviderStatus {
555            provider_id: provider_id.to_owned(),
556            model_id: entry.provider.model_id().to_owned(),
557            model_version: entry.provider.model_version().to_owned(),
558            generation: entry.generation,
559            health: entry.provider.health(),
560            semantic_identity: entry.provider.semantic_identity(),
561        })
562    }
563    pub fn resolve(
564        &self,
565        source: &EmbeddingSource,
566    ) -> Result<Arc<dyn EmbeddingProvider>, EmbeddingError> {
567        Ok(self.resolve_with_generation(source)?.1)
568    }
569    pub fn resolve_with_generation(
570        &self,
571        source: &EmbeddingSource,
572    ) -> Result<(u64, Arc<dyn EmbeddingProvider>), EmbeddingError> {
573        let provider_id = source
574            .provider_id()
575            .ok_or(EmbeddingError::SuppliedByApplication)?;
576        self.get_with_generation(provider_id)
577            .ok_or_else(|| EmbeddingError::ProviderNotFound(provider_id.to_owned()))
578    }
579    pub fn resolve_by_semantic_identity(
580        &self,
581        identity: &EmbeddingProviderRef,
582    ) -> Result<(u64, Arc<dyn EmbeddingProvider>), EmbeddingError> {
583        let providers = self.inner.read().unwrap_or_else(|e| e.into_inner());
584        for entry in providers.values() {
585            if &entry.provider.semantic_identity() == identity {
586                return Ok((entry.generation, Arc::clone(&entry.provider)));
587            }
588        }
589        Err(EmbeddingError::ProviderNotFound(format!(
590            "semantic identity provider_id={} model_id={} model_version={}",
591            identity.provider_id, identity.model_id, identity.model_version
592        )))
593    }
594
595    pub fn embed(
596        &self,
597        source: &EmbeddingSource,
598        texts: &[&str],
599        expected_dim: u32,
600    ) -> Result<Vec<Vec<f32>>, EmbeddingError> {
601        self.embed_controlled(
602            source,
603            texts,
604            expected_dim,
605            &crate::ExecutionControl::new(None),
606            "embedding",
607            EmbeddingLimits::default(),
608        )
609    }
610
611    pub fn embed_controlled(
612        &self,
613        source: &EmbeddingSource,
614        texts: &[&str],
615        expected_dim: u32,
616        control: &crate::ExecutionControl,
617        trace_id: &str,
618        limits: EmbeddingLimits,
619    ) -> Result<Vec<Vec<f32>>, EmbeddingError> {
620        control
621            .checkpoint()
622            .map_err(|error| EmbeddingError::Execution(error.to_string()))?;
623        validate_request(texts, expected_dim, limits)?;
624        let provider = self.resolve(source)?;
625        validate_identity(source, provider.as_ref())?;
626        if provider.health() == ProviderHealth::Unavailable {
627            return Err(EmbeddingError::ProviderFailed {
628                provider: provider.provider_id().to_owned(),
629                message: "provider unavailable".into(),
630            });
631        }
632        if provider.dimension() != expected_dim {
633            return Err(EmbeddingError::DimensionMismatch {
634                expected: expected_dim,
635                got: provider.dimension(),
636            });
637        }
638        let response = provider.embed(EmbeddingRequest {
639            texts,
640            control,
641            trace_id,
642        })?;
643        control
644            .checkpoint()
645            .map_err(|error| EmbeddingError::Execution(error.to_string()))?;
646        validate_response(
647            provider.as_ref(),
648            texts.len(),
649            expected_dim,
650            limits,
651            &response.vectors,
652        )?;
653        Ok(response.vectors)
654    }
655
656    /// Async provider execution for server/runtime callers.
657    ///
658    /// Blocking local providers run under a bounded semaphore on Tokio's
659    /// blocking pool. Remote providers must implement `embed_async`; both
660    /// paths stop waiting when the execution control is cancelled or expires.
661    pub async fn embed_async_controlled(
662        &self,
663        source: &EmbeddingSource,
664        texts: &[&str],
665        expected_dim: u32,
666        control: &crate::ExecutionControl,
667        trace_id: &str,
668        limits: EmbeddingLimits,
669    ) -> Result<Vec<Vec<f32>>, EmbeddingError> {
670        control
671            .checkpoint()
672            .map_err(|error| EmbeddingError::Execution(error.to_string()))?;
673        validate_request(texts, expected_dim, limits)?;
674        let provider = self.resolve(source)?;
675        validate_identity(source, provider.as_ref())?;
676        if provider.health() == ProviderHealth::Unavailable {
677            return Err(EmbeddingError::ProviderFailed {
678                provider: provider.provider_id().to_owned(),
679                message: "provider unavailable".into(),
680            });
681        }
682        if provider.dimension() != expected_dim {
683            return Err(EmbeddingError::DimensionMismatch {
684                expected: expected_dim,
685                got: provider.dimension(),
686            });
687        }
688
689        let response = match provider.execution_mode() {
690            ProviderExecutionMode::Cooperative | ProviderExecutionMode::Remote => {
691                let request = EmbeddingRequest {
692                    texts,
693                    control,
694                    trace_id,
695                };
696                tokio::select! {
697                    result = provider.embed_async(request) => result?,
698                    _ = control.cancelled() => {
699                        return Err(execution_stopped(control));
700                    }
701                }
702            }
703            ProviderExecutionMode::Blocking => {
704                let permit = tokio::select! {
705                    result = Arc::clone(&self.blocking_slots).acquire_owned() => {
706                        result.map_err(|_| EmbeddingError::Execution(
707                            "embedding blocking executor closed".into()
708                        ))?
709                    }
710                    _ = control.cancelled() => {
711                        return Err(execution_stopped(control));
712                    }
713                };
714                let provider = Arc::clone(&provider);
715                let owned_texts = texts
716                    .iter()
717                    .map(|text| (*text).to_owned())
718                    .collect::<Vec<_>>();
719                let owned_control = control.clone();
720                let owned_trace_id = trace_id.to_owned();
721                let task = tokio::task::spawn_blocking(move || {
722                    let _permit = permit;
723                    let text_refs = owned_texts.iter().map(String::as_str).collect::<Vec<_>>();
724                    provider.embed(EmbeddingRequest {
725                        texts: &text_refs,
726                        control: &owned_control,
727                        trace_id: &owned_trace_id,
728                    })
729                });
730                tokio::select! {
731                    result = task => {
732                        result
733                            .map_err(|error| EmbeddingError::Execution(
734                                format!("embedding blocking task failed: {error}")
735                            ))??
736                    }
737                    _ = control.cancelled() => {
738                        return Err(execution_stopped(control));
739                    }
740                }
741            }
742        };
743
744        control
745            .checkpoint()
746            .map_err(|error| EmbeddingError::Execution(error.to_string()))?;
747        validate_response(
748            provider.as_ref(),
749            texts.len(),
750            expected_dim,
751            limits,
752            &response.vectors,
753        )?;
754        Ok(response.vectors)
755    }
756}
757
758fn execution_stopped(control: &crate::ExecutionControl) -> EmbeddingError {
759    let message = control
760        .checkpoint()
761        .expect_err("cancelled future completed without an execution error")
762        .to_string();
763    EmbeddingError::Execution(message)
764}
765
766fn validate_identity(
767    source: &EmbeddingSource,
768    provider: &dyn EmbeddingProvider,
769) -> Result<(), EmbeddingError> {
770    if let EmbeddingSource::GeneratedColumnSpec { spec } = source {
771        if spec.normalization != provider.normalization() {
772            return Err(EmbeddingError::NormalizationMismatch);
773        }
774        if spec.dimension != provider.dimension() {
775            return Err(EmbeddingError::DimensionMismatch {
776                expected: spec.dimension,
777                got: provider.dimension(),
778            });
779        }
780    }
781    if source
782        .provider_id()
783        .is_some_and(|id| id != provider.provider_id())
784    {
785        return Err(EmbeddingError::ProviderIdentityMismatch {
786            expected: source.provider_id().unwrap_or_default().to_owned(),
787            got: provider.provider_id().to_owned(),
788        });
789    }
790    if source
791        .model_id()
792        .is_some_and(|id| id != provider.model_id())
793    {
794        return Err(EmbeddingError::ModelIdentityMismatch {
795            expected: source.model_id().unwrap_or_default().to_owned(),
796            got: provider.model_id().to_owned(),
797        });
798    }
799    if source
800        .model_version()
801        .is_some_and(|version| version != provider.model_version())
802    {
803        return Err(EmbeddingError::ModelVersionMismatch {
804            expected: source.model_version().unwrap_or_default().to_owned(),
805            got: provider.model_version().to_owned(),
806        });
807    }
808    Ok(())
809}
810
811fn validate_request(
812    texts: &[&str],
813    dimension: u32,
814    limits: EmbeddingLimits,
815) -> Result<(), EmbeddingError> {
816    if texts.len() > limits.max_texts {
817        return Err(EmbeddingError::LimitExceeded {
818            resource: "text count",
819            requested: texts.len(),
820            limit: limits.max_texts,
821        });
822    }
823    if dimension > limits.max_dimension {
824        return Err(EmbeddingError::LimitExceeded {
825            resource: "dimension",
826            requested: dimension as usize,
827            limit: limits.max_dimension as usize,
828        });
829    }
830    let mut total = 0usize;
831    for text in texts {
832        if text.len() > limits.max_text_bytes {
833            return Err(EmbeddingError::LimitExceeded {
834                resource: "text bytes",
835                requested: text.len(),
836                limit: limits.max_text_bytes,
837            });
838        }
839        total = total.saturating_add(text.len());
840    }
841    if total > limits.max_total_input_bytes {
842        return Err(EmbeddingError::LimitExceeded {
843            resource: "total input bytes",
844            requested: total,
845            limit: limits.max_total_input_bytes,
846        });
847    }
848    Ok(())
849}
850
851pub fn validate_response(
852    provider: &dyn EmbeddingProvider,
853    expected_count: usize,
854    expected_dim: u32,
855    limits: EmbeddingLimits,
856    vectors: &[Vec<f32>],
857) -> Result<(), EmbeddingError> {
858    if vectors.len() != expected_count {
859        return Err(EmbeddingError::OutputCountMismatch {
860            expected: expected_count,
861            got: vectors.len(),
862        });
863    }
864    let output_bytes = vectors
865        .len()
866        .saturating_mul(expected_dim as usize)
867        .saturating_mul(std::mem::size_of::<f32>());
868    if output_bytes > limits.max_output_bytes {
869        return Err(EmbeddingError::LimitExceeded {
870            resource: "output bytes",
871            requested: output_bytes,
872            limit: limits.max_output_bytes,
873        });
874    }
875    for vector in vectors {
876        if vector.len() as u32 != expected_dim {
877            return Err(EmbeddingError::DimensionMismatch {
878                expected: expected_dim,
879                got: vector.len() as u32,
880            });
881        }
882        if vector.iter().any(|value| !value.is_finite()) {
883            return Err(EmbeddingError::NonFiniteOutput);
884        }
885        if provider.normalization() == EmbeddingNormalization::L2 {
886            let norm = vector
887                .iter()
888                .map(|value| (*value as f64) * (*value as f64))
889                .sum::<f64>()
890                .sqrt();
891            if (norm - 1.0).abs() > 1e-4 {
892                return Err(EmbeddingError::NormalizationMismatch);
893            }
894        }
895    }
896    Ok(())
897}
898
899#[derive(Debug, Clone)]
900pub struct FixedVectorProvider {
901    pub id: String,
902    pub model_id: String,
903    pub model_version: String,
904    pub normalization: EmbeddingNormalization,
905    pub vector: Vec<f32>,
906    pub provider_version: String,
907    pub model_artifact_sha256: Option<[u8; 32]>,
908    pub tokenizer_sha256: Option<[u8; 32]>,
909    pub preprocessing_sha256: Option<[u8; 32]>,
910}
911impl FixedVectorProvider {
912    pub fn new(
913        id: impl Into<String>,
914        model_id: impl Into<String>,
915        model_version: impl Into<String>,
916        normalization: EmbeddingNormalization,
917        vector: Vec<f32>,
918    ) -> Self {
919        Self {
920            id: id.into(),
921            model_id: model_id.into(),
922            model_version: model_version.into(),
923            normalization,
924            vector,
925            provider_version: "1".into(),
926            model_artifact_sha256: None,
927            tokenizer_sha256: None,
928            preprocessing_sha256: None,
929        }
930    }
931    pub fn with_artifact_sha256(mut self, sha: [u8; 32]) -> Self {
932        self.model_artifact_sha256 = Some(sha);
933        self
934    }
935}
936impl EmbeddingProvider for FixedVectorProvider {
937    fn provider_id(&self) -> &str {
938        &self.id
939    }
940    fn model_id(&self) -> &str {
941        &self.model_id
942    }
943    fn model_version(&self) -> &str {
944        &self.model_version
945    }
946    fn dimension(&self) -> u32 {
947        self.vector.len() as u32
948    }
949    fn normalization(&self) -> EmbeddingNormalization {
950        self.normalization
951    }
952    fn preprocessing_version(&self) -> &str {
953        "fixed-test-v1"
954    }
955    fn provider_version(&self) -> &str {
956        &self.provider_version
957    }
958    fn model_artifact_sha256(&self) -> [u8; 32] {
959        self.model_artifact_sha256.unwrap_or_else(|| {
960            let mut h = Sha256::new();
961            h.update(b"model-artifact:");
962            h.update(self.model_id.as_bytes());
963            h.update([0]);
964            h.update(self.model_version.as_bytes());
965            h.finalize().into()
966        })
967    }
968    fn tokenizer_sha256(&self) -> [u8; 32] {
969        self.tokenizer_sha256.unwrap_or_else(|| {
970            let mut h = Sha256::new();
971            h.update(b"tokenizer:");
972            h.update(self.model_id.as_bytes());
973            h.update([0]);
974            h.update(b"fixed-test-v1");
975            h.finalize().into()
976        })
977    }
978    fn preprocessing_sha256(&self) -> [u8; 32] {
979        self.preprocessing_sha256.unwrap_or_else(|| {
980            let mut h = Sha256::new();
981            h.update(b"preprocessing:");
982            h.update(b"fixed-test-v1");
983            h.update([0]);
984            h.update([self.normalization as u8]);
985            h.finalize().into()
986        })
987    }
988    fn embed(&self, request: EmbeddingRequest<'_>) -> Result<EmbeddingResponse, EmbeddingError> {
989        request
990            .control
991            .checkpoint()
992            .map_err(|e| EmbeddingError::Execution(e.to_string()))?;
993        Ok(EmbeddingResponse {
994            vectors: request.texts.iter().map(|_| self.vector.clone()).collect(),
995        })
996    }
997}
998
999#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1000pub struct EmbeddingModelMeta {
1001    pub provider_id: Option<String>,
1002    pub model_id: Option<String>,
1003    pub model_version: Option<String>,
1004    pub preprocessing_version: Option<String>,
1005    pub normalization: EmbeddingNormalization,
1006    pub dimension: u32,
1007    pub source_kind: Option<String>,
1008    #[serde(default)]
1009    pub semantic_identity: Option<EmbeddingProviderRef>,
1010}
1011impl EmbeddingModelMeta {
1012    pub fn from_provider(source: &EmbeddingSource, provider: &dyn EmbeddingProvider) -> Self {
1013        Self {
1014            provider_id: Some(provider.provider_id().to_owned()),
1015            model_id: Some(provider.model_id().to_owned()),
1016            model_version: Some(provider.model_version().to_owned()),
1017            preprocessing_version: Some(provider.preprocessing_version().to_owned()),
1018            normalization: provider.normalization(),
1019            dimension: provider.dimension(),
1020            source_kind: Some(source.label().to_owned()),
1021            semantic_identity: Some(provider.semantic_identity()),
1022        }
1023    }
1024}
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1026pub struct TextSearchOptions {
1027    pub k: usize,
1028}
1029impl Default for TextSearchOptions {
1030    fn default() -> Self {
1031        Self { k: 10 }
1032    }
1033}
1034impl TextSearchOptions {
1035    pub fn new(k: usize) -> Self {
1036        Self { k }
1037    }
1038}
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1040pub struct TextRetrieveProvenance {
1041    pub semantic_identity: EmbeddingProviderRef,
1042    pub provider_registry_generation: u64,
1043    pub query_source_fingerprint: [u8; 32],
1044    pub embedding_column: u16,
1045}
1046#[derive(Debug, Clone, PartialEq)]
1047pub struct TextRetrieveResult {
1048    pub hits: Vec<crate::query::RetrieverHit>,
1049    pub provenance: TextRetrieveProvenance,
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    struct SlowBlockingProvider;
1057
1058    impl EmbeddingProvider for SlowBlockingProvider {
1059        fn provider_id(&self) -> &str {
1060            "slow-blocking"
1061        }
1062        fn model_id(&self) -> &str {
1063            "slow"
1064        }
1065        fn model_version(&self) -> &str {
1066            "1"
1067        }
1068        fn dimension(&self) -> u32 {
1069            2
1070        }
1071        fn normalization(&self) -> EmbeddingNormalization {
1072            EmbeddingNormalization::None
1073        }
1074        fn preprocessing_version(&self) -> &str {
1075            "slow-v1"
1076        }
1077        fn execution_mode(&self) -> ProviderExecutionMode {
1078            ProviderExecutionMode::Blocking
1079        }
1080        fn embed(
1081            &self,
1082            request: EmbeddingRequest<'_>,
1083        ) -> Result<EmbeddingResponse, EmbeddingError> {
1084            std::thread::sleep(std::time::Duration::from_millis(50));
1085            Ok(EmbeddingResponse {
1086                vectors: request.texts.iter().map(|_| vec![1.0, 2.0]).collect(),
1087            })
1088        }
1089    }
1090
1091    fn provider() -> Arc<FixedVectorProvider> {
1092        Arc::new(FixedVectorProvider::new(
1093            "local-test",
1094            "fixed",
1095            "1",
1096            EmbeddingNormalization::L2,
1097            vec![0.0, 1.0],
1098        ))
1099    }
1100
1101    fn source() -> EmbeddingSource {
1102        EmbeddingSource::GeneratedColumnSpec {
1103            spec: GeneratedEmbeddingSpec {
1104                provider_id: "local-test".into(),
1105                model_id: "fixed".into(),
1106                model_version: "1".into(),
1107                source_columns: vec![1],
1108                input_template: "{text}".into(),
1109                dimension: 2,
1110                normalization: EmbeddingNormalization::L2,
1111                failure_policy: EmbeddingFailurePolicy::AbortWrite,
1112            },
1113        }
1114    }
1115
1116    #[test]
1117    fn registration_requires_expected_generation() {
1118        let registry = EmbeddingProviderRegistry::new();
1119        assert_eq!(registry.register_new(provider()).unwrap(), 1);
1120        assert!(matches!(
1121            registry.register_new(provider()),
1122            Err(EmbeddingError::ProviderAlreadyRegistered(_))
1123        ));
1124        assert!(matches!(
1125            registry.replace(9, provider()),
1126            Err(EmbeddingError::ProviderGenerationMismatch { .. })
1127        ));
1128        assert_eq!(registry.replace(1, provider()).unwrap(), 2);
1129        assert_eq!(registry.status("local-test").unwrap().generation, 2);
1130    }
1131
1132    #[test]
1133    fn validates_count_dimension_finiteness_limits_and_cancellation() {
1134        let registry = EmbeddingProviderRegistry::new();
1135        registry.register_new(provider()).unwrap();
1136        assert_eq!(registry.embed(&source(), &["a", "b"], 2).unwrap().len(), 2);
1137        let control = crate::ExecutionControl::new(None);
1138        control.cancel(crate::CancellationReason::ClientRequest);
1139        assert!(matches!(
1140            registry.embed_controlled(
1141                &source(),
1142                &["a"],
1143                2,
1144                &control,
1145                "cancelled",
1146                EmbeddingLimits::default(),
1147            ),
1148            Err(EmbeddingError::Execution(_))
1149        ));
1150        assert!(matches!(
1151            registry.embed_controlled(
1152                &source(),
1153                &["too long"],
1154                2,
1155                &crate::ExecutionControl::new(None),
1156                "bounded",
1157                EmbeddingLimits {
1158                    max_text_bytes: 2,
1159                    ..EmbeddingLimits::default()
1160                },
1161            ),
1162            Err(EmbeddingError::LimitExceeded { .. })
1163        ));
1164    }
1165
1166    #[test]
1167    fn legacy_local_model_path_is_process_local_only() {
1168        let source = EmbeddingSource::LocalModel {
1169            model_id: "legacy".into(),
1170            model_path: "/node-only/model.onnx".into(),
1171        };
1172        let encoded = serde_json::to_string(&source).unwrap();
1173        assert!(!encoded.contains("node-only"));
1174        assert_eq!(
1175            serde_json::from_str::<EmbeddingSource>(&encoded).unwrap(),
1176            EmbeddingSource::LocalModel {
1177                model_id: "legacy".into(),
1178                model_path: std::path::PathBuf::new(),
1179            }
1180        );
1181    }
1182
1183    struct WrongCount;
1184    impl EmbeddingProvider for WrongCount {
1185        fn provider_id(&self) -> &str {
1186            "wrong"
1187        }
1188        fn model_id(&self) -> &str {
1189            "wrong-model"
1190        }
1191        fn model_version(&self) -> &str {
1192            "1"
1193        }
1194        fn dimension(&self) -> u32 {
1195            2
1196        }
1197        fn normalization(&self) -> EmbeddingNormalization {
1198            EmbeddingNormalization::None
1199        }
1200        fn preprocessing_version(&self) -> &str {
1201            "1"
1202        }
1203        fn embed(
1204            &self,
1205            _request: EmbeddingRequest<'_>,
1206        ) -> Result<EmbeddingResponse, EmbeddingError> {
1207            Ok(EmbeddingResponse {
1208                vectors: Vec::new(),
1209            })
1210        }
1211    }
1212
1213    #[test]
1214    fn rejects_wrong_output_count() {
1215        let registry = EmbeddingProviderRegistry::new();
1216        registry.register_new(Arc::new(WrongCount)).unwrap();
1217        let source = EmbeddingSource::ConfiguredModel {
1218            provider_id: "wrong".into(),
1219            model_id: "wrong-model".into(),
1220            model_version: "1".into(),
1221        };
1222        assert!(matches!(
1223            registry.embed(&source, &["a"], 2),
1224            Err(EmbeddingError::OutputCountMismatch {
1225                expected: 1,
1226                got: 0
1227            })
1228        ));
1229    }
1230
1231    #[tokio::test(flavor = "current_thread")]
1232    async fn async_path_bounds_blocking_provider_and_honors_deadline() {
1233        let registry = EmbeddingProviderRegistry::with_max_blocking_concurrency(1);
1234        registry
1235            .register_new(Arc::new(SlowBlockingProvider))
1236            .unwrap();
1237        let source = EmbeddingSource::ConfiguredModel {
1238            provider_id: "slow-blocking".into(),
1239            model_id: "slow".into(),
1240            model_version: "1".into(),
1241        };
1242        let control = crate::ExecutionControl::with_timeout(std::time::Duration::from_millis(5));
1243        assert!(matches!(
1244            registry
1245                .embed_async_controlled(
1246                    &source,
1247                    &["text"],
1248                    2,
1249                    &control,
1250                    "deadline-test",
1251                    EmbeddingLimits::default(),
1252                )
1253                .await,
1254            Err(EmbeddingError::Execution(_))
1255        ));
1256    }
1257}