Skip to main content

ruvector_core/
embeddings.rs

1//! Text Embedding Providers
2//!
3//! This module provides a pluggable embedding system for AgenticDB.
4//!
5//! ## Available Providers
6//!
7//! - **HashEmbedding**: Fast hash-based placeholder (default, not semantic)
8//! - **OnnxEmbedding**: Real semantic embeddings using ONNX Runtime (feature: `onnx-embeddings`) ✅ RECOMMENDED
9//! - **LatticeEmbedding**: Real semantic embeddings using lattice-embed, pure-Rust native inference (feature: `lattice-embeddings`)
10//! - **CandleEmbedding**: Real embeddings using candle-transformers (feature: `real-embeddings`)
11//! - **ApiEmbedding**: External API calls (OpenAI, Anthropic, Cohere, etc.)
12//!
13//! ## Usage
14//!
15//! ```rust,no_run
16//! use ruvector_core::embeddings::{EmbeddingProvider, HashEmbedding};
17//!
18//! // Default: Hash-based (fast, but not semantic)
19//! let hash_provider = HashEmbedding::new(384);
20//! let embedding = hash_provider.embed("hello world")?;
21//!
22//! # Ok::<(), Box<dyn std::error::Error>>(())
23//! ```
24//!
25//! ## ONNX Embeddings (Recommended for Production)
26//!
27//! ```rust,ignore
28//! use ruvector_core::embeddings::{EmbeddingProvider, OnnxEmbedding};
29//!
30//! // Real semantic embeddings using all-MiniLM-L6-v2
31//! let provider = OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")?;
32//! let embedding = provider.embed("hello world")?;
33//! // "dog" and "cat" WILL be similar (semantic understanding!)
34//! ```
35
36use crate::error::{Result, RuvectorError};
37use serde::{Deserialize, Serialize};
38use sha2::{Digest, Sha256};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::Arc;
41use unicode_normalization::UnicodeNormalization;
42
43/// The role of text at a retrieval boundary.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum EmbeddingRole {
47    Query,
48    Passage,
49}
50
51/// Whether a model requires distinct query and passage transforms.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum EmbeddingRolePolicy {
55    Symmetric,
56    Asymmetric,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(untagged)]
61pub enum PoolingStrategy {
62    Named(PoolingStrategyName),
63    Custom {
64        custom: String,
65        implementation_revision: String,
66    },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum PoolingStrategyName {
72    Mean,
73    Cls,
74    LastToken,
75    WeightedMean,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum OutputDtype {
81    F32,
82    F16,
83    Bf16,
84    I8,
85    U8,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum EmbeddingDistanceMetric {
91    Cosine,
92    Dot,
93    Euclidean,
94    Manhattan,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "kebab-case")]
99pub enum PrefixPolicy {
100    None,
101    Required,
102    QueryRecommended,
103    Custom,
104}
105
106/// Complete immutable identity of a retrieval vector space.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct EmbeddingSpaceIdentity {
109    pub schema_version: u16,
110    pub provider: String,
111    pub model_id: String,
112    pub model_artifact_sha256: String,
113    pub model_graph_sha256: String,
114    pub tokenizer_sha256: String,
115    pub prompt_template_sha256: String,
116    pub pooling_strategy: PoolingStrategy,
117    pub normalize: bool,
118    pub truncation_tokens: u32,
119    pub output_dimension: u32,
120    pub output_dtype: OutputDtype,
121    pub runtime_revision: String,
122    pub distance_metric: EmbeddingDistanceMetric,
123    pub role_policy: EmbeddingRolePolicy,
124    pub prefix_policy: PrefixPolicy,
125    pub prefix_policy_version: u32,
126}
127
128const EMBEDDING_SPACE_DOMAIN: &[u8] = b"ruvector.embedding-space.v1\0";
129
130/// Revision of this crate's *embedding semantics*, and the only ruvector-owned
131/// version number that may reach `runtime_revision` and therefore
132/// `embedding_space_id`.
133///
134/// Bump this ONLY when a change alters the vectors ruvector produces for an
135/// otherwise-identical model: pooling, normalization, tokenization, truncation,
136/// quantization, or role/prefix handling. Never bump it for a release, a
137/// refactor, or a dependency upgrade.
138///
139/// # Why the crate version is not used here
140/// `embedding_space_id` gates corpus compatibility and cache reuse. If the
141/// crate version were an input, a routine workspace version bump (2.2.3 → 2.3.0
142/// has already happened once in this repo's history) would change every id:
143/// every persisted `AgenticDB` corpus would refuse to reopen with
144/// `EmbeddingSpaceMismatch`, and 100% of cluster cache entries would be
145/// invalidated, without a single vector actually changing. The id must be a
146/// function of the embedding function alone.
147pub const EMBEDDING_SPACE_FORMAT_REVISION: u32 = 1;
148
149/// Build provenance of the running binary — informational only.
150///
151/// This is deliberately NOT a field of [`EmbeddingSpaceIdentity`]: the identity
152/// is a closed, cross-language wire contract (`schemas/embedding-space-identity-v1.json`
153/// pins exactly 17 properties with `additionalProperties: false`, and
154/// `npm/packages/ruvector/src/core/embedding-provenance.ts` mirrors it), and
155/// every field it carries is hashed into `embedding_space_id`. A build version
156/// belongs in logs and diagnostics, not in the vector-space identity — see
157/// [`EMBEDDING_SPACE_FORMAT_REVISION`].
158pub fn build_revision() -> &'static str {
159    env!("CARGO_PKG_VERSION")
160}
161
162/// `runtime_revision` value for a provider class.
163///
164/// Identifies which inference stack produced the vectors plus the semantics
165/// revision above. It carries no crate or dependency version, so upgrading
166/// ruvector or its runtime dependencies leaves every persisted corpus readable
167/// and every cache entry valid.
168fn runtime_revision_for(provider_class: &str) -> String {
169    format!("{provider_class}/space-rev-{EMBEDDING_SPACE_FORMAT_REVISION}")
170}
171
172impl EmbeddingSpaceIdentity {
173    pub fn validate(&self) -> Result<()> {
174        if self.schema_version != 1 {
175            return Err(RuvectorError::InvalidParameter(format!(
176                "unsupported embedding identity schema version {}",
177                self.schema_version
178            )));
179        }
180        if self.output_dimension == 0
181            || self.truncation_tokens == 0
182            || self.prefix_policy_version == 0
183        {
184            return Err(RuvectorError::InvalidParameter(
185                "embedding identity dimensions, truncation, and policy version must be positive"
186                    .into(),
187            ));
188        }
189        for (name, value) in [
190            ("provider", self.provider.as_str()),
191            ("model_id", self.model_id.as_str()),
192            ("runtime_revision", self.runtime_revision.as_str()),
193        ] {
194            if value.is_empty() || value.nfc().collect::<String>() != value {
195                return Err(RuvectorError::InvalidParameter(format!(
196                    "embedding identity {name} must be non-empty NFC"
197                )));
198            }
199        }
200        for (name, value) in [
201            ("model_artifact_sha256", self.model_artifact_sha256.as_str()),
202            ("model_graph_sha256", self.model_graph_sha256.as_str()),
203            ("tokenizer_sha256", self.tokenizer_sha256.as_str()),
204            (
205                "prompt_template_sha256",
206                self.prompt_template_sha256.as_str(),
207            ),
208        ] {
209            if value.len() != 64
210                || !value
211                    .bytes()
212                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
213            {
214                return Err(RuvectorError::InvalidParameter(format!(
215                    "embedding identity {name} must be lowercase SHA-256 hex"
216                )));
217            }
218        }
219        Ok(())
220    }
221
222    /// RFC-8785 canonical JSON for this schema. The schema contains no
223    /// non-integer numbers, so recursively sorting object keys is sufficient.
224    pub fn canonical_json(&self) -> Result<String> {
225        self.validate()?;
226        fn canonical(value: &serde_json::Value, out: &mut String) -> Result<()> {
227            match value {
228                serde_json::Value::Null => out.push_str("null"),
229                serde_json::Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
230                serde_json::Value::Number(v) => out.push_str(&v.to_string()),
231                serde_json::Value::String(v) => out.push_str(
232                    &serde_json::to_string(v)
233                        .map_err(|e| RuvectorError::SerializationError(e.to_string()))?,
234                ),
235                serde_json::Value::Array(values) => {
236                    out.push('[');
237                    for (index, item) in values.iter().enumerate() {
238                        if index > 0 {
239                            out.push(',');
240                        }
241                        canonical(item, out)?;
242                    }
243                    out.push(']');
244                }
245                serde_json::Value::Object(values) => {
246                    out.push('{');
247                    let mut keys: Vec<_> = values.keys().collect();
248                    keys.sort();
249                    for (index, key) in keys.iter().enumerate() {
250                        if index > 0 {
251                            out.push(',');
252                        }
253                        out.push_str(
254                            &serde_json::to_string(key)
255                                .map_err(|e| RuvectorError::SerializationError(e.to_string()))?,
256                        );
257                        out.push(':');
258                        canonical(&values[*key], out)?;
259                    }
260                    out.push('}');
261                }
262            }
263            Ok(())
264        }
265        let value = serde_json::to_value(self)
266            .map_err(|e| RuvectorError::SerializationError(e.to_string()))?;
267        let mut output = String::new();
268        canonical(&value, &mut output)?;
269        Ok(output)
270    }
271
272    pub fn embedding_space_id(&self) -> Result<String> {
273        let mut hash = Sha256::new();
274        hash.update(EMBEDDING_SPACE_DOMAIN);
275        hash.update(self.canonical_json()?.as_bytes());
276        Ok(format!("{:x}", hash.finalize()))
277    }
278}
279
280fn sha256_hex(bytes: &[u8]) -> String {
281    format!("{:x}", Sha256::digest(bytes))
282}
283
284pub fn embedding_cache_key(
285    identity: &EmbeddingSpaceIdentity,
286    role: EmbeddingRole,
287    text: &str,
288) -> Result<String> {
289    let text_hash = sha256_hex(text.as_bytes());
290    let role = match role {
291        EmbeddingRole::Query => "query",
292        EmbeddingRole::Passage => "passage",
293    };
294    Ok(format!(
295        "{}:{role}:{text_hash}",
296        identity.embedding_space_id()?
297    ))
298}
299
300/// The exact query and passage prompt templates a registered model owns.
301///
302/// `{text}` is the single substitution point. The pair is hashed into
303/// [`EmbeddingSpaceIdentity::prompt_template_sha256`], so changing either
304/// template changes the embedding-space identity (ADR-281 §1).
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub struct PromptTemplates {
307    pub query: &'static str,
308    pub passage: &'static str,
309}
310
311impl PromptTemplates {
312    /// Hash covering the canonical query and passage template bundle.
313    pub fn sha256(&self) -> String {
314        sha256_hex(format!("query={}\0passage={}", self.query, self.passage).as_bytes())
315    }
316
317    /// Apply the role's template to raw caller text. Providers call this
318    /// exactly once; high-level callers never prepend prefixes themselves.
319    pub fn apply(&self, role: EmbeddingRole, text: &str) -> String {
320        let template = match role {
321            EmbeddingRole::Query => self.query,
322            EmbeddingRole::Passage => self.passage,
323        };
324        template.replace("{text}", text)
325    }
326}
327
328/// One pinned model fixture: exact artifact revision aliases, output
329/// dimension, role/prefix policy, and the exact prompt templates.
330struct RegistryEntry {
331    aliases: &'static [&'static str],
332    output_dimension: u32,
333    role_policy: EmbeddingRolePolicy,
334    prefix_policy: PrefixPolicy,
335    templates: PromptTemplates,
336}
337
338const SYMMETRIC_TEMPLATES: PromptTemplates = PromptTemplates {
339    query: "{text}",
340    passage: "{text}",
341};
342const BGE_TEMPLATES: PromptTemplates = PromptTemplates {
343    query: "Represent this sentence for searching relevant passages: {text}",
344    passage: "{text}",
345};
346const E5_TEMPLATES: PromptTemplates = PromptTemplates {
347    query: "query: {text}",
348    passage: "passage: {text}",
349};
350const QWEN3_TEMPLATES: PromptTemplates = PromptTemplates {
351    query: "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:{text}",
352    passage: "{text}",
353};
354
355/// Registered models, keyed by exact artifact revision rather than family
356/// name (ADR-281 §2). Dimensions and templates come from each model card;
357/// an id that is not listed here requires a caller-supplied complete
358/// [`EmbeddingSpaceIdentity`].
359static MODEL_REGISTRY: &[RegistryEntry] = &[
360    RegistryEntry {
361        aliases: &[
362            "sentence-transformers/all-MiniLM-L6-v2",
363            "all-MiniLM-L6-v2",
364            "all-minilm-l6-v2",
365        ],
366        output_dimension: 384,
367        role_policy: EmbeddingRolePolicy::Symmetric,
368        prefix_policy: PrefixPolicy::None,
369        templates: SYMMETRIC_TEMPLATES,
370    },
371    RegistryEntry {
372        aliases: &[
373            "sentence-transformers/all-mpnet-base-v2",
374            "all-mpnet-base-v2",
375        ],
376        output_dimension: 768,
377        role_policy: EmbeddingRolePolicy::Symmetric,
378        prefix_policy: PrefixPolicy::None,
379        templates: SYMMETRIC_TEMPLATES,
380    },
381    RegistryEntry {
382        aliases: &[
383            "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
384            "paraphrase-multilingual-MiniLM-L12-v2",
385            "paraphrase-multilingual-minilm-l12-v2",
386        ],
387        output_dimension: 384,
388        role_policy: EmbeddingRolePolicy::Symmetric,
389        prefix_policy: PrefixPolicy::None,
390        templates: SYMMETRIC_TEMPLATES,
391    },
392    RegistryEntry {
393        aliases: &["BAAI/bge-small-en-v1.5", "bge-small-en-v1.5"],
394        output_dimension: 384,
395        role_policy: EmbeddingRolePolicy::Asymmetric,
396        prefix_policy: PrefixPolicy::QueryRecommended,
397        templates: BGE_TEMPLATES,
398    },
399    RegistryEntry {
400        aliases: &["BAAI/bge-base-en-v1.5", "bge-base-en-v1.5"],
401        output_dimension: 768,
402        role_policy: EmbeddingRolePolicy::Asymmetric,
403        prefix_policy: PrefixPolicy::QueryRecommended,
404        templates: BGE_TEMPLATES,
405    },
406    RegistryEntry {
407        aliases: &["BAAI/bge-large-en-v1.5", "bge-large-en-v1.5"],
408        output_dimension: 1024,
409        role_policy: EmbeddingRolePolicy::Asymmetric,
410        prefix_policy: PrefixPolicy::QueryRecommended,
411        templates: BGE_TEMPLATES,
412    },
413    RegistryEntry {
414        aliases: &["intfloat/e5-small-v2", "e5-small-v2"],
415        output_dimension: 384,
416        role_policy: EmbeddingRolePolicy::Asymmetric,
417        prefix_policy: PrefixPolicy::Required,
418        templates: E5_TEMPLATES,
419    },
420    RegistryEntry {
421        aliases: &["intfloat/e5-base-v2", "e5-base-v2"],
422        output_dimension: 768,
423        role_policy: EmbeddingRolePolicy::Asymmetric,
424        prefix_policy: PrefixPolicy::Required,
425        templates: E5_TEMPLATES,
426    },
427    RegistryEntry {
428        aliases: &["intfloat/e5-large-v2", "e5-large-v2"],
429        output_dimension: 1024,
430        role_policy: EmbeddingRolePolicy::Asymmetric,
431        prefix_policy: PrefixPolicy::Required,
432        templates: E5_TEMPLATES,
433    },
434    RegistryEntry {
435        aliases: &["intfloat/multilingual-e5-small", "multilingual-e5-small"],
436        output_dimension: 384,
437        role_policy: EmbeddingRolePolicy::Asymmetric,
438        prefix_policy: PrefixPolicy::Required,
439        templates: E5_TEMPLATES,
440    },
441    RegistryEntry {
442        aliases: &["intfloat/multilingual-e5-base", "multilingual-e5-base"],
443        output_dimension: 768,
444        role_policy: EmbeddingRolePolicy::Asymmetric,
445        prefix_policy: PrefixPolicy::Required,
446        templates: E5_TEMPLATES,
447    },
448    RegistryEntry {
449        aliases: &["intfloat/multilingual-e5-large", "multilingual-e5-large"],
450        output_dimension: 1024,
451        role_policy: EmbeddingRolePolicy::Asymmetric,
452        prefix_policy: PrefixPolicy::Required,
453        templates: E5_TEMPLATES,
454    },
455    RegistryEntry {
456        aliases: &["Qwen/Qwen3-Embedding-0.6B", "qwen3-embedding-0.6b"],
457        output_dimension: 1024,
458        role_policy: EmbeddingRolePolicy::Asymmetric,
459        prefix_policy: PrefixPolicy::Required,
460        templates: QWEN3_TEMPLATES,
461    },
462    RegistryEntry {
463        aliases: &["Qwen/Qwen3-Embedding-4B", "qwen3-embedding-4b"],
464        output_dimension: 2560,
465        role_policy: EmbeddingRolePolicy::Asymmetric,
466        prefix_policy: PrefixPolicy::Required,
467        templates: QWEN3_TEMPLATES,
468    },
469];
470
471fn registry_entry(model_id: &str) -> Option<&'static RegistryEntry> {
472    MODEL_REGISTRY
473        .iter()
474        .find(|entry| entry.aliases.contains(&model_id))
475}
476
477/// The exact prompt templates registered for `model_id`.
478///
479/// Returns an error for models that carry no registry fixture: their prompt
480/// behavior cannot be attested, so no prefix may be guessed for them.
481pub fn registry_prompt_templates(model_id: &str) -> Result<PromptTemplates> {
482    registry_entry(model_id)
483        .map(|entry| entry.templates)
484        .ok_or_else(|| {
485            RuvectorError::InvalidParameter(format!(
486                "no registered prompt templates for embedding model '{model_id}'"
487            ))
488        })
489}
490
491/// Apply the identity's registered prompt template for `role` to raw text.
492///
493/// This is the single place a prefix is added, so the text actually embedded
494/// always matches the templates hashed into
495/// [`EmbeddingSpaceIdentity::prompt_template_sha256`] (ADR-281 §4). A prefix
496/// policy that promises a transform but whose templates cannot be verified
497/// fails closed rather than silently embedding unprefixed text.
498pub fn apply_prompt_template(
499    identity: &EmbeddingSpaceIdentity,
500    role: EmbeddingRole,
501    text: &str,
502) -> Result<String> {
503    match identity.prefix_policy {
504        PrefixPolicy::None => Ok(text.to_owned()),
505        PrefixPolicy::Custom => Err(RuvectorError::InvalidParameter(format!(
506            "model '{}' declares PrefixPolicy::Custom; ruvector has no template for it, so \
507             embedding would silently drop the model's required prompt",
508            identity.model_id
509        ))),
510        PrefixPolicy::Required | PrefixPolicy::QueryRecommended => {
511            let templates = registry_prompt_templates(&identity.model_id)?;
512            if templates.sha256() != identity.prompt_template_sha256 {
513                return Err(RuvectorError::InvalidParameter(format!(
514                    "prompt template hash for '{}' disagrees with the registry fixture; the \
515                     attested identity does not describe the templates that would be applied",
516                    identity.model_id
517                )));
518            }
519            Ok(templates.apply(role, text))
520        }
521    }
522}
523
524/// A non-attested symmetric identity for providers with no registry fixture
525/// (hash placeholders, opaque fleet backends, tests).
526///
527/// The artifact hashes are derived from `provider`/`model_id`, not from model
528/// bytes: this identity states "this vector space is whatever this named
529/// provider produces", which is enough to keep corpora from mixing but is not
530/// artifact provenance. Attested paths must supply a complete identity.
531pub fn default_space_identity(
532    provider: &str,
533    model_id: &str,
534    dimension: usize,
535) -> EmbeddingSpaceIdentity {
536    let fixture = format!("{provider}\0{model_id}\0v1");
537    EmbeddingSpaceIdentity {
538        schema_version: 1,
539        provider: provider.into(),
540        model_id: model_id.into(),
541        model_artifact_sha256: sha256_hex(format!("{fixture}\0artifact").as_bytes()),
542        model_graph_sha256: sha256_hex(format!("{fixture}\0graph").as_bytes()),
543        tokenizer_sha256: sha256_hex(format!("{fixture}\0tokenizer").as_bytes()),
544        prompt_template_sha256: sha256_hex(
545            format!("{fixture}\0query={{text}}\0passage={{text}}").as_bytes(),
546        ),
547        pooling_strategy: PoolingStrategy::Named(PoolingStrategyName::Mean),
548        normalize: true,
549        truncation_tokens: 512,
550        output_dimension: dimension as u32,
551        output_dtype: OutputDtype::F32,
552        runtime_revision: runtime_revision_for("ruvector-core"),
553        distance_metric: EmbeddingDistanceMetric::Cosine,
554        role_policy: EmbeddingRolePolicy::Symmetric,
555        prefix_policy: PrefixPolicy::None,
556        prefix_policy_version: 1,
557    }
558}
559
560/// Build the identity a registered model must have, from the exact registry
561/// fixture for `model_id`.
562///
563/// Errors when the id is unregistered or when `dimension` disagrees with the
564/// registered output dimension — a family-name guess is never substituted.
565/// Callers that load real artifacts overwrite the hash fields with digests of
566/// the bytes they loaded.
567pub fn registry_identity(
568    provider: &str,
569    model_id: &str,
570    dimension: usize,
571) -> Result<EmbeddingSpaceIdentity> {
572    let entry = registry_entry(model_id).ok_or_else(|| {
573        RuvectorError::InvalidParameter(format!(
574            "unknown embedding model '{model_id}' requires an explicit complete \
575             EmbeddingSpaceIdentity"
576        ))
577    })?;
578    if entry.output_dimension as usize != dimension {
579        return Err(RuvectorError::InvalidParameter(format!(
580            "embedding model '{model_id}' is registered at {} dimensions, not {dimension}",
581            entry.output_dimension
582        )));
583    }
584    let mut identity = default_space_identity(provider, model_id, dimension);
585    identity.role_policy = entry.role_policy;
586    identity.prefix_policy = entry.prefix_policy;
587    identity.prompt_template_sha256 = entry.templates.sha256();
588    Ok(identity)
589}
590
591static LEGACY_EMBED_CALLS: AtomicU64 = AtomicU64::new(0);
592
593pub fn legacy_embed_call_count() -> u64 {
594    LEGACY_EMBED_CALLS.load(Ordering::Relaxed)
595}
596
597/// Capabilities retained when opening a corpus with an incompatible active
598/// embedding space. Vector inspection never invokes the active embedder.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub struct EmbeddingSpaceAccess {
601    pub text_embedding: bool,
602    pub corpus_mutation: bool,
603    pub vector_read: bool,
604    pub inspect: bool,
605    pub verify: bool,
606    pub export: bool,
607}
608
609impl EmbeddingSpaceAccess {
610    pub fn for_identities(
611        stored: &EmbeddingSpaceIdentity,
612        active: &EmbeddingSpaceIdentity,
613    ) -> Result<Self> {
614        let compatible = stored.embedding_space_id()? == active.embedding_space_id()?;
615        Ok(Self {
616            text_embedding: compatible,
617            corpus_mutation: compatible,
618            vector_read: true,
619            inspect: true,
620            verify: true,
621            export: true,
622        })
623    }
624}
625
626/// Explicit migration wrapper for symmetric embedding implementations.
627pub struct SymmetricEmbeddingAdapter<F>
628where
629    F: Fn(&str) -> Result<Vec<f32>> + Send + Sync,
630{
631    embed_fn: F,
632    identity: EmbeddingSpaceIdentity,
633    name: String,
634}
635
636impl<F> SymmetricEmbeddingAdapter<F>
637where
638    F: Fn(&str) -> Result<Vec<f32>> + Send + Sync,
639{
640    pub fn new(
641        name: impl Into<String>,
642        identity: EmbeddingSpaceIdentity,
643        embed_fn: F,
644    ) -> Result<Self> {
645        identity.validate()?;
646        if identity.role_policy != EmbeddingRolePolicy::Symmetric {
647            return Err(RuvectorError::InvalidParameter(
648                "SymmetricEmbeddingAdapter requires role_policy=symmetric".into(),
649            ));
650        }
651        Ok(Self {
652            embed_fn,
653            identity,
654            name: name.into(),
655        })
656    }
657}
658
659impl<F> EmbeddingProvider for SymmetricEmbeddingAdapter<F>
660where
661    F: Fn(&str) -> Result<Vec<f32>> + Send + Sync,
662{
663    fn embed_for(&self, _role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
664        (self.embed_fn)(text)
665    }
666
667    fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
668        &self.identity
669    }
670
671    fn dimensions(&self) -> usize {
672        self.identity.output_dimension as usize
673    }
674
675    fn name(&self) -> &str {
676        &self.name
677    }
678}
679
680/// Trait for text embedding providers
681pub trait EmbeddingProvider: Send + Sync {
682    fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>>;
683
684    fn embedding_space(&self) -> &EmbeddingSpaceIdentity;
685
686    fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
687        self.embed_for(EmbeddingRole::Query, text)
688    }
689
690    fn embed_passage(&self, text: &str) -> Result<Vec<f32>> {
691        self.embed_for(EmbeddingRole::Passage, text)
692    }
693
694    #[deprecated(note = "use embed_query or embed_passage")]
695    fn embed(&self, text: &str) -> Result<Vec<f32>> {
696        LEGACY_EMBED_CALLS.fetch_add(1, Ordering::Relaxed);
697        self.embed_for(EmbeddingRole::Passage, text)
698    }
699
700    fn embed_batch_for(&self, role: EmbeddingRole, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
701        texts
702            .iter()
703            .map(|text| self.embed_for(role, text))
704            .collect()
705    }
706
707    fn embed_query_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
708        self.embed_batch_for(EmbeddingRole::Query, texts)
709    }
710
711    fn embed_passage_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
712        self.embed_batch_for(EmbeddingRole::Passage, texts)
713    }
714
715    /// Get the dimensionality of embeddings produced by this provider
716    fn dimensions(&self) -> usize;
717
718    /// Get a description of this provider (for logging/debugging)
719    fn name(&self) -> &str;
720}
721
722/// Hash-based embedding provider (placeholder, not semantic)
723///
724/// ⚠️ **WARNING**: This does NOT produce semantic embeddings!
725/// - "dog" and "cat" will NOT be similar
726/// - "dog" and "god" WILL be similar (same characters)
727///
728/// Use this only for:
729/// - Testing
730/// - Prototyping
731/// - When semantic similarity is not required
732#[derive(Debug, Clone)]
733pub struct HashEmbedding {
734    dimensions: usize,
735    identity: EmbeddingSpaceIdentity,
736}
737
738impl HashEmbedding {
739    /// Create a new hash-based embedding provider
740    pub fn new(dimensions: usize) -> Self {
741        Self {
742            dimensions,
743            identity: default_space_identity("ruvector-hash", "hash-v1", dimensions),
744        }
745    }
746}
747
748impl EmbeddingProvider for HashEmbedding {
749    fn embed_for(&self, _role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
750        let mut embedding = vec![0.0; self.dimensions];
751        let bytes = text.as_bytes();
752
753        for (i, byte) in bytes.iter().enumerate() {
754            embedding[i % self.dimensions] += (*byte as f32) / 255.0;
755        }
756
757        // Normalize
758        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
759        if norm > 0.0 {
760            for val in &mut embedding {
761                *val /= norm;
762            }
763        }
764
765        Ok(embedding)
766    }
767
768    fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
769        &self.identity
770    }
771
772    fn dimensions(&self) -> usize {
773        self.dimensions
774    }
775
776    fn name(&self) -> &str {
777        "HashEmbedding (placeholder)"
778    }
779}
780
781/// Real embeddings using candle-transformers
782///
783/// Requires feature flag: `real-embeddings`
784///
785/// ⚠️ **Note**: Full candle integration is complex and model-specific.
786/// For production use, we recommend:
787/// 1. Using the API-based providers (simpler, always up-to-date)
788/// 2. Using ONNX Runtime with pre-exported models
789/// 3. Implementing your own candle wrapper for your specific model
790///
791/// This is a stub implementation showing the structure.
792/// Users should implement `EmbeddingProvider` trait for their specific models.
793#[cfg(feature = "real-embeddings")]
794pub mod candle {
795    use super::*;
796
797    /// Candle-based embedding provider stub
798    ///
799    /// This is a placeholder. For real implementation:
800    /// 1. Add candle dependencies for your specific model type
801    /// 2. Implement model loading and inference
802    /// 3. Handle tokenization appropriately
803    ///
804    /// Example structure:
805    /// ```rust,ignore
806    /// pub struct CandleEmbedding {
807    ///     model: YourModelType,
808    ///     tokenizer: Tokenizer,
809    ///     device: Device,
810    ///     dimensions: usize,
811    /// }
812    /// ```
813    pub struct CandleEmbedding {
814        dimensions: usize,
815        model_id: String,
816        identity: EmbeddingSpaceIdentity,
817    }
818
819    impl CandleEmbedding {
820        /// Create a stub candle embedding provider
821        ///
822        /// **This is not a real implementation!**
823        /// For production, implement with actual model loading.
824        ///
825        /// # Example
826        /// ```rust,no_run
827        /// # #[cfg(feature = "real-embeddings")]
828        /// # {
829        /// use ruvector_core::embeddings::candle::CandleEmbedding;
830        ///
831        /// // This returns an error - real implementation required
832        /// let result = CandleEmbedding::from_pretrained(
833        ///     "sentence-transformers/all-MiniLM-L6-v2",
834        ///     false
835        /// );
836        /// assert!(result.is_err());
837        /// # }
838        /// ```
839        pub fn from_pretrained(model_id: &str, _use_gpu: bool) -> Result<Self> {
840            Err(RuvectorError::ModelLoadError(format!(
841                "Candle embedding support is a stub. Please:\n\
842                     1. Use ApiEmbedding for production (recommended)\n\
843                     2. Or implement CandleEmbedding for model: {}\n\
844                     3. See docs for ONNX Runtime integration examples",
845                model_id
846            )))
847        }
848    }
849
850    impl EmbeddingProvider for CandleEmbedding {
851        fn embed_for(&self, _role: EmbeddingRole, _text: &str) -> Result<Vec<f32>> {
852            Err(RuvectorError::ModelInferenceError(
853                "Candle embedding not implemented - use ApiEmbedding instead".to_string(),
854            ))
855        }
856
857        fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
858            &self.identity
859        }
860
861        fn dimensions(&self) -> usize {
862            self.dimensions
863        }
864
865        fn name(&self) -> &str {
866            "CandleEmbedding (stub - not implemented)"
867        }
868    }
869}
870
871#[cfg(feature = "real-embeddings")]
872pub use candle::CandleEmbedding;
873
874/// API-based embedding provider (OpenAI, Anthropic, Cohere, etc.)
875///
876/// Supports any API that accepts JSON and returns embeddings in a standard format.
877///
878/// # Example (OpenAI)
879/// ```rust,no_run
880/// use ruvector_core::embeddings::{EmbeddingProvider, ApiEmbedding};
881///
882/// # let identity = todo!("load a pinned EmbeddingSpaceIdentity");
883/// let provider = ApiEmbedding::openai("sk-...", "text-embedding-3-small", identity)?;
884/// let embedding = provider.embed("hello world")?;
885/// # Ok::<(), Box<dyn std::error::Error>>(())
886/// ```
887#[cfg(feature = "api-embeddings")]
888#[derive(Clone)]
889pub struct ApiEmbedding {
890    api_key: String,
891    endpoint: String,
892    model: String,
893    dimensions: usize,
894    identity: EmbeddingSpaceIdentity,
895    client: reqwest::blocking::Client,
896}
897
898#[cfg(feature = "api-embeddings")]
899impl ApiEmbedding {
900    /// Create a new API embedding provider
901    ///
902    /// # Arguments
903    /// * `api_key` - API key for authentication
904    /// * `endpoint` - API endpoint URL
905    /// * `model` - Model identifier
906    /// * `dimensions` - Expected embedding dimensions
907    pub fn new(
908        api_key: String,
909        endpoint: String,
910        model: String,
911        dimensions: usize,
912        identity: EmbeddingSpaceIdentity,
913    ) -> Result<Self> {
914        identity.validate()?;
915        if identity.model_id != model || identity.output_dimension as usize != dimensions {
916            return Err(RuvectorError::InvalidParameter(
917                "remote provider identity must match the requested model and dimension".into(),
918            ));
919        }
920        // Fail at construction, not on the first embed: a prefix policy this
921        // provider cannot honor would otherwise send raw text to the API while
922        // the identity attests that a template was applied. Probing with a
923        // sample of each role is enough — `apply_prompt_template` is
924        // deterministic in the identity, not the text.
925        for role in [EmbeddingRole::Query, EmbeddingRole::Passage] {
926            apply_prompt_template(&identity, role, "probe")?;
927        }
928        Ok(Self {
929            api_key,
930            endpoint,
931            model,
932            dimensions,
933            identity,
934            client: reqwest::blocking::Client::new(),
935        })
936    }
937
938    /// Create OpenAI embedding provider
939    ///
940    /// # Models
941    /// - `text-embedding-3-small` - 1536 dimensions, $0.02/1M tokens
942    /// - `text-embedding-3-large` - 3072 dimensions, $0.13/1M tokens
943    /// - `text-embedding-ada-002` - 1536 dimensions (legacy)
944    pub fn openai(api_key: &str, model: &str, identity: EmbeddingSpaceIdentity) -> Result<Self> {
945        let dimensions = match model {
946            "text-embedding-3-large" => 3072,
947            _ => 1536, // text-embedding-3-small and ada-002
948        };
949
950        Self::new(
951            api_key.to_string(),
952            "https://api.openai.com/v1/embeddings".to_string(),
953            model.to_string(),
954            dimensions,
955            identity,
956        )
957    }
958
959    /// Create Cohere embedding provider
960    ///
961    /// # Models
962    /// - `embed-english-v3.0` - 1024 dimensions
963    /// - `embed-multilingual-v3.0` - 1024 dimensions
964    pub fn cohere(api_key: &str, model: &str, identity: EmbeddingSpaceIdentity) -> Result<Self> {
965        Self::new(
966            api_key.to_string(),
967            "https://api.cohere.ai/v1/embed".to_string(),
968            model.to_string(),
969            1024,
970            identity,
971        )
972    }
973
974    /// Create Voyage AI embedding provider
975    ///
976    /// # Models
977    /// - `voyage-2` - 1024 dimensions
978    /// - `voyage-large-2` - 1536 dimensions
979    pub fn voyage(api_key: &str, model: &str, identity: EmbeddingSpaceIdentity) -> Result<Self> {
980        let dimensions = if model.contains("large") { 1536 } else { 1024 };
981
982        Self::new(
983            api_key.to_string(),
984            "https://api.voyageai.com/v1/embeddings".to_string(),
985            model.to_string(),
986            dimensions,
987            identity,
988        )
989    }
990}
991
992#[cfg(feature = "api-embeddings")]
993impl EmbeddingProvider for ApiEmbedding {
994    fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
995        // Same contract as the ONNX path: the provider owns prefixing, and the
996        // templates come from the identity's registry fixture so the text sent
997        // upstream matches what `prompt_template_sha256` attests (ADR-281 §4).
998        // Endpoints whose own `input_type` handles the role declare
999        // PrefixPolicy::None and are unaffected.
1000        let prepared = apply_prompt_template(&self.identity, role, text)?;
1001        let mut request_body = serde_json::json!({
1002            "input": prepared,
1003            "model": self.model,
1004        });
1005        if self.identity.role_policy == EmbeddingRolePolicy::Asymmetric {
1006            let input_type = match role {
1007                EmbeddingRole::Query => "search_query",
1008                EmbeddingRole::Passage => "search_document",
1009            };
1010            request_body["input_type"] = serde_json::Value::String(input_type.into());
1011        }
1012
1013        let response = self
1014            .client
1015            .post(&self.endpoint)
1016            .header("Authorization", format!("Bearer {}", self.api_key))
1017            .header("Content-Type", "application/json")
1018            .json(&request_body)
1019            .send()
1020            .map_err(|e| {
1021                RuvectorError::ModelInferenceError(format!("API request failed: {}", e))
1022            })?;
1023
1024        if !response.status().is_success() {
1025            let status = response.status();
1026            let error_text = response
1027                .text()
1028                .unwrap_or_else(|_| "Unknown error".to_string());
1029            return Err(RuvectorError::ModelInferenceError(format!(
1030                "API returned error {}: {}",
1031                status, error_text
1032            )));
1033        }
1034
1035        let response_json: serde_json::Value = response.json().map_err(|e| {
1036            RuvectorError::ModelInferenceError(format!("Failed to parse response: {}", e))
1037        })?;
1038
1039        // Handle different API response formats
1040        let embedding = if let Some(data) = response_json.get("data") {
1041            // OpenAI format: {"data": [{"embedding": [...]}]}
1042            data.as_array()
1043                .and_then(|arr| arr.first())
1044                .and_then(|obj| obj.get("embedding"))
1045                .and_then(|emb| emb.as_array())
1046                .ok_or_else(|| {
1047                    RuvectorError::ModelInferenceError("Invalid OpenAI response format".to_string())
1048                })?
1049        } else if let Some(embeddings) = response_json.get("embeddings") {
1050            // Cohere format: {"embeddings": [[...]]}
1051            embeddings
1052                .as_array()
1053                .and_then(|arr| arr.first())
1054                .and_then(|emb| emb.as_array())
1055                .ok_or_else(|| {
1056                    RuvectorError::ModelInferenceError("Invalid Cohere response format".to_string())
1057                })?
1058        } else {
1059            return Err(RuvectorError::ModelInferenceError(
1060                "Unknown API response format".to_string(),
1061            ));
1062        };
1063
1064        let embedding_vec: Result<Vec<f32>> = embedding
1065            .iter()
1066            .map(|v| {
1067                v.as_f64().map(|f| f as f32).ok_or_else(|| {
1068                    RuvectorError::ModelInferenceError("Invalid embedding value".to_string())
1069                })
1070            })
1071            .collect();
1072
1073        embedding_vec
1074    }
1075
1076    fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
1077        &self.identity
1078    }
1079
1080    fn dimensions(&self) -> usize {
1081        self.dimensions
1082    }
1083
1084    fn name(&self) -> &str {
1085        "ApiEmbedding"
1086    }
1087}
1088
1089// ============================================================================
1090// ONNX Embeddings (Recommended for Production)
1091// ============================================================================
1092
1093/// ONNX-based embedding provider using ONNX Runtime
1094///
1095/// Provides **real semantic embeddings** using transformer models like all-MiniLM-L6-v2.
1096/// This is the **recommended** embedding provider for production use.
1097///
1098/// Requires feature flag: `onnx-embeddings`
1099///
1100/// ## Features
1101/// - Real semantic understanding ("dog" and "cat" ARE similar)
1102/// - Local inference (no API calls, works offline)
1103/// - Fast inference (5-50ms per embedding)
1104/// - Automatic model download from HuggingFace
1105///
1106/// ## Supported Models
1107/// - `sentence-transformers/all-MiniLM-L6-v2` (384 dims, recommended)
1108/// - `sentence-transformers/all-mpnet-base-v2` (768 dims)
1109/// - `BAAI/bge-small-en-v1.5` (384 dims)
1110///
1111/// # Example
1112/// ```rust,ignore
1113/// use ruvector_core::embeddings::{EmbeddingProvider, OnnxEmbedding};
1114///
1115/// let provider = OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")?;
1116/// let embedding = provider.embed("hello world")?;
1117/// assert_eq!(embedding.len(), 384);
1118/// ```
1119#[cfg(feature = "onnx-embeddings")]
1120pub mod onnx {
1121    use super::*;
1122    use crate::error::RuvectorError;
1123    use ort::session::Session;
1124    use ort::value::{Tensor, ValueType};
1125    use parking_lot::RwLock;
1126    use std::path::PathBuf;
1127    use tokenizers::Tokenizer;
1128
1129    /// ONNX-based embedding provider
1130    pub struct OnnxEmbedding {
1131        session: RwLock<Session>,
1132        tokenizer: RwLock<Tokenizer>,
1133        dimensions: usize,
1134        model_id: String,
1135        identity: EmbeddingSpaceIdentity,
1136        #[allow(dead_code)]
1137        max_length: usize,
1138    }
1139
1140    impl OnnxEmbedding {
1141        /// Load a pre-trained embedding model from HuggingFace
1142        ///
1143        /// The model will be downloaded and cached automatically.
1144        ///
1145        /// # Arguments
1146        /// * `model_id` - HuggingFace model identifier (e.g., "sentence-transformers/all-MiniLM-L6-v2")
1147        ///
1148        /// # Example
1149        /// ```rust,ignore
1150        /// let provider = OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2")?;
1151        /// ```
1152        pub fn from_pretrained(model_id: &str) -> Result<Self> {
1153            let api = hf_hub::api::sync::Api::new().map_err(|e| {
1154                RuvectorError::ModelLoadError(format!("Failed to create HuggingFace API: {}", e))
1155            })?;
1156
1157            let repo = api.model(model_id.to_string());
1158
1159            // Download model files
1160            let model_path = repo
1161                .get("model.onnx")
1162                .or_else(|_| {
1163                    // Try alternative path for some models
1164                    repo.get("onnx/model.onnx")
1165                })
1166                .map_err(|e| {
1167                    RuvectorError::ModelLoadError(format!(
1168                        "Failed to download ONNX model from {}: {}. \
1169                     Make sure the model has an ONNX export available.",
1170                        model_id, e
1171                    ))
1172                })?;
1173
1174            let tokenizer_path = repo.get("tokenizer.json").map_err(|e| {
1175                RuvectorError::ModelLoadError(format!(
1176                    "Failed to download tokenizer from {}: {}",
1177                    model_id, e
1178                ))
1179            })?;
1180
1181            Self::from_files(&model_path, &tokenizer_path, model_id)
1182        }
1183
1184        /// Load from local files
1185        ///
1186        /// # Arguments
1187        /// * `model_path` - Path to the ONNX model file
1188        /// * `tokenizer_path` - Path to the tokenizer.json file
1189        /// * `model_id` - Model identifier for logging
1190        pub fn from_files(
1191            model_path: &PathBuf,
1192            tokenizer_path: &PathBuf,
1193            model_id: &str,
1194        ) -> Result<Self> {
1195            // Initialize ONNX Runtime (returns bool, true = first init)
1196            let _ = ort::init().commit();
1197
1198            // Load the ONNX session
1199            let session = Session::builder()
1200                .map_err(|e| {
1201                    RuvectorError::ModelLoadError(format!(
1202                        "Failed to create session builder: {}",
1203                        e
1204                    ))
1205                })?
1206                .with_intra_threads(4)
1207                .map_err(|e| {
1208                    RuvectorError::ModelLoadError(format!("Failed to set thread count: {}", e))
1209                })?
1210                .commit_from_file(model_path)
1211                .map_err(|e| {
1212                    RuvectorError::ModelLoadError(format!("Failed to load ONNX model: {}", e))
1213                })?;
1214
1215            // Load tokenizer
1216            let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
1217                RuvectorError::ModelLoadError(format!("Failed to load tokenizer: {}", e))
1218            })?;
1219
1220            // Determine dimensions from model output
1221            let dimensions = Self::infer_dimensions(&session, model_id)?;
1222
1223            // Determine max_length from model (default to 512 for sentence transformers)
1224            let max_length = 512;
1225
1226            tracing::info!(
1227                model_id,
1228                dimensions,
1229                build_revision = build_revision(),
1230                "Loaded ONNX embedding model"
1231            );
1232
1233            // `registry_identity` already sets `runtime_revision` to the
1234            // provider class plus EMBEDDING_SPACE_FORMAT_REVISION. It carries
1235            // no `ort` version on purpose: the previous value hardcoded
1236            // "ort-2.0.0-rc.9" while Cargo.lock resolved rc.10 — a claim that
1237            // was both untrue and, being hashed, would have re-keyed every
1238            // corpus and cache on a patch upgrade of a dependency. What
1239            // actually pins this vector space is the pair of artifact digests
1240            // below plus the registry's prompt templates.
1241            let mut identity = registry_identity("ruvector-onnx", model_id, dimensions)?;
1242            let model_bytes = std::fs::read(model_path)?;
1243            let tokenizer_bytes = std::fs::read(tokenizer_path)?;
1244            identity.model_artifact_sha256 = sha256_hex(&model_bytes);
1245            identity.model_graph_sha256 = identity.model_artifact_sha256.clone();
1246            identity.tokenizer_sha256 = sha256_hex(&tokenizer_bytes);
1247            identity.validate()?;
1248
1249            Ok(Self {
1250                session: RwLock::new(session),
1251                tokenizer: RwLock::new(tokenizer),
1252                dimensions,
1253                model_id: model_id.to_string(),
1254                identity,
1255                max_length,
1256            })
1257        }
1258
1259        fn infer_dimensions(session: &Session, model_id: &str) -> Result<usize> {
1260            // Common dimensions for known models
1261            let dimensions = match model_id {
1262                id if id.contains("all-MiniLM-L6") => 384,
1263                id if id.contains("all-mpnet-base") => 768,
1264                id if id.contains("bge-small") => 384,
1265                id if id.contains("bge-base") => 768,
1266                id if id.contains("bge-large") => 1024,
1267                id if id.contains("e5-small") => 384,
1268                id if id.contains("e5-base") => 768,
1269                id if id.contains("e5-large") => 1024,
1270                _ => {
1271                    // Try to infer from output shape via session.outputs() method
1272                    if let Some(output) = session.outputs().first() {
1273                        if let ValueType::Tensor { shape, .. } = output.dtype() {
1274                            let dims: Vec<i64> = shape.iter().copied().collect();
1275                            if dims.len() >= 2 {
1276                                let last_dim = dims[dims.len() - 1];
1277                                if last_dim > 0 {
1278                                    return Ok(last_dim as usize);
1279                                }
1280                            }
1281                        }
1282                    }
1283                    // Default to 384 (most common)
1284                    384
1285                }
1286            };
1287
1288            Ok(dimensions)
1289        }
1290
1291        /// Embed multiple texts in a batch (more efficient than individual calls)
1292        pub fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1293            self.embed_passage_batch(texts)
1294        }
1295
1296        fn mean_pooling(
1297            token_embeddings: &[f32],
1298            attention_mask: &[i64],
1299            seq_len: usize,
1300            hidden_size: usize,
1301        ) -> Vec<f32> {
1302            let mut pooled = vec![0.0f32; hidden_size];
1303            let mut mask_sum = 0.0f32;
1304
1305            for i in 0..seq_len {
1306                let mask = attention_mask[i] as f32;
1307                mask_sum += mask;
1308                for j in 0..hidden_size {
1309                    pooled[j] += token_embeddings[i * hidden_size + j] * mask;
1310                }
1311            }
1312
1313            // Avoid division by zero
1314            if mask_sum > 0.0 {
1315                for val in &mut pooled {
1316                    *val /= mask_sum;
1317                }
1318            }
1319
1320            // L2 normalize
1321            let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
1322            if norm > 0.0 {
1323                for val in &mut pooled {
1324                    *val /= norm;
1325                }
1326            }
1327
1328            pooled
1329        }
1330    }
1331
1332    impl EmbeddingProvider for OnnxEmbedding {
1333        fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
1334            // The templates come from the identity's registry fixture, so the
1335            // text embedded here is exactly what `prompt_template_sha256`
1336            // attests to (ADR-281 §4).
1337            let prepared = apply_prompt_template(&self.identity, role, text)?;
1338            // Tokenize
1339            let encoding = {
1340                let tokenizer = self.tokenizer.read();
1341                tokenizer.encode(prepared, true).map_err(|e| {
1342                    RuvectorError::ModelInferenceError(format!("Tokenization failed: {}", e))
1343                })?
1344            };
1345
1346            // Prepare inputs
1347            let input_ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
1348            let attention_mask: Vec<i64> = encoding
1349                .get_attention_mask()
1350                .iter()
1351                .map(|&x| x as i64)
1352                .collect();
1353            let token_type_ids: Vec<i64> =
1354                encoding.get_type_ids().iter().map(|&x| x as i64).collect();
1355
1356            let seq_len = input_ids.len();
1357
1358            // Create ONNX tensors using ort 2.0 API (batch_size=1)
1359            // Tensor::from_array takes (shape, owned_data)
1360            let input_ids_tensor =
1361                Tensor::<i64>::from_array(([1, seq_len], input_ids.clone().into_boxed_slice()))
1362                    .map_err(|e| {
1363                        RuvectorError::ModelInferenceError(format!(
1364                            "Failed to create input_ids tensor: {}",
1365                            e
1366                        ))
1367                    })?;
1368
1369            let attention_mask_tensor = Tensor::<i64>::from_array((
1370                [1, seq_len],
1371                attention_mask.clone().into_boxed_slice(),
1372            ))
1373            .map_err(|e| {
1374                RuvectorError::ModelInferenceError(format!(
1375                    "Failed to create attention_mask tensor: {}",
1376                    e
1377                ))
1378            })?;
1379
1380            let token_type_ids_tensor =
1381                Tensor::<i64>::from_array(([1, seq_len], token_type_ids.into_boxed_slice()))
1382                    .map_err(|e| {
1383                        RuvectorError::ModelInferenceError(format!(
1384                            "Failed to create token_type_ids tensor: {}",
1385                            e
1386                        ))
1387                    })?;
1388
1389            // Run inference and extract output (needs mutable access to session)
1390            // We must extract all data while holding the lock since SessionOutputs has a lifetime
1391            let (output_data, output_shape_vec) = {
1392                let mut session = self.session.write();
1393                let outputs = session
1394                    .run(ort::inputs![
1395                        "input_ids" => input_ids_tensor,
1396                        "attention_mask" => attention_mask_tensor,
1397                        "token_type_ids" => token_type_ids_tensor,
1398                    ])
1399                    .map_err(|e| {
1400                        RuvectorError::ModelInferenceError(format!("ONNX inference failed: {}", e))
1401                    })?;
1402
1403                // Extract output using indexing (ort 2.0 API)
1404                // Sentence transformers output shape: [batch_size, seq_len, hidden_size]
1405                let output_value = &outputs[0];
1406
1407                // Extract as ndarray view
1408                let output_array = output_value.try_extract_array::<f32>().map_err(|e| {
1409                    RuvectorError::ModelInferenceError(format!(
1410                        "Failed to extract output tensor: {}",
1411                        e
1412                    ))
1413                })?;
1414
1415                let output_shape_vec: Vec<usize> = output_array.shape().to_vec();
1416                let output_data_vec: Vec<f32> = output_array.iter().copied().collect();
1417
1418                (output_data_vec, output_shape_vec)
1419            };
1420
1421            // Determine if we need pooling based on output shape
1422            let embedding = if output_shape_vec.len() == 3 {
1423                // Shape: [batch_size, seq_len, hidden_size] - needs pooling
1424                let hidden_size = output_shape_vec[2];
1425                Self::mean_pooling(&output_data, &attention_mask, seq_len, hidden_size)
1426            } else if output_shape_vec.len() == 2 {
1427                // Shape: [batch_size, hidden_size] - already pooled
1428                let mut emb = output_data;
1429                // L2 normalize
1430                let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
1431                if norm > 0.0 {
1432                    for val in &mut emb {
1433                        *val /= norm;
1434                    }
1435                }
1436                emb
1437            } else {
1438                return Err(RuvectorError::ModelInferenceError(format!(
1439                    "Unexpected output shape: {:?}",
1440                    output_shape_vec
1441                )));
1442            };
1443
1444            Ok(embedding)
1445        }
1446
1447        fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
1448            &self.identity
1449        }
1450
1451        fn dimensions(&self) -> usize {
1452            self.dimensions
1453        }
1454
1455        fn name(&self) -> &str {
1456            &self.model_id
1457        }
1458    }
1459
1460    impl std::fmt::Debug for OnnxEmbedding {
1461        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1462            f.debug_struct("OnnxEmbedding")
1463                .field("model_id", &self.model_id)
1464                .field("dimensions", &self.dimensions)
1465                .field("max_length", &self.max_length)
1466                .finish()
1467        }
1468    }
1469}
1470
1471#[cfg(feature = "onnx-embeddings")]
1472pub use onnx::OnnxEmbedding;
1473
1474// ============================================================================
1475// Lattice Embeddings (pure-Rust, native, no C++ FFI / no ONNX Runtime)
1476// ============================================================================
1477
1478/// Native embedding provider backed by [`lattice-embed`](https://crates.io/crates/lattice-embed),
1479/// a pure-Rust transformer inference engine (SIMD matmul, safetensors weight
1480/// loading, no ONNX Runtime, no C++ FFI).
1481///
1482/// Requires feature flag: `lattice-embeddings`
1483///
1484/// ## Supported models
1485/// - `bge-small-en-v1.5` / `BAAI/bge-small-en-v1.5` (384 dims, default, recommended for `.rvf` packs)
1486/// - `bge-base-en-v1.5` / `BAAI/bge-base-en-v1.5` (768 dims)
1487/// - `bge-large-en-v1.5` / `BAAI/bge-large-en-v1.5` (1024 dims)
1488/// - `multilingual-e5-small` / `intfloat/multilingual-e5-small` (384 dims)
1489/// - `multilingual-e5-base` / `intfloat/multilingual-e5-base` (768 dims)
1490/// - `all-minilm-l6-v2` / `sentence-transformers/all-MiniLM-L6-v2` (384 dims)
1491/// - `paraphrase-multilingual-minilm-l12-v2` / `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (384 dims)
1492/// - `qwen3-embedding-0.6b` / `Qwen/Qwen3-Embedding-0.6B` (1024 dims)
1493/// - `qwen3-embedding-4b` / `Qwen/Qwen3-Embedding-4B` (2560 dims)
1494///
1495/// Model-id parsing is delegated to `lattice_embed::EmbeddingModel`'s own
1496/// `FromStr` impl (case-insensitive, accepts display names, short names, and
1497/// HuggingFace ids) rather than re-implementing the mapping here, so this
1498/// provider stays in sync with lattice-embed's canonical model table.
1499///
1500/// ## CPU / native, no GPU
1501/// This provider uses lattice-embed's default `native` feature (CPU-only,
1502/// SIMD-accelerated). It does **not** enable lattice-embed's `metal-gpu`
1503/// feature.
1504///
1505/// ## Minimum Supported Rust Version
1506/// Enabling the `lattice-embeddings` feature raises the effective MSRV for
1507/// this crate to Rust 1.93 (edition 2024), since `lattice-embed` requires it.
1508/// Cargo has no mechanism to express a per-feature `rust-version`, so this is
1509/// not reflected in `rust-version.workspace = true` above — it only applies
1510/// when this feature is enabled. The crate's default build (feature
1511/// disabled) keeps the workspace MSRV of 1.77.
1512///
1513/// ## Model download
1514/// BERT-family models (BGE, E5, MiniLM) download automatically from
1515/// HuggingFace into `~/.lattice/models` on first use. Qwen3-Embedding models
1516/// must be placed at `~/.lattice/models/qwen3-embedding-{0.6b,4b}/` manually
1517/// (or pointed to via `LATTICE_QWEN_MODEL_DIR`) before first use.
1518///
1519/// ## Asymmetric retrieval (query vs. passage prefixing)
1520/// BGE, E5, and Qwen3-Embedding are asymmetric retrievers: the query side is
1521/// prefixed with a retrieval instruction, the document side is not.
1522/// [`EmbeddingProvider::embed`] always takes the **passage/document** side (no
1523/// query instruction) via `lattice_embed::EmbeddingService::embed_passage`.
1524/// Use the inherent [`LatticeEmbedding::embed_query`] method for query text —
1525/// it applies the model's query instruction via
1526/// `EmbeddingService::embed_query`: BGE v1.5 prefixes queries with
1527/// `"Represent this sentence for searching relevant passages: "`, E5 with
1528/// `"query: "`, and Qwen3-Embedding with its search instruction. For all three
1529/// families `embed_query` and `embed` therefore produce different vectors,
1530/// which is what makes asymmetric retrieval correct. MiniLM is genuinely
1531/// symmetric (contrastive training on raw text, no prefix), so its two methods
1532/// are equivalent.
1533///
1534/// ## Normalization
1535/// Both [`EmbeddingProvider::embed`] and [`LatticeEmbedding::embed_query`]
1536/// return L2-normalized vectors (unit length): `lattice-embed`'s BERT-family
1537/// encode path (used for BGE, E5, and MiniLM) calls `l2_normalize`
1538/// unconditionally on the pooled output, both for single-text and batched
1539/// encoding (`BertModel::encode` / `encode_batch` in
1540/// `crates/inference/src/model/bert.rs`, upstream in
1541/// [`lattice-embed`](https://crates.io/crates/lattice-embed)'s
1542/// `lattice-inference` dependency). This holds regardless of distance
1543/// metric — safe to use with a dot-product index as well as cosine.
1544///
1545/// # Example
1546/// ```rust,no_run
1547/// use ruvector_core::embeddings::{EmbeddingProvider, LatticeEmbedding};
1548///
1549/// let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")?;
1550///
1551/// // Document side: no query instruction.
1552/// let doc_embedding = provider.embed("The cat sat on the mat.")?;
1553/// assert_eq!(doc_embedding.len(), 384);
1554///
1555/// // Query side: applies the model's query instruction, if any.
1556/// let query_embedding = provider.embed_query("Where did the cat sit?")?;
1557/// assert_eq!(query_embedding.len(), 384);
1558/// # Ok::<(), Box<dyn std::error::Error>>(())
1559/// ```
1560#[cfg(feature = "lattice-embeddings")]
1561pub mod lattice_native {
1562    use super::*;
1563    use lattice_embed::{
1564        EmbeddingModel as LatticeEmbeddingModel, EmbeddingService, NativeEmbeddingService,
1565    };
1566    use std::sync::mpsc;
1567    use std::sync::Mutex;
1568    use std::thread;
1569
1570    /// Which side of asymmetric retrieval a queued embedding request is for.
1571    enum EmbedKind {
1572        Query,
1573        Passage,
1574    }
1575
1576    /// A single embedding request sent to the worker thread, with a
1577    /// per-request reply channel for the result.
1578    struct EmbedRequest {
1579        kind: EmbedKind,
1580        text: String,
1581        reply_tx: mpsc::Sender<std::result::Result<Vec<f32>, String>>,
1582    }
1583
1584    /// See the [module-level docs](self) for the full provider description.
1585    ///
1586    /// # Examples
1587    /// Embed a passage and a query on an asymmetric BGE model. The query is
1588    /// embedded with [`embed_query`](LatticeEmbedding::embed_query), which
1589    /// applies BGE's retrieval instruction, so it produces a different vector
1590    /// than passing the same text through [`EmbeddingProvider::embed`] (the
1591    /// passage side). Using `embed_query` for queries is what makes
1592    /// query-to-passage retrieval scores correct on asymmetric models.
1593    /// ```rust,no_run
1594    /// use ruvector_core::embeddings::{EmbeddingProvider, LatticeEmbedding};
1595    ///
1596    /// let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")?;
1597    ///
1598    /// let passage = provider.embed("The Eiffel Tower is in Paris, France.")?;
1599    /// let query = provider.embed_query("Where is the Eiffel Tower?")?;
1600    /// assert_eq!(passage.len(), provider.dimensions());
1601    /// assert_eq!(query.len(), provider.dimensions());
1602    /// # Ok::<(), Box<dyn std::error::Error>>(())
1603    /// ```
1604    /// A runnable version that prints the cosine similarities of the query and
1605    /// passage vectors is in `examples/lattice_embedding_example.rs`.
1606    ///
1607    /// # Threading model
1608    /// `lattice-embed`'s [`EmbeddingService`] is `async`-only (no sync/blocking
1609    /// API), but [`EmbeddingProvider::embed`] is a sync method that ruvector-core
1610    /// callers may invoke from anywhere, including from inside an existing Tokio
1611    /// runtime (e.g. an async server handler). Bridging via a stored
1612    /// `Runtime::block_on` would panic in that case (`block_on` cannot be
1613    /// called from within an already-running runtime). Instead, the runtime and
1614    /// the embedding service live on a dedicated worker thread with no ambient
1615    /// async context of its own; `embed` / `embed_query` send a request over a
1616    /// channel and block on `Receiver::recv`, which is safe to call from any
1617    /// context, sync or async.
1618    pub struct LatticeEmbedding {
1619        model: LatticeEmbeddingModel,
1620        model_id: &'static str,
1621        dimensions: usize,
1622        identity: EmbeddingSpaceIdentity,
1623        request_tx: Mutex<mpsc::Sender<EmbedRequest>>,
1624        // Keeps the worker thread's handle alive for the lifetime of this
1625        // provider. Not joined on drop (that would block); dropping
1626        // `request_tx` closes the channel, which ends the worker's `recv`
1627        // loop and lets the thread exit on its own.
1628        _worker: thread::JoinHandle<()>,
1629    }
1630
1631    impl LatticeEmbedding {
1632        /// Load a pre-trained embedding model by id.
1633        ///
1634        /// Accepts display names (`"bge-small-en-v1.5"`), short names
1635        /// (`"bge-small"`, `"small"`), and HuggingFace ids
1636        /// (`"BAAI/bge-small-en-v1.5"`) — see [`lattice_embed::EmbeddingModel`]'s
1637        /// `FromStr` impl for the full accepted set. Returns an error for any
1638        /// unrecognized id, and for any id that resolves to a model
1639        /// [`lattice_embed`]'s native service cannot run locally (e.g. the
1640        /// remote-only OpenAI variants).
1641        ///
1642        /// # Example
1643        /// ```rust,no_run
1644        /// use ruvector_core::embeddings::LatticeEmbedding;
1645        ///
1646        /// let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")?;
1647        /// # Ok::<(), Box<dyn std::error::Error>>(())
1648        /// ```
1649        /// # Embedding-space identity
1650        /// This entry point derives the model's identity from the registry
1651        /// fixture for the resolved model id (ADR-281 §2), so role policy,
1652        /// prefix policy, and prompt templates are authoritative. The artifact
1653        /// hash fields are the registry defaults, not digests of the
1654        /// downloaded weights: `lattice-embed` owns model fetching and does
1655        /// not expose the bytes it loaded. Callers that need attested artifact
1656        /// provenance pass their own identity to
1657        /// [`from_pretrained_with_identity`](Self::from_pretrained_with_identity).
1658        /// Models with no registry fixture are rejected here.
1659        pub fn from_pretrained(model_id: &str) -> Result<Self> {
1660            let model: LatticeEmbeddingModel = model_id.parse().map_err(|e: String| {
1661                RuvectorError::ModelLoadError(format!(
1662                    "unknown lattice-embed model id '{model_id}': {e}"
1663                ))
1664            })?;
1665            Self::with_model(model)
1666        }
1667
1668        /// Load a model with caller-supplied, immutable artifact provenance.
1669        pub fn from_pretrained_with_identity(
1670            model_id: &str,
1671            identity: EmbeddingSpaceIdentity,
1672        ) -> Result<Self> {
1673            let model: LatticeEmbeddingModel = model_id.parse().map_err(|e: String| {
1674                RuvectorError::ModelLoadError(format!(
1675                    "unknown lattice-embed model id '{model_id}': {e}"
1676                ))
1677            })?;
1678            Self::with_model_and_identity(model, identity)
1679        }
1680
1681        /// Load a pre-trained embedding model from an already-resolved
1682        /// [`lattice_embed::EmbeddingModel`] variant, using the registry
1683        /// fixture for its id as the embedding-space identity. See
1684        /// [`from_pretrained`](Self::from_pretrained) for what that identity
1685        /// does and does not attest.
1686        pub fn with_model(model: LatticeEmbeddingModel) -> Result<Self> {
1687            let identity =
1688                registry_identity("ruvector-lattice", model.model_id(), model.dimensions())?;
1689            Self::with_model_and_identity(model, identity)
1690        }
1691
1692        /// Who owns prefixing on this provider, and the check that keeps the
1693        /// two owners honest.
1694        ///
1695        /// `lattice-embed` applies role prompts itself, inside
1696        /// `EmbeddingService::embed_query` / `embed_passage`, from
1697        /// `EmbeddingModel::query_instruction()` / `document_instruction()`.
1698        /// So this provider must NOT call [`apply_prompt_template`]: that would
1699        /// prefix a second time and break ADR-281 §4's "exactly once" rule.
1700        ///
1701        /// That leaves a gap the other providers do not have — ruvector's
1702        /// registry templates are a *claim* about what lattice-embed will do,
1703        /// and `prompt_template_sha256` attests to that claim. If a
1704        /// lattice-embed upgrade changed a model's instruction, the attested
1705        /// hash would silently describe a prompt that is no longer applied. So
1706        /// construction compares the two sources of truth and fails closed on
1707        /// divergence rather than shipping a false attestation.
1708        fn assert_lattice_owns_matching_prefixes(
1709            model: LatticeEmbeddingModel,
1710            identity: &EmbeddingSpaceIdentity,
1711        ) -> Result<()> {
1712            let templates = registry_prompt_templates(&identity.model_id)?;
1713            const PROBE: &str = "\u{1}probe\u{1}";
1714            for (role, lattice_instruction, template_side) in [
1715                (
1716                    EmbeddingRole::Query,
1717                    model.query_instruction(),
1718                    templates.query,
1719                ),
1720                (
1721                    EmbeddingRole::Passage,
1722                    model.document_instruction(),
1723                    templates.passage,
1724                ),
1725            ] {
1726                let lattice_applies = format!("{}{PROBE}", lattice_instruction.unwrap_or_default());
1727                let registry_claims = templates.apply(role, PROBE);
1728                if lattice_applies != registry_claims {
1729                    return Err(RuvectorError::InvalidParameter(format!(
1730                        "prefix ownership divergence for '{}' on the {role:?} side: \
1731                         lattice-embed applies {lattice_applies:?} but ruvector's registry \
1732                         template {template_side:?} attests {registry_claims:?}. The \
1733                         prompt_template_sha256 in this identity would describe a prompt that \
1734                         is never applied; update the ruvector registry entry to match \
1735                         lattice-embed (and re-embed, since the space identity changes).",
1736                        identity.model_id
1737                    )));
1738                }
1739            }
1740            Ok(())
1741        }
1742
1743        /// Load a resolved model only after validating complete provenance and
1744        /// the exact role/prompt registry fixture.
1745        pub fn with_model_and_identity(
1746            model: LatticeEmbeddingModel,
1747            identity: EmbeddingSpaceIdentity,
1748        ) -> Result<Self> {
1749            if !model.is_local() {
1750                return Err(RuvectorError::ModelLoadError(format!(
1751                    "'{model}' cannot be loaded natively: lattice-embed's \
1752                     NativeEmbeddingService only supports models it can run \
1753                     on-device. Remote/API-only models (e.g. the OpenAI \
1754                     text-embedding-* family) are not supported by LatticeEmbedding."
1755                )));
1756            }
1757            identity.validate()?;
1758            if identity.model_id != model.model_id()
1759                || identity.output_dimension as usize != model.dimensions()
1760            {
1761                return Err(RuvectorError::InvalidParameter(format!(
1762                    "lattice identity disagrees with loaded model: expected {} ({}D)",
1763                    model.model_id(),
1764                    model.dimensions()
1765                )));
1766            }
1767            let expected = registry_identity(
1768                "lattice-policy-fixture",
1769                model.model_id(),
1770                model.dimensions(),
1771            )?;
1772            if identity.role_policy != expected.role_policy
1773                || identity.prefix_policy != expected.prefix_policy
1774                || identity.prompt_template_sha256 != expected.prompt_template_sha256
1775            {
1776                return Err(RuvectorError::InvalidParameter(
1777                    "lattice identity disagrees with exact role/prompt registry fixture".into(),
1778                ));
1779            }
1780            Self::assert_lattice_owns_matching_prefixes(model, &identity)?;
1781
1782            let runtime = tokio::runtime::Builder::new_current_thread()
1783                .enable_all()
1784                .build()
1785                .map_err(|e| {
1786                    RuvectorError::ModelLoadError(format!(
1787                        "failed to build tokio runtime for LatticeEmbedding: {e}"
1788                    ))
1789                })?;
1790            let service = NativeEmbeddingService::with_model(model);
1791
1792            let (request_tx, request_rx) = mpsc::channel::<EmbedRequest>();
1793            let worker = thread::Builder::new()
1794                .name("lattice-embed-worker".to_string())
1795                .spawn(move || {
1796                    // No ambient Tokio runtime exists on this thread, so
1797                    // `block_on` here can never panic on nested-runtime
1798                    // grounds regardless of the caller's own context.
1799                    for request in request_rx {
1800                        let outcome = runtime.block_on(async {
1801                            match request.kind {
1802                                EmbedKind::Query => {
1803                                    service.embed_query(&[request.text], model).await
1804                                }
1805                                EmbedKind::Passage => {
1806                                    service.embed_passage(&[request.text], model).await
1807                                }
1808                            }
1809                        });
1810                        let mapped =
1811                            outcome
1812                                .map_err(|e| e.to_string())
1813                                .and_then(|mut embeddings| {
1814                                    embeddings.pop().ok_or_else(|| {
1815                                        "lattice-embed returned no embedding".to_string()
1816                                    })
1817                                });
1818                        // Ignore send errors: they only occur if the caller
1819                        // already dropped its reply receiver.
1820                        let _ = request.reply_tx.send(mapped);
1821                    }
1822                })
1823                .map_err(|e| {
1824                    RuvectorError::ModelLoadError(format!(
1825                        "failed to spawn LatticeEmbedding worker thread: {e}"
1826                    ))
1827                })?;
1828
1829            Ok(Self {
1830                model,
1831                model_id: model.model_id(),
1832                dimensions: model.dimensions(),
1833                identity,
1834                request_tx: Mutex::new(request_tx),
1835                _worker: worker,
1836            })
1837        }
1838
1839        /// Get the dimensionality of embeddings produced by the loaded model.
1840        pub fn dimensions(&self) -> usize {
1841            self.dimensions
1842        }
1843
1844        /// Embed **query** text, applying the model's query-side prompt
1845        /// instruction if it uses one (BGE v1.5's `"Represent this sentence
1846        /// for searching relevant passages: "` prefix, E5's `"query: "`
1847        /// prefix, Qwen3's search-query instruction). For those asymmetric
1848        /// models this produces a different vector than
1849        /// [`EmbeddingProvider::embed`]; only MiniLM is symmetric, so its two
1850        /// methods are equivalent.
1851        ///
1852        /// This is what makes asymmetric retrieval correct: index documents
1853        /// via [`EmbeddingProvider::embed`] (passage side, no prefix) and
1854        /// embed the search query via this method (query side, prefixed).
1855        ///
1856        /// Safe to call from any context, including from inside a Tokio
1857        /// runtime — see the [threading model](LatticeEmbedding#threading-model).
1858        pub fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
1859            self.send_request(EmbedKind::Query, text)
1860        }
1861
1862        /// Send an embedding request to the worker thread and block on the
1863        /// reply. Never calls `block_on` on the caller's thread, so this is
1864        /// safe to invoke from inside an existing async runtime.
1865        fn send_request(&self, kind: EmbedKind, text: &str) -> Result<Vec<f32>> {
1866            let (reply_tx, reply_rx) = mpsc::channel();
1867            let request = EmbedRequest {
1868                kind,
1869                text: text.to_string(),
1870                reply_tx,
1871            };
1872
1873            self.request_tx
1874                .lock()
1875                .map_err(|_| {
1876                    RuvectorError::ModelInferenceError(
1877                        "lattice-embed embedding worker request channel poisoned".to_string(),
1878                    )
1879                })?
1880                .send(request)
1881                .map_err(|_| {
1882                    RuvectorError::ModelInferenceError(
1883                        "lattice-embed embedding worker unavailable".to_string(),
1884                    )
1885                })?;
1886
1887            reply_rx
1888                .recv()
1889                .map_err(|_| {
1890                    RuvectorError::ModelInferenceError(
1891                        "lattice-embed embedding worker unavailable".to_string(),
1892                    )
1893                })?
1894                .map_err(|e| {
1895                    RuvectorError::ModelInferenceError(format!(
1896                        "lattice-embed embedding failed: {e}"
1897                    ))
1898                })
1899        }
1900    }
1901
1902    impl EmbeddingProvider for LatticeEmbedding {
1903        /// Embed **passage/document** text (no query instruction applied).
1904        ///
1905        /// Use [`LatticeEmbedding::embed_query`] for the query side of
1906        /// asymmetric retrieval. Safe to call from any context, including
1907        /// from inside a Tokio runtime — see the
1908        /// [threading model](LatticeEmbedding#threading-model).
1909        fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
1910            self.send_request(
1911                match role {
1912                    EmbeddingRole::Query => EmbedKind::Query,
1913                    EmbeddingRole::Passage => EmbedKind::Passage,
1914                },
1915                text,
1916            )
1917        }
1918
1919        fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
1920            &self.identity
1921        }
1922
1923        fn dimensions(&self) -> usize {
1924            self.dimensions
1925        }
1926
1927        fn name(&self) -> &str {
1928            self.model_id
1929        }
1930    }
1931
1932    #[cfg(test)]
1933    mod tests {
1934        use super::*;
1935
1936        fn test_identity(model: LatticeEmbeddingModel) -> EmbeddingSpaceIdentity {
1937            registry_identity("lattice-test", model.model_id(), model.dimensions()).unwrap()
1938        }
1939
1940        #[test]
1941        fn from_pretrained_rejects_remote_only_models() {
1942            // "text-embedding-3-small" and "openai" both parse successfully
1943            // to `EmbeddingModel::TextEmbedding3Small` (see lattice-embed's
1944            // `FromStr` impl) but that variant is remote/API-only —
1945            // `NativeEmbeddingService` cannot run it. Both aliases must be
1946            // rejected at construction time, not on first `embed()` call.
1947            assert!(
1948                LatticeEmbedding::from_pretrained("text-embedding-3-small").is_err(),
1949                "remote-only model 'text-embedding-3-small' must be rejected at construction"
1950            );
1951            assert!(
1952                LatticeEmbedding::from_pretrained("openai").is_err(),
1953                "remote-only model alias 'openai' must be rejected at construction"
1954            );
1955        }
1956
1957        /// Smoke test for the identity-free entry points: they must construct
1958        /// a working provider whose identity comes from the registry fixture,
1959        /// and must still reject models with no fixture.
1960        #[test]
1961        fn legacy_entry_points_use_the_registry_fixture() {
1962            let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")
1963                .expect("registered model must load through the legacy entry point");
1964            let identity = provider.embedding_space();
1965            assert_eq!(identity.model_id, "BAAI/bge-small-en-v1.5");
1966            assert_eq!(identity.role_policy, EmbeddingRolePolicy::Asymmetric);
1967            assert_eq!(identity.prefix_policy, PrefixPolicy::QueryRecommended);
1968
1969            let model: LatticeEmbeddingModel = "all-minilm-l6-v2".parse().unwrap();
1970            let by_model = LatticeEmbedding::with_model(model)
1971                .expect("with_model must load a registered model");
1972            assert_eq!(
1973                by_model.embedding_space().role_policy,
1974                EmbeddingRolePolicy::Symmetric
1975            );
1976
1977            assert!(
1978                LatticeEmbedding::from_pretrained("text-embedding-3-small").is_err(),
1979                "a model with no registry fixture must not get a guessed identity"
1980            );
1981        }
1982
1983        /// #662: pins the bge-small alias surface this provider accepts.
1984        /// `ruvector-extensions`' `LatticeWasmEmbeddings` (the WASM sibling of
1985        /// this provider) mirrors this same alias set
1986        /// (`normalizeLatticeWasmModel` in
1987        /// `npm/packages/ruvector-extensions/src/embeddings.ts`) so a model id
1988        /// valid for one Lattice-backed provider is valid for the other.
1989        #[test]
1990        fn from_pretrained_accepts_bge_small_alias_surface() {
1991            for alias in [
1992                "bge-small-en-v1.5",
1993                "bge-small-en",
1994                "bge-small",
1995                "small",
1996                "BAAI/bge-small-en-v1.5",
1997                "BGE_SMALL_EN_V1.5",
1998            ] {
1999                let model: LatticeEmbeddingModel = alias.parse().unwrap();
2000                let provider =
2001                    LatticeEmbedding::from_pretrained_with_identity(alias, test_identity(model))
2002                        .unwrap_or_else(|e| {
2003                            panic!("alias '{alias}' must resolve to bge-small: {e}")
2004                        });
2005                assert_eq!(
2006                    provider.dimensions(),
2007                    384,
2008                    "alias '{alias}' resolved to the wrong dimensionality"
2009                );
2010                assert_eq!(
2011                    provider.name(),
2012                    "BAAI/bge-small-en-v1.5",
2013                    "alias '{alias}' resolved to a different model than 'bge-small-en-v1.5'"
2014                );
2015            }
2016        }
2017
2018        /// Extends the bge-small/minilm contract above to the two asymmetric
2019        /// families it never covered — E5 (`query: ` / `passage: `, both sides
2020        /// prefixed) and Qwen3 (instruction on the query side, bare passage).
2021        ///
2022        /// Prefixing on this provider is owned by lattice-embed, not by
2023        /// `apply_prompt_template` (see
2024        /// [`assert_lattice_owns_matching_prefixes`](LatticeEmbedding::assert_lattice_owns_matching_prefixes)).
2025        /// This test pins that split: the constructor must succeed exactly when
2026        /// lattice-embed's instructions agree with ruvector's registry
2027        /// templates, and must fail closed — never silently attest a prompt
2028        /// hash for a template it does not apply — when they diverge.
2029        #[test]
2030        fn lattice_prefix_ownership_matches_registry_for_e5_and_qwen3() {
2031            const PROBE: &str = "\u{1}probe\u{1}";
2032            for alias in [
2033                "multilingual-e5-small",
2034                "qwen3-embedding-0.6b",
2035                "bge-small-en-v1.5",
2036                "all-minilm-l6-v2",
2037            ] {
2038                let Ok(model) = alias.parse::<LatticeEmbeddingModel>() else {
2039                    continue; // alias not carried by this lattice-embed version
2040                };
2041                let templates = registry_prompt_templates(model.model_id()).unwrap_or_else(|e| {
2042                    panic!("lattice model '{alias}' must have a ruvector registry entry: {e}")
2043                });
2044
2045                // Recompute the agreement independently of the constructor so
2046                // this asserts the guard's behavior, not its own implementation.
2047                let query_agrees =
2048                    format!("{}{PROBE}", model.query_instruction().unwrap_or_default())
2049                        == templates.apply(EmbeddingRole::Query, PROBE);
2050                let passage_agrees = format!(
2051                    "{}{PROBE}",
2052                    model.document_instruction().unwrap_or_default()
2053                ) == templates.apply(EmbeddingRole::Passage, PROBE);
2054                let agrees = query_agrees && passage_agrees;
2055
2056                let built = LatticeEmbedding::from_pretrained(alias);
2057                assert_eq!(
2058                    built.is_ok(),
2059                    agrees,
2060                    "prefix ownership for '{alias}': lattice-embed applies \
2061                     query={:?} passage={:?}, ruvector's registry claims query={:?} \
2062                     passage={:?}. Construction must succeed iff those agree, so that \
2063                     prompt_template_sha256 never attests a prompt lattice-embed will not \
2064                     apply. Constructor said: {:?}",
2065                    model.query_instruction(),
2066                    model.document_instruction(),
2067                    templates.query,
2068                    templates.passage,
2069                    built.err().map(|e| e.to_string()),
2070                );
2071            }
2072        }
2073
2074        /// Regression test for the nested-runtime panic: `embed` / `embed_query`
2075        /// used to call `Runtime::block_on` on a `Runtime` stored on the
2076        /// provider, which panics when invoked from inside an already-running
2077        /// Tokio runtime. The worker-thread bridge has no ambient runtime on
2078        /// the calling side, so both calls must succeed here instead.
2079        #[tokio::test]
2080        async fn embed_from_inside_async_runtime_does_not_panic() {
2081            let model: LatticeEmbeddingModel = "bge-small-en-v1.5".parse().unwrap();
2082            let provider = LatticeEmbedding::from_pretrained_with_identity(
2083                "bge-small-en-v1.5",
2084                test_identity(model),
2085            )
2086            .expect("bge-small-en-v1.5 is a native local model");
2087
2088            let doc = provider
2089                .embed("a nested-runtime regression test")
2090                .expect("embed must not panic or error from inside a Tokio runtime");
2091            assert_eq!(doc.len(), provider.dimensions());
2092
2093            let query = provider
2094                .embed_query("a nested-runtime regression test")
2095                .expect("embed_query must not panic or error from inside a Tokio runtime");
2096            assert_eq!(query.len(), provider.dimensions());
2097        }
2098
2099        /// Cross-provider contract test (maintainer follow-up on #663).
2100        ///
2101        /// This provider never builds the prefixed query string itself: `embed_query`
2102        /// forwards raw `text` to `EmbeddingService::embed_query`, which prepends
2103        /// `model.query_instruction()` internally (see `send_request` above and
2104        /// `lattice_embed::EmbeddingService::embed_query`'s default impl). So the
2105        /// prefix this provider *effectively* applies for a given model **is**
2106        /// `LatticeEmbeddingModel::query_instruction()` / `document_instruction()` --
2107        /// both documented `**Stable**` in lattice-embed's own API-stability
2108        /// convention (`crates/embed/src/model.rs` in ohdearquant/lattice).
2109        ///
2110        /// `ruvector-extensions`' WASM sibling provider has no such delegation
2111        /// (`@khive-ai/lattice-embed-wasm`'s `embed()` binding takes raw text only,
2112        /// no prefix concept), so it hardcodes the same prefixes as a TS literal
2113        /// map (`LATTICE_WASM_QUERY_INSTRUCTIONS` in
2114        /// `npm/packages/ruvector-extensions/src/embeddings.ts`) and asserts against
2115        /// the identical fixture in its own contract test
2116        /// (`npm/packages/ruvector-extensions/tests/lattice-prefix-contract.test.ts`).
2117        /// Both tests read `fixtures/lattice-embed/query-prefixes.json` at the repo
2118        /// root, so a future lattice-embed bump that changes either model's
2119        /// convention fails this test on the Rust side (and its TS sibling
2120        /// independently), instead of the two providers silently re-diverging the
2121        /// way they did before #663.
2122        #[test]
2123        fn cross_provider_query_prefix_contract() {
2124            let fixture: serde_json::Value = serde_json::from_str(include_str!(
2125                "../../../fixtures/lattice-embed/query-prefixes.json"
2126            ))
2127            .expect("fixtures/lattice-embed/query-prefixes.json must be valid JSON");
2128
2129            let models = fixture["models"]
2130                .as_object()
2131                .expect("fixture must have a top-level 'models' object");
2132            assert!(
2133                !models.is_empty(),
2134                "fixture 'models' must not be empty -- an empty fixture would make this \
2135                 contract test vacuously pass"
2136            );
2137            assert!(
2138                models.contains_key("bge-small"),
2139                "fixture must cover 'bge-small' -- the model #662 was about"
2140            );
2141            assert!(
2142                models.contains_key("minilm"),
2143                "fixture must cover 'minilm' as the symmetric control case"
2144            );
2145
2146            for (alias, expected) in models {
2147                let model: LatticeEmbeddingModel = alias.parse().unwrap_or_else(|e| {
2148                    panic!(
2149                        "fixture alias '{alias}' must be a valid lattice_embed::EmbeddingModel: {e}"
2150                    )
2151                });
2152
2153                let expected_query_prefix = expected["query_prefix"].as_str();
2154                assert_eq!(
2155                    model.query_instruction(),
2156                    expected_query_prefix,
2157                    "query prefix mismatch for '{alias}': lattice_embed::EmbeddingModel::\
2158                     query_instruction() returned {:?} but fixtures/lattice-embed/\
2159                     query-prefixes.json expects {:?}. If lattice-embed intentionally changed \
2160                     this model's convention, update the fixture AND the TS sibling test in \
2161                     npm/packages/ruvector-extensions/tests/lattice-prefix-contract.test.ts \
2162                     together.",
2163                    model.query_instruction(),
2164                    expected_query_prefix
2165                );
2166
2167                let expected_passage_prefix = expected["passage_prefix"].as_str();
2168                assert_eq!(
2169                    model.document_instruction(),
2170                    expected_passage_prefix,
2171                    "passage prefix mismatch for '{alias}': lattice_embed::EmbeddingModel::\
2172                     document_instruction() returned {:?} but the fixture expects {:?}",
2173                    model.document_instruction(),
2174                    expected_passage_prefix
2175                );
2176            }
2177        }
2178    }
2179}
2180
2181#[cfg(feature = "lattice-embeddings")]
2182pub use lattice_native::LatticeEmbedding;
2183
2184/// Type-erased embedding provider for dynamic dispatch
2185pub type BoxedEmbeddingProvider = Arc<dyn EmbeddingProvider>;
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::*;
2190
2191    #[test]
2192    fn query_template_change_creates_a_new_space_and_read_only_access() {
2193        let stored = default_space_identity("fixture", "same-model", 384);
2194        let mut active = stored.clone();
2195        active.prompt_template_sha256 = sha256_hex(b"changed query template");
2196        active.prefix_policy_version += 1;
2197
2198        assert_ne!(
2199            stored.embedding_space_id().unwrap(),
2200            active.embedding_space_id().unwrap()
2201        );
2202        let access = EmbeddingSpaceAccess::for_identities(&stored, &active).unwrap();
2203        assert!(!access.text_embedding);
2204        assert!(!access.corpus_mutation);
2205        assert!(access.vector_read && access.inspect && access.verify && access.export);
2206        assert_ne!(
2207            embedding_cache_key(&stored, EmbeddingRole::Query, "same query").unwrap(),
2208            embedding_cache_key(&active, EmbeddingRole::Query, "same query").unwrap(),
2209        );
2210    }
2211
2212    #[test]
2213    fn canonical_identity_matches_cross_runtime_fixture() {
2214        let fixture: serde_json::Value = serde_json::from_str(include_str!(
2215            "../../../schemas/fixtures/embedding-space-identity-v1.json"
2216        ))
2217        .unwrap();
2218        for variant in ["base", "changed_query_template"] {
2219            let identity: EmbeddingSpaceIdentity =
2220                serde_json::from_value(fixture[variant]["identity"].clone()).unwrap();
2221            assert_eq!(
2222                identity.embedding_space_id().unwrap(),
2223                fixture[variant]["embedding_space_id"].as_str().unwrap()
2224            );
2225        }
2226    }
2227
2228    #[test]
2229    fn cache_key_is_role_aware() {
2230        let identity = default_space_identity("fixture", "same-model", 4);
2231        assert_ne!(
2232            embedding_cache_key(&identity, EmbeddingRole::Query, "text").unwrap(),
2233            embedding_cache_key(&identity, EmbeddingRole::Passage, "text").unwrap(),
2234        );
2235    }
2236
2237    #[test]
2238    fn trait_object_preserves_explicit_roles_and_legacy_metric() {
2239        let provider: Arc<dyn EmbeddingProvider> = Arc::new(HashEmbedding::new(8));
2240        assert_eq!(
2241            provider.embed_query("same").unwrap(),
2242            provider.embed_passage("same").unwrap()
2243        );
2244        let before = legacy_embed_call_count();
2245        #[allow(deprecated)]
2246        let _ = provider.embed("legacy").unwrap();
2247        assert_eq!(legacy_embed_call_count(), before + 1);
2248    }
2249
2250    #[test]
2251    fn test_hash_embedding() {
2252        let provider = HashEmbedding::new(128);
2253
2254        let emb1 = provider.embed("hello world").unwrap();
2255        let emb2 = provider.embed("hello world").unwrap();
2256
2257        assert_eq!(emb1.len(), 128);
2258        assert_eq!(emb1, emb2, "Same text should produce same embedding");
2259
2260        // Check normalization
2261        let norm: f32 = emb1.iter().map(|x| x * x).sum::<f32>().sqrt();
2262        assert!((norm - 1.0).abs() < 1e-5, "Embedding should be normalized");
2263    }
2264
2265    #[test]
2266    fn test_hash_embedding_different_text() {
2267        let provider = HashEmbedding::new(128);
2268
2269        let emb1 = provider.embed("hello").unwrap();
2270        let emb2 = provider.embed("world").unwrap();
2271
2272        assert_ne!(
2273            emb1, emb2,
2274            "Different text should produce different embeddings"
2275        );
2276    }
2277
2278    #[cfg(feature = "real-embeddings")]
2279    #[test]
2280    #[ignore] // Requires model download
2281    fn test_candle_embedding() {
2282        let provider =
2283            CandleEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2", false)
2284                .unwrap();
2285
2286        let embedding = provider.embed("hello world").unwrap();
2287        assert_eq!(embedding.len(), 384);
2288
2289        // Check normalization
2290        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
2291        assert!((norm - 1.0).abs() < 1e-5, "Embedding should be normalized");
2292    }
2293
2294    #[test]
2295    #[cfg(feature = "api-embeddings")]
2296    #[ignore] // Requires API key
2297    fn test_api_embedding_openai() {
2298        let api_key = std::env::var("OPENAI_API_KEY").unwrap();
2299        let identity = default_space_identity("test-api", "text-embedding-3-small", 1536);
2300        let provider = ApiEmbedding::openai(&api_key, "text-embedding-3-small", identity).unwrap();
2301
2302        let embedding = provider.embed("hello world").unwrap();
2303        assert_eq!(embedding.len(), 1536);
2304    }
2305
2306    #[cfg(feature = "onnx-embeddings")]
2307    mod onnx_tests {
2308        use super::*;
2309
2310        #[test]
2311        #[ignore] // Requires model download (~90MB)
2312        fn test_onnx_embedding_minilm() {
2313            let provider =
2314                OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
2315
2316            let embedding = provider.embed("hello world").unwrap();
2317            assert_eq!(embedding.len(), 384);
2318
2319            // Check normalization
2320            let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
2321            assert!(
2322                (norm - 1.0).abs() < 1e-4,
2323                "Embedding should be normalized, got norm={}",
2324                norm
2325            );
2326        }
2327
2328        #[test]
2329        #[ignore] // Requires model download
2330        fn test_onnx_semantic_similarity() {
2331            let provider =
2332                OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
2333
2334            let emb_dog = provider.embed("dog").unwrap();
2335            let emb_cat = provider.embed("cat").unwrap();
2336            let emb_car = provider.embed("car").unwrap();
2337
2338            // Cosine similarity (embeddings are normalized, so dot product = cosine)
2339            let sim_dog_cat: f32 = emb_dog.iter().zip(&emb_cat).map(|(a, b)| a * b).sum();
2340            let sim_dog_car: f32 = emb_dog.iter().zip(&emb_car).map(|(a, b)| a * b).sum();
2341
2342            // dog and cat should be more similar than dog and car
2343            assert!(
2344                sim_dog_cat > sim_dog_car,
2345                "Expected dog-cat similarity ({}) > dog-car similarity ({})",
2346                sim_dog_cat,
2347                sim_dog_car
2348            );
2349        }
2350
2351        #[test]
2352        #[ignore] // Requires model download
2353        fn test_onnx_batch_embedding() {
2354            let provider =
2355                OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
2356
2357            let texts = vec!["hello world", "goodbye world", "rust programming"];
2358            let embeddings = provider.embed_batch(&texts).unwrap();
2359
2360            assert_eq!(embeddings.len(), 3);
2361            for emb in &embeddings {
2362                assert_eq!(emb.len(), 384);
2363            }
2364        }
2365    }
2366
2367    #[cfg(feature = "lattice-embeddings")]
2368    mod lattice_tests {
2369        use super::*;
2370        use crate::embeddings::LatticeEmbedding;
2371        use lattice_embed::EmbeddingModel as LatticeEmbeddingModel;
2372
2373        fn test_provider(alias: &str) -> LatticeEmbedding {
2374            let model: LatticeEmbeddingModel = alias.parse().unwrap();
2375            let identity =
2376                registry_identity("lattice-test", model.model_id(), model.dimensions()).unwrap();
2377            LatticeEmbedding::from_pretrained_with_identity(alias, identity).unwrap()
2378        }
2379
2380        /// Pure model-id mapping test — no network, no model load.
2381        /// `LatticeEmbedding::from_pretrained` delegates to
2382        /// `lattice_embed::EmbeddingModel::from_str`; this test locks in that
2383        /// bge-small resolves from both its display name and its HuggingFace
2384        /// id, and that an unrecognized id errors instead of silently
2385        /// defaulting.
2386        #[test]
2387        fn test_lattice_from_pretrained_model_id_mapping() {
2388            let by_display_name = test_provider("bge-small-en-v1.5");
2389            assert_eq!(by_display_name.dimensions(), 384);
2390            assert_eq!(EmbeddingProvider::dimensions(&by_display_name), 384);
2391
2392            let by_hf_id = test_provider("BAAI/bge-small-en-v1.5");
2393            assert_eq!(by_hf_id.dimensions(), 384);
2394
2395            let unknown = LatticeEmbedding::from_pretrained("not-a-real-model");
2396            assert!(
2397                unknown.is_err(),
2398                "unknown model id should error, not default"
2399            );
2400        }
2401
2402        #[test]
2403        fn test_lattice_from_pretrained_minilm_mapping() {
2404            let by_short = test_provider("all-minilm-l6-v2");
2405            assert_eq!(by_short.dimensions(), 384);
2406
2407            let by_hf_id = test_provider("sentence-transformers/all-MiniLM-L6-v2");
2408            assert_eq!(by_hf_id.dimensions(), 384);
2409        }
2410
2411        /// Real end-to-end embedding test. Requires the bge-small-en-v1.5
2412        /// model to be downloaded from HuggingFace on first use (~130MB) —
2413        /// network access, not run in CI. Run manually with:
2414        ///   cargo test -p ruvector-core --features lattice-embeddings -- --ignored lattice_tests
2415        #[test]
2416        #[ignore]
2417        fn test_lattice_embedding_real() {
2418            let provider = test_provider("bge-small-en-v1.5");
2419
2420            let embedding = provider.embed("hello world").unwrap();
2421            assert_eq!(embedding.len(), 384);
2422            assert!(embedding.iter().all(|v| v.is_finite()));
2423
2424            let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
2425            assert!(
2426                (norm - 1.0).abs() < 1e-3,
2427                "embedding should be L2-normalized, got norm={norm}"
2428            );
2429        }
2430    }
2431}