Skip to main content

lattice_embed/
model.rs

1//! Embedding model definitions.
2//!
3//! Provides `EmbeddingModel` enum for local model selection.
4
5use serde::{Deserialize, Serialize};
6use std::time::SystemTime;
7
8/// **Stable**: external consumers may depend on this; breaking changes require a SemVer bump.
9///
10/// Model provenance information for security audits.
11///
12/// Tracks metadata about when and how a model was loaded, including a hash
13/// for verification that the model hasn't been tampered with.
14///
15/// # Example
16///
17/// ```rust
18/// use lattice_embed::{EmbeddingModel, ModelProvenance};
19///
20/// // Created when a model is loaded
21/// let provenance = ModelProvenance::new(
22///     EmbeddingModel::BgeSmallEnV15,
23///     "BAAI/bge-small-en-v1.5".to_string(),
24/// );
25///
26/// assert!(provenance.model_id.contains("BAAI"));
27/// assert!(!provenance.hash.is_empty());
28/// ```
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ModelProvenance {
31    /// **Stable**: model variant that was loaded.
32    pub model: EmbeddingModel,
33    /// **Stable**: source identifier (HuggingFace ID, URL, or file path).
34    pub model_id: String,
35    /// **Stable**: Blake3 hash of the model identifier + timestamp for uniqueness.
36    ///
37    /// Note: This is a lightweight hash based on metadata, not a full hash
38    /// of model weights (which would be expensive). For full model verification,
39    /// use the lattice-inference library's built-in checksum verification.
40    pub hash: String,
41    /// **Stable**: when the model was loaded.
42    pub loaded_at: SystemTime,
43    /// **Stable**: formatted timestamp string for convenience.
44    pub loaded_at_iso: String,
45}
46
47impl ModelProvenance {
48    /// **Stable**: create new provenance information for a loaded model.
49    pub fn new(model: EmbeddingModel, model_id: String) -> Self {
50        let loaded_at = SystemTime::now();
51        let loaded_at_iso = {
52            let dt: chrono::DateTime<chrono::Utc> = loaded_at.into();
53            dt.to_rfc3339()
54        };
55
56        // Create a lightweight hash from model metadata
57        let hash_input = format!("{model_id}:{loaded_at_iso}:{model:?}");
58        let hash = blake3::hash(hash_input.as_bytes()).to_hex().to_string();
59
60        Self {
61            model,
62            model_id,
63            hash,
64            loaded_at,
65            loaded_at_iso,
66        }
67    }
68
69    /// **Stable**: get the model dimensions.
70    pub fn dimensions(&self) -> usize {
71        self.model.dimensions()
72    }
73
74    /// **Stable**: check if this provenance matches expected model.
75    pub fn matches_model(&self, expected: EmbeddingModel) -> bool {
76        self.model == expected
77    }
78}
79
80/// **Stable**: external consumers may depend on this; breaking changes require a SemVer bump.
81///
82/// Supported embedding models.
83///
84/// This enum represents the embedding models available for text vectorization.
85/// Models are categorized as either local (run on-device via lattice-inference) or
86/// remote (require API calls).
87///
88/// # Example
89///
90/// ```rust
91/// use lattice_embed::EmbeddingModel;
92///
93/// let model = EmbeddingModel::default();
94/// assert_eq!(model.dimensions(), 384);
95/// assert!(model.is_local());
96/// ```
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
98#[serde(rename_all = "snake_case")]
99#[non_exhaustive]
100pub enum EmbeddingModel {
101    /// BGE small English v1.5 (384 dimensions) - fast and efficient.
102    #[default]
103    #[serde(alias = "BgeSmallEnV15")]
104    BgeSmallEnV15,
105
106    /// BGE base English v1.5 (768 dimensions) - balanced quality/speed.
107    #[serde(alias = "BgeBaseEnV15")]
108    BgeBaseEnV15,
109
110    /// BGE large English v1.5 (1024 dimensions) - highest quality local.
111    #[serde(alias = "BgeLargeEnV15")]
112    BgeLargeEnV15,
113
114    /// Multilingual E5 small (384 dimensions) - multilingual, same arch as BGE.
115    #[serde(alias = "MultilingualE5Small")]
116    MultilingualE5Small,
117
118    /// Multilingual E5 base (768 dimensions) - best multilingual quality/speed.
119    #[serde(alias = "MultilingualE5Base")]
120    MultilingualE5Base,
121
122    /// Qwen3-Embedding-0.6B (1024 dimensions) - multilingual, decoder-only, GPU-accelerated.
123    #[serde(alias = "Qwen3Embedding0_6B")]
124    Qwen3Embedding0_6B,
125
126    /// Qwen3-Embedding-4B (2560 dimensions, MRL-capable) - multilingual, decoder-only, GPU-accelerated.
127    #[serde(alias = "Qwen3Embedding4B")]
128    Qwen3Embedding4B,
129
130    /// all-MiniLM-L6-v2 (384 dimensions) - BERT-class, WordPiece tokenizer, sentence-transformers.
131    #[serde(alias = "AllMiniLmL6V2")]
132    AllMiniLmL6V2,
133
134    /// paraphrase-multilingual-MiniLM-L12-v2 (384 dimensions) - multilingual, XLM-R base, sentence-transformers.
135    #[serde(alias = "ParaphraseMultilingualMiniLmL12V2")]
136    ParaphraseMultilingualMiniLmL12V2,
137
138    /// OpenAI text-embedding-3-small (1536 dimensions) - remote API.
139    #[serde(alias = "TextEmbedding3Small")]
140    TextEmbedding3Small,
141}
142
143impl EmbeddingModel {
144    /// **Stable**: get the native (full-resolution) output dimension of this model's embeddings.
145    ///
146    /// Returns the model's intrinsic dimension regardless of any MRL truncation.
147    /// For MRL-capable models with a configured truncation, use `ModelConfig::dimensions()`.
148    #[inline]
149    pub const fn native_dimensions(&self) -> usize {
150        match self {
151            EmbeddingModel::BgeSmallEnV15
152            | EmbeddingModel::MultilingualE5Small
153            | EmbeddingModel::AllMiniLmL6V2
154            | EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => 384,
155            EmbeddingModel::BgeBaseEnV15 | EmbeddingModel::MultilingualE5Base => 768,
156            EmbeddingModel::BgeLargeEnV15 | EmbeddingModel::Qwen3Embedding0_6B => 1024,
157            EmbeddingModel::Qwen3Embedding4B => 2560,
158            EmbeddingModel::TextEmbedding3Small => 1536,
159        }
160    }
161
162    /// **Stable**: get the output dimension of this model's embeddings.
163    ///
164    /// # Example
165    ///
166    /// ```rust
167    /// use lattice_embed::EmbeddingModel;
168    ///
169    /// assert_eq!(EmbeddingModel::BgeSmallEnV15.dimensions(), 384);
170    /// assert_eq!(EmbeddingModel::BgeBaseEnV15.dimensions(), 768);
171    /// assert_eq!(EmbeddingModel::BgeLargeEnV15.dimensions(), 1024);
172    /// ```
173    #[inline]
174    pub const fn dimensions(&self) -> usize {
175        self.native_dimensions()
176    }
177
178    /// **Stable**: check if this model can run locally (via lattice-inference).
179    #[inline]
180    pub const fn is_local(&self) -> bool {
181        matches!(
182            self,
183            EmbeddingModel::BgeSmallEnV15
184                | EmbeddingModel::BgeBaseEnV15
185                | EmbeddingModel::BgeLargeEnV15
186                | EmbeddingModel::MultilingualE5Small
187                | EmbeddingModel::MultilingualE5Base
188                | EmbeddingModel::AllMiniLmL6V2
189                | EmbeddingModel::ParaphraseMultilingualMiniLmL12V2
190                | EmbeddingModel::Qwen3Embedding0_6B
191                | EmbeddingModel::Qwen3Embedding4B
192        )
193    }
194
195    /// **Stable**: check if this model requires a remote API.
196    #[inline]
197    pub const fn is_remote(&self) -> bool {
198        matches!(self, EmbeddingModel::TextEmbedding3Small)
199    }
200
201    /// **Stable**: maximum input tokens supported by this model.
202    ///
203    /// Use this for chunking/truncation decisions. Values are conservative
204    /// to leave room for special tokens.
205    ///
206    /// Reference limits:
207    /// - BGE models: 512 tokens
208    /// - OpenAI text-embedding-3: 8191 tokens
209    /// - Gemini embedding-001: 20000 tokens
210    #[inline]
211    pub const fn max_input_tokens(&self) -> usize {
212        match self {
213            // BGE models have 512 token limit
214            EmbeddingModel::BgeSmallEnV15 => 512,
215            EmbeddingModel::BgeBaseEnV15 => 512,
216            EmbeddingModel::BgeLargeEnV15 => 512,
217            // E5 models have 512 token limit
218            EmbeddingModel::MultilingualE5Small => 512,
219            EmbeddingModel::MultilingualE5Base => 512,
220            // MiniLM has a shorter context window
221            EmbeddingModel::AllMiniLmL6V2 => 256,
222            // paraphrase-multilingual-MiniLM max sequence length 128
223            EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => 128,
224            // Qwen3-Embedding supports 32K but we cap at 8192 for practical use
225            EmbeddingModel::Qwen3Embedding0_6B => 8192,
226            EmbeddingModel::Qwen3Embedding4B => 8192,
227            // OpenAI text-embedding-3-small has 8191 token limit
228            EmbeddingModel::TextEmbedding3Small => 8191,
229        }
230    }
231
232    /// **Stable**: query instruction prefix for asymmetric retrieval.
233    ///
234    /// Some models require different text for queries vs documents (asymmetric retrieval).
235    ///
236    /// - **E5 models** (`MultilingualE5Small`, `MultilingualE5Base`): trained with
237    ///   "query: " / "passage: " asymmetric prefixes. Omitting the prefix degrades
238    ///   retrieval quality significantly — the model expects them during fine-tuning.
239    ///
240    /// - **Qwen3-Embedding** models: require an instruction prompt to align the
241    ///   decoder embedding space for retrieval tasks.
242    ///
243    /// - **BGE** models (`BgeSmallEnV15`, `BgeBaseEnV15`, `BgeLargeEnV15`): also
244    ///   asymmetric-retrieval — queries need the BGE-v1.5 retrieval instruction
245    ///   prefix, passages do not (see `document_instruction()`, which stays
246    ///   `None` for BGE). Omitting the prefix degrades retrieval quality.
247    ///
248    /// - **MiniLM** models: trained with contrastive objectives on raw text;
249    ///   genuinely symmetric, no prefix needed on either side.
250    ///
251    /// Returns `Some(prefix)` if the query text should be wrapped as
252    /// `"{prefix}{query}"` before embedding. Returns `None` for models that
253    /// don't need instruction prompting.
254    #[inline]
255    pub const fn query_instruction(&self) -> Option<&'static str> {
256        match self {
257            EmbeddingModel::MultilingualE5Small | EmbeddingModel::MultilingualE5Base => {
258                // E5 asymmetric retrieval: "query: " prefix for queries,
259                // "passage: " prefix for documents (see document_instruction()).
260                Some("query: ")
261            }
262            EmbeddingModel::Qwen3Embedding0_6B | EmbeddingModel::Qwen3Embedding4B => Some(
263                "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: ",
264            ),
265            EmbeddingModel::BgeSmallEnV15
266            | EmbeddingModel::BgeBaseEnV15
267            | EmbeddingModel::BgeLargeEnV15 => {
268                // BGE-v1.5 asymmetric retrieval: queries get the documented
269                // retrieval instruction, passages stay raw text (see
270                // document_instruction()).
271                Some("Represent this sentence for searching relevant passages: ")
272            }
273            _ => None,
274        }
275    }
276
277    /// **Stable**: document instruction prefix for asymmetric retrieval.
278    ///
279    /// Some models use different prompts for documents vs queries.
280    /// Returns `Some(prefix)` if the document text should be wrapped as
281    /// `"{prefix}{text}"` before embedding at storage time.
282    ///
283    /// - **E5 models**: trained with `"passage: "` prefix on document/passage inputs.
284    ///   Omitting the prefix on the document side degrades retrieval quality because
285    ///   the model's embedding space was conditioned on this asymmetry during fine-tuning.
286    /// - **BGE / MiniLM**: no document prefix required (contrastive training on raw text).
287    /// - **Qwen3-Embedding**: raw passage text is used without an instruction prefix;
288    ///   only the query side carries the task instruction.
289    #[inline]
290    pub const fn document_instruction(&self) -> Option<&'static str> {
291        match self {
292            EmbeddingModel::MultilingualE5Small | EmbeddingModel::MultilingualE5Base => {
293                // E5 asymmetric retrieval: "passage: " prefix for documents/passages.
294                Some("passage: ")
295            }
296            _ => None,
297        }
298    }
299
300    /// **Stable**: get the model identifier (HuggingFace ID or provider/model).
301    #[inline]
302    pub const fn model_id(&self) -> &'static str {
303        match self {
304            EmbeddingModel::BgeSmallEnV15 => "BAAI/bge-small-en-v1.5",
305            EmbeddingModel::BgeBaseEnV15 => "BAAI/bge-base-en-v1.5",
306            EmbeddingModel::BgeLargeEnV15 => "BAAI/bge-large-en-v1.5",
307            EmbeddingModel::MultilingualE5Small => "intfloat/multilingual-e5-small",
308            EmbeddingModel::MultilingualE5Base => "intfloat/multilingual-e5-base",
309            EmbeddingModel::AllMiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
310            EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => {
311                "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
312            }
313            EmbeddingModel::Qwen3Embedding0_6B => "Qwen/Qwen3-Embedding-0.6B",
314            EmbeddingModel::Qwen3Embedding4B => "Qwen/Qwen3-Embedding-4B",
315            EmbeddingModel::TextEmbedding3Small => "text-embedding-3-small",
316        }
317    }
318
319    /// **Stable**: whether this model supports configurable output dimensions (MRL/Matryoshka).
320    #[inline]
321    pub const fn supports_output_dim(&self) -> bool {
322        matches!(
323            self,
324            EmbeddingModel::Qwen3Embedding0_6B | EmbeddingModel::Qwen3Embedding4B
325        )
326    }
327
328    /// **Stable**: the pooling strategy this model expects from BERT-family inference.
329    ///
330    /// BGE v1.5 models use CLS-token pooling (first token) as documented on their
331    /// HuggingFace model cards (`model_output[0][:, 0]`).  All other BERT-family
332    /// models (E5, MiniLM) use masked mean pooling.
333    ///
334    /// Returns `None` for non-BERT models (Qwen3, OpenAI remote) which have their
335    /// own pooling paths.
336    ///
337    /// Only available when the `native` feature is enabled (requires `lattice-inference`).
338    #[cfg(feature = "native")]
339    #[inline]
340    pub const fn bert_pooling(&self) -> Option<lattice_inference::BertPooling> {
341        match self {
342            // BGE v1.5 — CLS pooling per model card
343            EmbeddingModel::BgeSmallEnV15
344            | EmbeddingModel::BgeBaseEnV15
345            | EmbeddingModel::BgeLargeEnV15 => Some(lattice_inference::BertPooling::CLS),
346            // E5 multilingual — masked mean pooling per model card
347            EmbeddingModel::MultilingualE5Small | EmbeddingModel::MultilingualE5Base => {
348                Some(lattice_inference::BertPooling::Mean)
349            }
350            // MiniLM family — masked mean pooling per sentence-transformers convention
351            EmbeddingModel::AllMiniLmL6V2 | EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => {
352                Some(lattice_inference::BertPooling::Mean)
353            }
354            // Qwen and remote models — not BERT-family, pooling handled separately
355            EmbeddingModel::Qwen3Embedding0_6B
356            | EmbeddingModel::Qwen3Embedding4B
357            | EmbeddingModel::TextEmbedding3Small => None,
358        }
359    }
360
361    /// **Stable**: embedding key revision string for this model family.
362    #[inline]
363    pub const fn key_version(&self) -> &'static str {
364        match self {
365            EmbeddingModel::TextEmbedding3Small
366            | EmbeddingModel::Qwen3Embedding0_6B
367            | EmbeddingModel::Qwen3Embedding4B => "v3",
368            EmbeddingModel::AllMiniLmL6V2 | EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => {
369                "v2"
370            }
371            _ => "v1.5",
372        }
373    }
374}
375
376impl std::fmt::Display for EmbeddingModel {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        match self {
379            EmbeddingModel::BgeSmallEnV15 => write!(f, "bge-small-en-v1.5"),
380            EmbeddingModel::BgeBaseEnV15 => write!(f, "bge-base-en-v1.5"),
381            EmbeddingModel::BgeLargeEnV15 => write!(f, "bge-large-en-v1.5"),
382            EmbeddingModel::MultilingualE5Small => write!(f, "multilingual-e5-small"),
383            EmbeddingModel::MultilingualE5Base => write!(f, "multilingual-e5-base"),
384            EmbeddingModel::Qwen3Embedding0_6B => write!(f, "qwen3-embedding-0.6b"),
385            EmbeddingModel::Qwen3Embedding4B => write!(f, "qwen3-embedding-4b"),
386            EmbeddingModel::AllMiniLmL6V2 => write!(f, "all-minilm-l6-v2"),
387            EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => {
388                write!(f, "paraphrase-multilingual-minilm-l12-v2")
389            }
390            EmbeddingModel::TextEmbedding3Small => write!(f, "text-embedding-3-small"),
391        }
392    }
393}
394
395impl std::str::FromStr for EmbeddingModel {
396    type Err = String;
397
398    /// **Stable**: parse model from string (case-insensitive, flexible matching).
399    ///
400    /// Accepts:
401    /// - Display names: "bge-small-en-v1.5"
402    /// - Short names: "bge-small", "small"
403    /// - HuggingFace IDs: "BAAI/bge-small-en-v1.5"
404    fn from_str(s: &str) -> Result<Self, Self::Err> {
405        let lower = s.to_lowercase();
406        let normalized = lower.trim().replace("_", "-").replace("baai/", "");
407
408        match normalized.as_str() {
409            "bge-small-en-v1.5" | "bge-small-en" | "bge-small" | "small" => {
410                Ok(EmbeddingModel::BgeSmallEnV15)
411            }
412            "bge-base-en-v1.5" | "bge-base-en" | "bge-base" | "base" => {
413                Ok(EmbeddingModel::BgeBaseEnV15)
414            }
415            "bge-large-en-v1.5" | "bge-large-en" | "bge-large" | "large" => {
416                Ok(EmbeddingModel::BgeLargeEnV15)
417            }
418            "multilingual-e5-small" | "e5-small" | "intfloat/multilingual-e5-small" => {
419                Ok(EmbeddingModel::MultilingualE5Small)
420            }
421            "multilingual-e5-base" | "e5-base" | "intfloat/multilingual-e5-base" => {
422                Ok(EmbeddingModel::MultilingualE5Base)
423            }
424            "qwen3-embedding-0.6b" | "qwen3-embedding" | "qwen3" | "qwen/qwen3-embedding-0.6b" => {
425                Ok(EmbeddingModel::Qwen3Embedding0_6B)
426            }
427            "qwen3-embedding-4b" | "qwen3-4b" | "qwen/qwen3-embedding-4b" => {
428                Ok(EmbeddingModel::Qwen3Embedding4B)
429            }
430            "all-minilm-l6-v2"
431            | "minilm"
432            | "all-minilm"
433            | "sentence-transformers/all-minilm-l6-v2" => Ok(EmbeddingModel::AllMiniLmL6V2),
434            "paraphrase-multilingual-minilm-l12-v2"
435            | "paraphrase-multilingual"
436            | "multilingual-minilm"
437            | "sentence-transformers/paraphrase-multilingual-minilm-l12-v2" => {
438                Ok(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2)
439            }
440            "text-embedding-3-small" | "openai-small" | "openai" => {
441                Ok(EmbeddingModel::TextEmbedding3Small)
442            }
443            _ => Err(format!(
444                "unknown embedding model: '{s}'. Valid: bge-small-en-v1.5, bge-base-en-v1.5, bge-large-en-v1.5, multilingual-e5-small, multilingual-e5-base, text-embedding-3-small"
445            )),
446        }
447    }
448}
449
450// ============================================================================
451// ModelConfig — runtime MRL dimension configuration
452// ============================================================================
453
454/// Minimum allowed MRL output dimension.
455pub const MIN_MRL_OUTPUT_DIM: usize = 32;
456
457/// Runtime configuration pairing a model with an optional MRL truncation dimension.
458///
459/// Two `ModelConfig` values with different `output_dim` produce different embedding spaces
460/// and must be stored in separate vector index namespaces.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
462pub struct ModelConfig {
463    /// The underlying embedding model.
464    pub model: EmbeddingModel,
465    /// MRL truncation dimension. `None` uses the model's native dimension.
466    #[serde(default)]
467    pub output_dim: Option<usize>,
468}
469
470impl Default for ModelConfig {
471    fn default() -> Self {
472        Self::new(EmbeddingModel::default())
473    }
474}
475
476impl ModelConfig {
477    /// Create a config with no MRL truncation (native model dimension).
478    pub const fn new(model: EmbeddingModel) -> Self {
479        Self {
480            model,
481            output_dim: None,
482        }
483    }
484
485    /// Create and validate a config with an optional MRL truncation dimension.
486    pub fn try_new(
487        model: EmbeddingModel,
488        output_dim: Option<usize>,
489    ) -> std::result::Result<Self, crate::error::EmbedError> {
490        let config = Self { model, output_dim };
491        config.validate()?;
492        Ok(config)
493    }
494
495    /// Validate that the output dimension is consistent with the model.
496    pub fn validate(&self) -> std::result::Result<(), crate::error::EmbedError> {
497        let Some(dim) = self.output_dim else {
498            return Ok(());
499        };
500        if !self.model.supports_output_dim() {
501            return Err(crate::error::EmbedError::InvalidInput(format!(
502                "{} does not support configurable embedding dimensions",
503                self.model
504            )));
505        }
506        if dim < MIN_MRL_OUTPUT_DIM {
507            return Err(crate::error::EmbedError::InvalidInput(format!(
508                "embedding output dimension {dim} is below minimum {MIN_MRL_OUTPUT_DIM}"
509            )));
510        }
511        let native = self.model.native_dimensions();
512        if dim > native {
513            return Err(crate::error::EmbedError::InvalidInput(format!(
514                "embedding output dimension {dim} exceeds native dimension {native} for {}",
515                self.model
516            )));
517        }
518        Ok(())
519    }
520
521    /// Active output dimension: configured truncation if set, otherwise the model's native dimension.
522    pub fn dimensions(&self) -> usize {
523        self.output_dim
524            .unwrap_or_else(|| self.model.native_dimensions())
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn test_default_model() {
534        let model = EmbeddingModel::default();
535        assert_eq!(model, EmbeddingModel::BgeSmallEnV15);
536    }
537
538    #[test]
539    fn test_model_provenance_new() {
540        let provenance = ModelProvenance::new(
541            EmbeddingModel::BgeSmallEnV15,
542            "BAAI/bge-small-en-v1.5".into(),
543        );
544
545        assert_eq!(provenance.model, EmbeddingModel::BgeSmallEnV15);
546        assert_eq!(provenance.model_id, "BAAI/bge-small-en-v1.5");
547        assert!(!provenance.hash.is_empty());
548        assert_eq!(provenance.hash.len(), 64); // blake3 hex is 64 chars
549        assert!(!provenance.loaded_at_iso.is_empty());
550    }
551
552    #[test]
553    fn test_model_provenance_unique_hash() {
554        let p1 = ModelProvenance::new(EmbeddingModel::BgeSmallEnV15, "model1".into());
555        std::thread::sleep(std::time::Duration::from_millis(10)); // Ensure different timestamp
556        let p2 = ModelProvenance::new(EmbeddingModel::BgeSmallEnV15, "model1".into());
557
558        // Different timestamps should produce different hashes
559        assert_ne!(p1.hash, p2.hash);
560    }
561
562    #[test]
563    fn test_model_provenance_dimensions() {
564        let p1 = ModelProvenance::new(EmbeddingModel::BgeSmallEnV15, "small".into());
565        assert_eq!(p1.dimensions(), 384);
566
567        let p2 = ModelProvenance::new(EmbeddingModel::BgeBaseEnV15, "base".into());
568        assert_eq!(p2.dimensions(), 768);
569
570        let p3 = ModelProvenance::new(EmbeddingModel::BgeLargeEnV15, "large".into());
571        assert_eq!(p3.dimensions(), 1024);
572    }
573
574    #[test]
575    fn test_model_provenance_matches_model() {
576        let provenance = ModelProvenance::new(EmbeddingModel::BgeSmallEnV15, "test".into());
577
578        assert!(provenance.matches_model(EmbeddingModel::BgeSmallEnV15));
579        assert!(!provenance.matches_model(EmbeddingModel::BgeBaseEnV15));
580        assert!(!provenance.matches_model(EmbeddingModel::BgeLargeEnV15));
581    }
582
583    #[test]
584    fn test_model_provenance_serialization() {
585        let provenance = ModelProvenance::new(EmbeddingModel::BgeSmallEnV15, "test-model".into());
586
587        let json = serde_json::to_string(&provenance).unwrap();
588        // FP-037: EmbeddingModel has #[serde(rename_all = "snake_case")] so
589        // BgeSmallEnV15 serializes as "bge_small_en_v15", not "BgeSmallEnV15".
590        assert!(json.contains("bge_small_en_v15"), "json={json}");
591        assert!(json.contains("test-model"));
592        assert!(json.contains(&provenance.hash));
593
594        let parsed: ModelProvenance = serde_json::from_str(&json).unwrap();
595        assert_eq!(parsed.model, provenance.model);
596        assert_eq!(parsed.model_id, provenance.model_id);
597        assert_eq!(parsed.hash, provenance.hash);
598    }
599
600    #[test]
601    fn test_dimensions() {
602        assert_eq!(EmbeddingModel::BgeSmallEnV15.dimensions(), 384);
603        assert_eq!(EmbeddingModel::BgeBaseEnV15.dimensions(), 768);
604        assert_eq!(EmbeddingModel::BgeLargeEnV15.dimensions(), 1024);
605        assert_eq!(EmbeddingModel::Qwen3Embedding4B.dimensions(), 2560);
606    }
607
608    #[test]
609    fn test_model_config_native_dims() {
610        assert_eq!(
611            ModelConfig::new(EmbeddingModel::Qwen3Embedding4B).dimensions(),
612            2560
613        );
614        assert_eq!(
615            ModelConfig::new(EmbeddingModel::Qwen3Embedding0_6B).dimensions(),
616            1024
617        );
618        assert_eq!(
619            ModelConfig::new(EmbeddingModel::BgeSmallEnV15).dimensions(),
620            384
621        );
622    }
623
624    #[test]
625    fn test_model_config_configured_dim() {
626        let cfg = ModelConfig::try_new(EmbeddingModel::Qwen3Embedding4B, Some(1024)).unwrap();
627        assert_eq!(cfg.dimensions(), 1024);
628
629        let cfg = ModelConfig::try_new(EmbeddingModel::Qwen3Embedding0_6B, Some(512)).unwrap();
630        assert_eq!(cfg.dimensions(), 512);
631    }
632
633    #[test]
634    fn test_model_config_validation_below_min() {
635        assert!(ModelConfig::try_new(EmbeddingModel::Qwen3Embedding4B, Some(31)).is_err());
636        assert!(ModelConfig::try_new(EmbeddingModel::Qwen3Embedding4B, Some(0)).is_err());
637    }
638
639    #[test]
640    fn test_model_config_validation_above_native() {
641        assert!(ModelConfig::try_new(EmbeddingModel::Qwen3Embedding4B, Some(2561)).is_err());
642        assert!(ModelConfig::try_new(EmbeddingModel::Qwen3Embedding0_6B, Some(1025)).is_err());
643    }
644
645    #[test]
646    fn test_model_config_validation_non_mrl_model() {
647        assert!(ModelConfig::try_new(EmbeddingModel::BgeSmallEnV15, Some(128)).is_err());
648        assert!(ModelConfig::try_new(EmbeddingModel::BgeBaseEnV15, Some(512)).is_err());
649    }
650
651    #[test]
652    fn test_model_config_none_output_dim_ok_for_any_model() {
653        assert!(ModelConfig::try_new(EmbeddingModel::BgeSmallEnV15, None).is_ok());
654        assert!(ModelConfig::try_new(EmbeddingModel::Qwen3Embedding4B, None).is_ok());
655    }
656
657    #[test]
658    fn test_is_local() {
659        assert!(EmbeddingModel::BgeSmallEnV15.is_local());
660        assert!(EmbeddingModel::BgeBaseEnV15.is_local());
661        assert!(EmbeddingModel::BgeLargeEnV15.is_local());
662    }
663
664    #[test]
665    fn test_display() {
666        assert_eq!(
667            EmbeddingModel::BgeSmallEnV15.to_string(),
668            "bge-small-en-v1.5"
669        );
670        assert_eq!(EmbeddingModel::BgeBaseEnV15.to_string(), "bge-base-en-v1.5");
671        assert_eq!(
672            EmbeddingModel::BgeLargeEnV15.to_string(),
673            "bge-large-en-v1.5"
674        );
675    }
676
677    #[test]
678    fn test_serialization_roundtrip() {
679        let model = EmbeddingModel::BgeSmallEnV15;
680        let json = serde_json::to_string(&model).unwrap();
681        let parsed: EmbeddingModel = serde_json::from_str(&json).unwrap();
682        assert_eq!(model, parsed);
683    }
684
685    #[test]
686    fn test_max_input_tokens() {
687        assert_eq!(EmbeddingModel::BgeSmallEnV15.max_input_tokens(), 512);
688        assert_eq!(EmbeddingModel::BgeBaseEnV15.max_input_tokens(), 512);
689        assert_eq!(EmbeddingModel::BgeLargeEnV15.max_input_tokens(), 512);
690    }
691
692    #[test]
693    fn test_from_str_display_names() {
694        assert_eq!(
695            "bge-small-en-v1.5".parse::<EmbeddingModel>().unwrap(),
696            EmbeddingModel::BgeSmallEnV15
697        );
698        assert_eq!(
699            "bge-base-en-v1.5".parse::<EmbeddingModel>().unwrap(),
700            EmbeddingModel::BgeBaseEnV15
701        );
702        assert_eq!(
703            "bge-large-en-v1.5".parse::<EmbeddingModel>().unwrap(),
704            EmbeddingModel::BgeLargeEnV15
705        );
706    }
707
708    #[test]
709    fn test_from_str_short_names() {
710        assert_eq!(
711            "small".parse::<EmbeddingModel>().unwrap(),
712            EmbeddingModel::BgeSmallEnV15
713        );
714        assert_eq!(
715            "bge-base".parse::<EmbeddingModel>().unwrap(),
716            EmbeddingModel::BgeBaseEnV15
717        );
718        assert_eq!(
719            "LARGE".parse::<EmbeddingModel>().unwrap(), // case insensitive
720            EmbeddingModel::BgeLargeEnV15
721        );
722    }
723
724    #[test]
725    fn test_from_str_huggingface_ids() {
726        assert_eq!(
727            "BAAI/bge-small-en-v1.5".parse::<EmbeddingModel>().unwrap(),
728            EmbeddingModel::BgeSmallEnV15
729        );
730    }
731
732    #[test]
733    fn test_from_str_invalid() {
734        let result = "unknown-model".parse::<EmbeddingModel>();
735        assert!(result.is_err());
736        assert!(result.unwrap_err().contains("unknown embedding model"));
737    }
738
739    // -------------------------------------------------------------------------
740    // bert_pooling() routing tests (P1-E3) — require `native` feature
741    // -------------------------------------------------------------------------
742
743    /// BGE small/base/large must use CLS pooling per their HF model cards.
744    #[cfg(feature = "native")]
745    #[test]
746    fn test_bge_models_use_cls_pooling() {
747        use lattice_inference::BertPooling;
748
749        assert_eq!(
750            EmbeddingModel::BgeSmallEnV15.bert_pooling(),
751            Some(BertPooling::CLS),
752            "BgeSmallEnV15 must use CLS pooling"
753        );
754        assert_eq!(
755            EmbeddingModel::BgeBaseEnV15.bert_pooling(),
756            Some(BertPooling::CLS),
757            "BgeBaseEnV15 must use CLS pooling"
758        );
759        assert_eq!(
760            EmbeddingModel::BgeLargeEnV15.bert_pooling(),
761            Some(BertPooling::CLS),
762            "BgeLargeEnV15 must use CLS pooling"
763        );
764    }
765
766    /// E5 models must use mean pooling per their HF model cards.
767    #[cfg(feature = "native")]
768    #[test]
769    fn test_e5_models_use_mean_pooling() {
770        use lattice_inference::BertPooling;
771
772        assert_eq!(
773            EmbeddingModel::MultilingualE5Small.bert_pooling(),
774            Some(BertPooling::Mean),
775            "MultilingualE5Small must use mean pooling"
776        );
777        assert_eq!(
778            EmbeddingModel::MultilingualE5Base.bert_pooling(),
779            Some(BertPooling::Mean),
780            "MultilingualE5Base must use mean pooling"
781        );
782    }
783
784    /// MiniLM models must use mean pooling per sentence-transformers convention.
785    #[cfg(feature = "native")]
786    #[test]
787    fn test_minilm_models_use_mean_pooling() {
788        use lattice_inference::BertPooling;
789
790        assert_eq!(
791            EmbeddingModel::AllMiniLmL6V2.bert_pooling(),
792            Some(BertPooling::Mean),
793            "AllMiniLmL6V2 must use mean pooling"
794        );
795        assert_eq!(
796            EmbeddingModel::ParaphraseMultilingualMiniLmL12V2.bert_pooling(),
797            Some(BertPooling::Mean),
798            "ParaphraseMultilingualMiniLmL12V2 must use mean pooling"
799        );
800    }
801
802    /// Qwen and remote models return None — they have separate pooling paths.
803    #[cfg(feature = "native")]
804    #[test]
805    fn test_non_bert_models_return_none_pooling() {
806        assert_eq!(
807            EmbeddingModel::Qwen3Embedding0_6B.bert_pooling(),
808            None,
809            "Qwen model must return None for bert_pooling()"
810        );
811        assert_eq!(
812            EmbeddingModel::Qwen3Embedding4B.bert_pooling(),
813            None,
814            "Qwen model must return None for bert_pooling()"
815        );
816        assert_eq!(
817            EmbeddingModel::TextEmbedding3Small.bert_pooling(),
818            None,
819            "Remote model must return None for bert_pooling()"
820        );
821    }
822
823    /// BGE and E5 use DIFFERENT pooling strategies — this is the key correctness distinction.
824    #[cfg(feature = "native")]
825    #[test]
826    fn test_bge_and_e5_use_different_pooling() {
827        assert_ne!(
828            EmbeddingModel::BgeSmallEnV15.bert_pooling(),
829            EmbeddingModel::MultilingualE5Small.bert_pooling(),
830            "BGE and E5 must use different pooling strategies"
831        );
832    }
833}