Skip to main content

clawft_kernel/
embedding.rs

1//! Pluggable embedding backends for ECC vector operations (K3c-G2).
2//!
3//! Provides the [`EmbeddingProvider`] trait that the [`WeaverEngine`] uses
4//! to convert text into vector embeddings for HNSW storage and similarity
5//! search. Ships with [`MockEmbeddingProvider`] for deterministic testing.
6
7use std::fmt;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12
13// ---------------------------------------------------------------------------
14// EmbeddingError
15// ---------------------------------------------------------------------------
16
17/// Errors that embedding backends may produce.
18#[non_exhaustive]
19#[derive(Debug)]
20pub enum EmbeddingError {
21    /// The underlying model has not been loaded yet.
22    ModelNotLoaded,
23    /// Vector dimensionality does not match the expected value.
24    DimensionMismatch {
25        /// Expected dimensionality.
26        expected: usize,
27        /// Actual dimensionality returned.
28        got: usize,
29    },
30    /// Generic backend failure.
31    BackendError(String),
32    /// Rate-limited; caller should retry after the given duration.
33    RateLimited {
34        /// How long to wait before retrying.
35        retry_after: Duration,
36    },
37}
38
39impl fmt::Display for EmbeddingError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::ModelNotLoaded => write!(f, "embedding model not loaded"),
43            Self::DimensionMismatch { expected, got } => {
44                write!(f, "dimension mismatch: expected {expected}, got {got}")
45            }
46            Self::BackendError(msg) => write!(f, "embedding backend error: {msg}"),
47            Self::RateLimited { retry_after } => {
48                write!(f, "rate limited, retry after {}ms", retry_after.as_millis())
49            }
50        }
51    }
52}
53
54impl std::error::Error for EmbeddingError {}
55
56// ---------------------------------------------------------------------------
57// EmbeddingProvider trait
58// ---------------------------------------------------------------------------
59
60/// Trait for pluggable embedding backends.
61///
62/// Implementations convert text into fixed-dimensionality float vectors
63/// suitable for HNSW indexing and cosine similarity search.
64#[async_trait]
65pub trait EmbeddingProvider: Send + Sync {
66    /// Embed a single text chunk into a vector.
67    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError>;
68
69    /// Embed a batch of text chunks.
70    ///
71    /// The default implementation calls [`embed`](Self::embed) in a loop.
72    /// Backends that support native batching should override this for
73    /// efficiency.
74    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
75        let mut results = Vec::with_capacity(texts.len());
76        for text in texts {
77            results.push(self.embed(text).await?);
78        }
79        Ok(results)
80    }
81
82    /// Dimensionality of the output vectors.
83    fn dimensions(&self) -> usize;
84
85    /// Name of the embedding model (for metadata tracking).
86    fn model_name(&self) -> &str;
87
88    /// Embed a **query** (asymmetric retrieval lane).
89    ///
90    /// Default = [`embed`](Self::embed) (symmetric backends: Mock, MiniLM, LLM).
91    /// Providers with asymmetric training (e5 `query:` / `passage:`, Qwen3
92    /// instruction prefix) **must** override so graft/search queries land in the
93    /// correct half of the space (WEFT-640 / ADR-059).
94    async fn embed_query(&self, query: &str) -> Result<Vec<f32>, EmbeddingError> {
95        self.embed(query).await
96    }
97
98    /// Pre-warm the backend: run one throwaway [`embed`](Self::embed) so the
99    /// model graph, runtime thread pool, and allocator are hot before the first
100    /// real call. Best-effort — errors are swallowed (a backend with nothing to
101    /// warm, like the Mock, just pays one cheap call). Intended to be called
102    /// once at daemon startup so the first conversation turn doesn't pay the
103    /// model's first-inference cost (ADR-058 Phase 5.3; mirrors the 6.2 STT
104    /// pre-warm).
105    async fn warm(&self) {
106        let _ = self.embed("warm").await;
107    }
108}
109
110// ---------------------------------------------------------------------------
111// MockEmbeddingProvider
112// ---------------------------------------------------------------------------
113
114/// Deterministic embedding provider for testing.
115///
116/// Produces vectors derived from a SHA-256 hash of the input text,
117/// ensuring reproducible results without any external model dependency.
118pub struct MockEmbeddingProvider {
119    /// Output vector dimensionality.
120    pub dims: usize,
121}
122
123impl MockEmbeddingProvider {
124    /// Create a mock provider with the given output dimensionality.
125    pub fn new(dims: usize) -> Self {
126        Self { dims }
127    }
128
129    /// Deterministic hash-based embedding generation.
130    fn hash_embed(&self, text: &str) -> Vec<f32> {
131        use sha2::{Digest, Sha256};
132        let mut hasher = Sha256::new();
133        hasher.update(text.as_bytes());
134        let hash = hasher.finalize();
135
136        let mut vec = Vec::with_capacity(self.dims);
137        for i in 0..self.dims {
138            // Cycle through hash bytes, normalise to [-1, 1]
139            let byte = hash[i % 32];
140            vec.push((byte as f32 / 128.0) - 1.0);
141        }
142        vec
143    }
144}
145
146#[async_trait]
147impl EmbeddingProvider for MockEmbeddingProvider {
148    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
149        Ok(self.hash_embed(text))
150    }
151
152    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
153        Ok(texts.iter().map(|t| self.hash_embed(t)).collect())
154    }
155
156    fn dimensions(&self) -> usize {
157        self.dims
158    }
159
160    fn model_name(&self) -> &str {
161        "mock-sha256"
162    }
163}
164
165// ---------------------------------------------------------------------------
166// LlmEmbeddingProvider
167// ---------------------------------------------------------------------------
168
169/// Configuration for the LLM API embedding backend.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct LlmEmbeddingConfig {
172    /// Model identifier (e.g., "text-embedding-3-small").
173    pub model: String,
174    /// Output vector dimensionality (e.g., 384 or 1536).
175    pub dimensions: usize,
176    /// Maximum texts per API call for batching.
177    pub batch_size: usize,
178    /// Whether the API is currently available.
179    pub api_available: bool,
180}
181
182impl Default for LlmEmbeddingConfig {
183    fn default() -> Self {
184        Self {
185            model: "text-embedding-3-small".to_string(),
186            dimensions: 384,
187            batch_size: 16,
188            api_available: false,
189        }
190    }
191}
192
193/// LLM-backed embedding provider that calls the clawft-llm provider layer.
194///
195/// Uses the model's embedding endpoint to produce real semantic vectors.
196/// When no API is configured (or the API is unavailable), falls back to
197/// [`MockEmbeddingProvider`] for deterministic hash-based embeddings.
198pub struct LlmEmbeddingProvider {
199    config: LlmEmbeddingConfig,
200    fallback: MockEmbeddingProvider,
201}
202
203impl LlmEmbeddingProvider {
204    /// Create a new LLM embedding provider with the given configuration.
205    pub fn new(config: LlmEmbeddingConfig) -> Self {
206        let fallback = MockEmbeddingProvider::new(config.dimensions);
207        Self { config, fallback }
208    }
209
210    /// Create from a weave.toml-style configuration table.
211    ///
212    /// Expected keys: `model` (string), `dimensions` (int), `batch_size` (int).
213    /// If the table is missing or incomplete, returns a provider with defaults
214    /// that falls back to mock embeddings.
215    pub fn from_config(table: &std::collections::HashMap<String, String>) -> Self {
216        let model = table
217            .get("model")
218            .cloned()
219            .unwrap_or_else(|| "text-embedding-3-small".to_string());
220        let dimensions = table
221            .get("dimensions")
222            .and_then(|d| d.parse::<usize>().ok())
223            .unwrap_or(384);
224        let batch_size = table
225            .get("batch_size")
226            .and_then(|b| b.parse::<usize>().ok())
227            .unwrap_or(16);
228        let api_available = table
229            .get("api_available")
230            .map(|v| v == "true")
231            .unwrap_or(false);
232
233        Self::new(LlmEmbeddingConfig {
234            model,
235            dimensions,
236            batch_size,
237            api_available,
238        })
239    }
240
241    /// Whether the LLM API is available (non-fallback mode).
242    pub fn is_api_available(&self) -> bool {
243        self.config.api_available
244    }
245
246    /// Get the underlying configuration.
247    pub fn config(&self) -> &LlmEmbeddingConfig {
248        &self.config
249    }
250
251    /// Perform an LLM API embedding call.
252    ///
253    /// In a production deployment this would call the clawft-llm provider's
254    /// embed endpoint. Currently returns an error so that the `embed()` method
255    /// falls back to the mock provider.
256    async fn call_llm_api(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
257        if !self.config.api_available {
258            return Err(EmbeddingError::BackendError(
259                "LLM API not configured; using fallback".to_string(),
260            ));
261        }
262        // Production implementation would call:
263        //   provider.embed(EmbedRequest { model, input: vec![text], dimensions })
264        // For now, the API path is gated behind api_available.
265        Err(EmbeddingError::BackendError(
266            "LLM API call not yet wired to clawft-llm provider".to_string(),
267        ))
268    }
269
270    /// Perform a batched LLM API embedding call.
271    async fn call_llm_api_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
272        if !self.config.api_available {
273            return Err(EmbeddingError::BackendError(
274                "LLM API not configured; using fallback".to_string(),
275            ));
276        }
277        Err(EmbeddingError::BackendError(
278            "LLM API batch call not yet wired to clawft-llm provider".to_string(),
279        ))
280    }
281}
282
283#[async_trait]
284impl EmbeddingProvider for LlmEmbeddingProvider {
285    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
286        // Try the LLM API first; fall back to mock on any error.
287        match self.call_llm_api(text).await {
288            Ok(vec) => {
289                if vec.len() != self.config.dimensions {
290                    return Err(EmbeddingError::DimensionMismatch {
291                        expected: self.config.dimensions,
292                        got: vec.len(),
293                    });
294                }
295                Ok(vec)
296            }
297            Err(_) => self.fallback.embed(text).await,
298        }
299    }
300
301    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
302        // Try the LLM API batch endpoint; fall back to mock.
303        match self.call_llm_api_batch(texts).await {
304            Ok(vecs) => {
305                for v in &vecs {
306                    if v.len() != self.config.dimensions {
307                        return Err(EmbeddingError::DimensionMismatch {
308                            expected: self.config.dimensions,
309                            got: v.len(),
310                        });
311                    }
312                }
313                Ok(vecs)
314            }
315            Err(_) => self.fallback.embed_batch(texts).await,
316        }
317    }
318
319    fn dimensions(&self) -> usize {
320        self.config.dimensions
321    }
322
323    fn model_name(&self) -> &str {
324        &self.config.model
325    }
326}
327
328// ---------------------------------------------------------------------------
329// select_embedding_provider
330// ---------------------------------------------------------------------------
331
332/// Select the best available embedding provider based on configuration.
333///
334/// Priority order (ADR-059 + WEFT-640):
335/// 1. **Qwen3-Embedding-0.6B** — if `model_fp16.onnx` + `tokenizer.json` are
336///    present and the `onnx-embeddings` runtime loads them (project-canonical
337///    long-context semantic embedder: 32K ctx, MRL-512).
338/// 2. **e5-small-v2** ONNX — 384-d MIT BERT-family encoder (matches RVF/HNSW
339///    dim; lightweights CI/dev when staged under `.weftos/models/e5-small-v2/`).
340/// 3. **all-MiniLM-L6-v2** ONNX — the legacy BERT encoder fallback.
341/// 4. **LLM API** — if an `llm_embedding` config is present.
342/// 5. **Mock** — graceful fallback for tests / when no model is available.
343///
344/// Selection only promotes a model-backed provider when its runtime is actually
345/// live, so without the `onnx-embeddings` feature (or the artifacts) this always
346/// degrades cleanly to LLM or Mock. Model downloads are **not** required for
347/// CI — real inference is gated on staged artifacts + the `onnx-embeddings`
348/// feature.
349pub fn select_embedding_provider(
350    llm_config: Option<LlmEmbeddingConfig>,
351) -> Box<dyn EmbeddingProvider> {
352    // 1. Qwen3 (preferred / project-canonical): needs model_fp16.onnx + tokenizer.json.
353    for dir in qwen3_model_search_dirs() {
354        let model = dir.join("model_fp16.onnx");
355        let tokenizer = dir.join("tokenizer.json");
356        if model.exists() && tokenizer.exists() {
357            let provider = crate::embedding_qwen3::Qwen3EmbeddingProvider::new(&model, &tokenizer);
358            if provider.is_runtime_available() {
359                tracing::info!("Using Qwen3 embedding provider from {}", dir.display());
360                return Box::new(provider);
361            }
362        }
363    }
364
365    // 2. e5-small-v2 (WEFT-640): 384-d, query:/passage: asymmetric prefixes.
366    for path in &e5_model_search_paths() {
367        if path.exists() {
368            let provider = crate::embedding_e5::E5EmbeddingProvider::new(path);
369            if provider.is_runtime_available() {
370                tracing::info!("Using e5-small-v2 embedding provider from {}", path.display());
371                return Box::new(provider);
372            }
373        }
374    }
375
376    // 3. all-MiniLM-L6-v2 ONNX (legacy BERT fallback).
377    for path in &onnx_model_search_paths() {
378        if path.exists() {
379            let provider = crate::embedding_onnx::OnnxEmbeddingProvider::new(path);
380            if provider.is_runtime_available() {
381                tracing::info!("Using ONNX embedding provider from {}", path.display());
382                return Box::new(provider);
383            }
384        }
385    }
386
387    // 4. LLM API.
388    if let Some(config) = llm_config {
389        return Box::new(LlmEmbeddingProvider::new(config));
390    }
391
392    // 5. Mock.
393    Box::new(MockEmbeddingProvider::new(64))
394}
395
396/// Standard search directories for the Qwen3 model bundle (each expected to hold
397/// `model_fp16.onnx` + `tokenizer.json`):
398/// 1. `.weftos/models/Qwen3-Embedding-0.6B/` (project-local)
399/// 2. `$HOME/.weftos/models/Qwen3-Embedding-0.6B/` (user-global)
400/// 3. `$WEFTOS_MODEL_PATH` (as a models-root and as the bundle dir directly)
401fn qwen3_model_search_dirs() -> Vec<std::path::PathBuf> {
402    let subdir = "Qwen3-Embedding-0.6B";
403    let mut dirs = Vec::new();
404
405    dirs.push(std::path::PathBuf::from(format!(".weftos/models/{subdir}")));
406
407    if let Some(home) = dirs_home() {
408        dirs.push(home.join(format!(".weftos/models/{subdir}")));
409    }
410
411    if let Ok(env_path) = std::env::var("WEFTOS_MODEL_PATH") {
412        let p = std::path::PathBuf::from(env_path);
413        dirs.push(p.join(subdir));
414        dirs.push(p);
415    }
416
417    dirs
418}
419
420/// Standard search paths for the ONNX embedding model.
421///
422/// Looks in (in order):
423/// 1. `.weftos/models/all-MiniLM-L6-v2.onnx` (project-local)
424/// 2. `$HOME/.weftos/models/all-MiniLM-L6-v2.onnx` (user-global)
425/// 3. `$WEFTOS_MODEL_PATH` environment variable
426fn onnx_model_search_paths() -> Vec<std::path::PathBuf> {
427    let model_name = "all-MiniLM-L6-v2.onnx";
428    let mut paths = Vec::new();
429
430    // Project-local.
431    paths.push(std::path::PathBuf::from(format!(
432        ".weftos/models/{model_name}"
433    )));
434
435    // User-global.
436    if let Some(home) = dirs_home() {
437        paths.push(home.join(format!(".weftos/models/{model_name}")));
438    }
439
440    // Env override.
441    if let Ok(env_path) = std::env::var("WEFTOS_MODEL_PATH") {
442        paths.push(std::path::PathBuf::from(env_path));
443    }
444
445    paths
446}
447
448/// Search paths for the e5-small-v2 ONNX bundle (WEFT-640).
449///
450/// Each entry is a candidate **model file** path. Staging layout:
451/// - `.weftos/models/e5-small-v2/model.onnx` (or `model_int8.onnx`)
452/// - sibling `vocab.txt` for WordPiece (reuses MiniLM/BERT uncased vocab)
453///
454/// Order: project-local → user-global → `$WEFTOS_MODEL_PATH` (as models root
455/// and as a direct file path).
456fn e5_model_search_paths() -> Vec<std::path::PathBuf> {
457    let subdir = "e5-small-v2";
458    let files = ["model.onnx", "model_int8.onnx", "model_fp16.onnx"];
459    let mut paths = Vec::new();
460
461    let mut push_dir = |dir: std::path::PathBuf| {
462        for f in files {
463            paths.push(dir.join(f));
464        }
465    };
466
467    push_dir(std::path::PathBuf::from(format!(".weftos/models/{subdir}")));
468    if let Some(home) = dirs_home() {
469        push_dir(home.join(format!(".weftos/models/{subdir}")));
470    }
471    if let Ok(env_path) = std::env::var("WEFTOS_MODEL_PATH") {
472        let p = std::path::PathBuf::from(env_path);
473        push_dir(p.join(subdir));
474        // Direct file path override.
475        paths.push(p.clone());
476        for f in files {
477            paths.push(p.join(f));
478        }
479    }
480
481    paths
482}
483
484/// Get the user's home directory.
485fn dirs_home() -> Option<std::path::PathBuf> {
486    std::env::var("HOME").ok().map(std::path::PathBuf::from)
487}
488
489// ── Tests ─────────────────────────────────────────────────────────────────
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[tokio::test]
496    async fn mock_embed_returns_correct_dimensions() {
497        let provider = MockEmbeddingProvider::new(64);
498        let vec = provider.embed("hello world").await.unwrap();
499        assert_eq!(vec.len(), 64);
500    }
501
502    #[tokio::test]
503    async fn mock_embed_deterministic() {
504        let provider = MockEmbeddingProvider::new(32);
505        let v1 = provider.embed("test input").await.unwrap();
506        let v2 = provider.embed("test input").await.unwrap();
507        assert_eq!(v1, v2);
508    }
509
510    #[tokio::test]
511    async fn mock_embed_different_inputs_differ() {
512        let provider = MockEmbeddingProvider::new(32);
513        let v1 = provider.embed("alpha").await.unwrap();
514        let v2 = provider.embed("beta").await.unwrap();
515        assert_ne!(v1, v2);
516    }
517
518    #[tokio::test]
519    async fn mock_embed_batch() {
520        let provider = MockEmbeddingProvider::new(16);
521        let results = provider.embed_batch(&["a", "b", "c"]).await.unwrap();
522        assert_eq!(results.len(), 3);
523        for v in &results {
524            assert_eq!(v.len(), 16);
525        }
526    }
527
528    #[tokio::test]
529    async fn mock_embed_batch_matches_individual() {
530        let provider = MockEmbeddingProvider::new(8);
531        let batch = provider.embed_batch(&["x", "y"]).await.unwrap();
532        let x = provider.embed("x").await.unwrap();
533        let y = provider.embed("y").await.unwrap();
534        assert_eq!(batch[0], x);
535        assert_eq!(batch[1], y);
536    }
537
538    #[test]
539    fn mock_model_name() {
540        let provider = MockEmbeddingProvider::new(16);
541        assert_eq!(provider.model_name(), "mock-sha256");
542    }
543
544    #[test]
545    fn mock_dimensions() {
546        let provider = MockEmbeddingProvider::new(128);
547        assert_eq!(provider.dimensions(), 128);
548    }
549
550    #[test]
551    fn embedding_error_display() {
552        let err = EmbeddingError::DimensionMismatch {
553            expected: 384,
554            got: 256,
555        };
556        assert!(err.to_string().contains("384"));
557        assert!(err.to_string().contains("256"));
558
559        let err2 = EmbeddingError::ModelNotLoaded;
560        assert!(err2.to_string().contains("not loaded"));
561    }
562
563    // ── LlmEmbeddingProvider tests ───────────────────────────────────
564
565    #[tokio::test]
566    async fn llm_provider_falls_back_to_mock_when_api_unavailable() {
567        let config = LlmEmbeddingConfig {
568            api_available: false,
569            dimensions: 64,
570            ..Default::default()
571        };
572        let provider = LlmEmbeddingProvider::new(config);
573        // Should succeed via fallback, not error.
574        let vec = provider.embed("hello world").await.unwrap();
575        assert_eq!(vec.len(), 64);
576    }
577
578    #[tokio::test]
579    async fn llm_provider_fallback_is_deterministic() {
580        let config = LlmEmbeddingConfig {
581            api_available: false,
582            dimensions: 32,
583            ..Default::default()
584        };
585        let provider = LlmEmbeddingProvider::new(config);
586        let v1 = provider.embed("test").await.unwrap();
587        let v2 = provider.embed("test").await.unwrap();
588        assert_eq!(v1, v2);
589    }
590
591    #[tokio::test]
592    async fn llm_provider_batch_fallback() {
593        let config = LlmEmbeddingConfig {
594            api_available: false,
595            dimensions: 16,
596            ..Default::default()
597        };
598        let provider = LlmEmbeddingProvider::new(config);
599        let results = provider.embed_batch(&["a", "b", "c"]).await.unwrap();
600        assert_eq!(results.len(), 3);
601        for v in &results {
602            assert_eq!(v.len(), 16);
603        }
604    }
605
606    #[test]
607    fn llm_provider_reports_model_name() {
608        let config = LlmEmbeddingConfig {
609            model: "custom-embed-v1".to_string(),
610            ..Default::default()
611        };
612        let provider = LlmEmbeddingProvider::new(config);
613        assert_eq!(provider.model_name(), "custom-embed-v1");
614    }
615
616    #[test]
617    fn llm_provider_reports_dimensions() {
618        let config = LlmEmbeddingConfig {
619            dimensions: 1536,
620            ..Default::default()
621        };
622        let provider = LlmEmbeddingProvider::new(config);
623        assert_eq!(provider.dimensions(), 1536);
624    }
625
626    #[test]
627    fn llm_provider_api_availability_check() {
628        let unavailable = LlmEmbeddingProvider::new(LlmEmbeddingConfig::default());
629        assert!(!unavailable.is_api_available());
630
631        let available = LlmEmbeddingProvider::new(LlmEmbeddingConfig {
632            api_available: true,
633            ..Default::default()
634        });
635        assert!(available.is_api_available());
636    }
637
638    #[test]
639    fn llm_provider_from_config_defaults() {
640        let table = std::collections::HashMap::new();
641        let provider = LlmEmbeddingProvider::from_config(&table);
642        assert_eq!(provider.dimensions(), 384);
643        assert_eq!(provider.model_name(), "text-embedding-3-small");
644        assert!(!provider.is_api_available());
645    }
646
647    #[test]
648    fn llm_provider_from_config_custom() {
649        let mut table = std::collections::HashMap::new();
650        table.insert("model".to_string(), "my-model".to_string());
651        table.insert("dimensions".to_string(), "768".to_string());
652        table.insert("batch_size".to_string(), "32".to_string());
653        table.insert("api_available".to_string(), "true".to_string());
654        let provider = LlmEmbeddingProvider::from_config(&table);
655        assert_eq!(provider.model_name(), "my-model");
656        assert_eq!(provider.dimensions(), 768);
657        assert_eq!(provider.config().batch_size, 32);
658        assert!(provider.is_api_available());
659    }
660
661    #[test]
662    fn select_provider_returns_mock_when_no_config() {
663        let provider = select_embedding_provider(None);
664        assert_eq!(provider.dimensions(), 64);
665        assert_eq!(provider.model_name(), "mock-sha256");
666    }
667
668    #[test]
669    fn qwen3_search_dirs_include_project_local_bundle() {
670        let dirs = qwen3_model_search_dirs();
671        assert!(
672            dirs.iter()
673                .any(|d| d.ends_with(".weftos/models/Qwen3-Embedding-0.6B")),
674            "expected project-local Qwen3 bundle dir, got: {dirs:?}"
675        );
676    }
677
678    #[test]
679    fn e5_search_paths_include_project_local_model() {
680        let paths = e5_model_search_paths();
681        assert!(
682            paths
683                .iter()
684                .any(|p| p.ends_with(".weftos/models/e5-small-v2/model.onnx")),
685            "expected project-local e5 model path, got: {paths:?}"
686        );
687    }
688
689    #[test]
690    fn select_provider_priorities_skip_unavailable_qwen3() {
691        // No model artifacts present in the test cwd → Qwen3/e5/MiniLM runtimes
692        // are not live, so selection must fall through to Mock when no LLM config.
693        let provider = select_embedding_provider(None);
694        assert_eq!(provider.model_name(), "mock-sha256");
695    }
696
697    #[tokio::test]
698    async fn mock_embed_query_defaults_to_embed() {
699        let provider = MockEmbeddingProvider::new(16);
700        let a = provider.embed("hello").await.unwrap();
701        let b = provider.embed_query("hello").await.unwrap();
702        assert_eq!(a, b);
703    }
704
705    #[test]
706    fn select_provider_returns_llm_when_config_present() {
707        let config = LlmEmbeddingConfig {
708            model: "test-embed".to_string(),
709            dimensions: 256,
710            ..Default::default()
711        };
712        let provider = select_embedding_provider(Some(config));
713        assert_eq!(provider.dimensions(), 256);
714        assert_eq!(provider.model_name(), "test-embed");
715    }
716
717    #[tokio::test]
718    async fn llm_provider_fallback_matches_mock() {
719        let config = LlmEmbeddingConfig {
720            api_available: false,
721            dimensions: 32,
722            ..Default::default()
723        };
724        let llm = LlmEmbeddingProvider::new(config);
725        let mock = MockEmbeddingProvider::new(32);
726        let llm_vec = llm.embed("same input").await.unwrap();
727        let mock_vec = mock.embed("same input").await.unwrap();
728        // Fallback should produce identical results to mock.
729        assert_eq!(llm_vec, mock_vec);
730    }
731}