Skip to main content

wm_memory/
embedder.rs

1//! Local text embedder — generates vector embeddings for semantic search.
2//!
3//! Ported from v2's `local_embedder.py` (313 lines). Provides a trait-based
4//! embedder abstraction with two implementations:
5//!
6//! - `HttpEmbedder`: Calls llama-server's `/v1/embeddings` endpoint (preferred,
7//!   no model download needed — uses the already-running llama-server)
8//! - `StubEmbedder`: Hash-based pseudo-embeddings for testing/fallback
9//!
10//! Future: An `ort` (ONNX Runtime) embedder can implement the same trait for
11//! fully local embeddings without a server dependency.
12//!
13//! # Environment Variables
14//!
15//! | Variable | Default | Description |
16//! |----------|---------|-------------|
17//! | `WM_EMBEDDER_ENDPOINT` | — | llama-server HTTP URL (e.g. `http://localhost:8080`) |
18//! | `WM_EMBEDDER_MODEL` | `local` | Model name for the embeddings API |
19//! | `WM_EMBEDDER_DIM` | `384` | Expected embedding dimensionality |
20//! | `WM_EMBEDDER_TIMEOUT_MS` | `30000` | Request timeout in milliseconds |
21
22#![allow(clippy::cast_possible_wrap)]
23
24use serde::{Deserialize, Serialize};
25use std::time::Duration;
26use wm_core::{CoreError, Result};
27
28/// Trait for text embedding providers.
29///
30/// Implementations:
31/// - `HttpEmbedder` — llama-server `/v1/embeddings` endpoint
32/// - `StubEmbedder` — hash-based pseudo-embeddings (for testing)
33pub trait Embedder: Send + Sync {
34    /// Embed a batch of texts into f32 vectors.
35    ///
36    /// Returns one vector per input text. All vectors have the same dimensionality.
37    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
38
39    /// Embed a single text.
40    fn embed(&self, text: &str) -> Result<Vec<f32>> {
41        self.embed_batch(&[text])?
42            .into_iter()
43            .next()
44            .ok_or_else(|| CoreError::Memory("embedder returned empty result".into()))
45    }
46
47    /// Embed a single query (alias for `embed`).
48    fn embed_query(&self, query: &str) -> Result<Vec<f32>> {
49        self.embed(query)
50    }
51
52    /// Get the embedding dimensionality.
53    fn dimension(&self) -> usize;
54
55    /// Whether this embedder is available (model loaded, server reachable).
56    fn is_available(&self) -> bool;
57
58    /// Name of this embedder backend.
59    fn backend_name(&self) -> &'static str;
60
61    /// Identity used to namespace the persistent embedding cache.
62    ///
63    /// Vectors for identical text differ across models, quantization, and
64    /// endpoints, so the cache key must carry this — switching models must
65    /// never serve stale vectors. Default is the backend name.
66    fn cache_namespace(&self) -> String {
67        self.backend_name().to_string()
68    }
69
70    /// Preferred batch granularity in TEXTS for batch write paths.
71    ///
72    /// The write path's char-chunking exists for HTTP token limits; local
73    /// engines have no such limit and want bigger batches so session
74    /// pools fan out efficiently (4-7 texts per call starves each shard).
75    /// `usize::MAX` = char-chunking only (the HTTP shape).
76    fn preferred_max_batch_texts(&self) -> usize {
77        usize::MAX
78    }
79}
80
81/// Configuration for the HTTP-based embedder.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct EmbedderConfig {
84    /// llama-server HTTP API URL (e.g. `http://localhost:8080`).
85    pub endpoint: String,
86    /// Model name for the embeddings API.
87    pub model: String,
88    /// Expected embedding dimensionality.
89    pub dimension: usize,
90    /// Request timeout.
91    pub timeout: Duration,
92}
93
94impl EmbedderConfig {
95    /// Create a config from environment variables.
96    ///
97    /// Returns `None` if `WM_EMBEDDER_ENDPOINT` is not set or if the endpoint
98    /// fails SSRF validation (non-HTTP scheme, metadata endpoint, etc.).
99    #[must_use]
100    pub fn from_env() -> Option<Self> {
101        let endpoint = std::env::var("WM_EMBEDDER_ENDPOINT").ok()?;
102        if !is_endpoint_safe(&endpoint) {
103            tracing::warn!(
104                "embedder endpoint rejected by SSRF validation: {}",
105                endpoint
106            );
107            return None;
108        }
109        let model = std::env::var("WM_EMBEDDER_MODEL").unwrap_or_else(|_| "local".into());
110        let dimension = std::env::var("WM_EMBEDDER_DIM")
111            .ok()
112            .and_then(|v| v.parse::<usize>().ok())
113            .unwrap_or(384);
114        let timeout_ms = std::env::var("WM_EMBEDDER_TIMEOUT_MS")
115            .ok()
116            .and_then(|v| v.parse::<u64>().ok())
117            .unwrap_or(30_000);
118
119        Some(Self {
120            endpoint,
121            model,
122            dimension,
123            timeout: Duration::from_millis(timeout_ms),
124        })
125    }
126}
127
128/// Validate an embedder endpoint URL for SSRF safety.
129///
130/// Unlike `wm_core::security::is_url_safe`, this allows localhost and private
131/// IPs because the embedder is typically a local llama-server. It blocks:
132/// - Non-HTTP(S) schemes (file://, gopher://, ftp://, etc.)
133/// - Cloud metadata endpoints (169.254.169.254, metadata.google.internal)
134/// - Malformed URLs
135#[must_use]
136pub fn is_endpoint_safe(endpoint: &str) -> bool {
137    // Must start with http:// or https://
138    if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
139        return false;
140    }
141
142    // Extract host
143    let without_scheme = endpoint
144        .strip_prefix("http://")
145        .or_else(|| endpoint.strip_prefix("https://"))
146        .unwrap_or(endpoint);
147
148    let host_end = without_scheme
149        .find(['/', '?', '#'])
150        .unwrap_or(without_scheme.len());
151    let host_port = &without_scheme[..host_end];
152
153    // Handle IPv6 bracket notation
154    let host = if host_port.starts_with('[') {
155        if let Some(end) = host_port.find(']') {
156            &host_port[1..end]
157        } else {
158            return false; // Malformed IPv6
159        }
160    } else {
161        host_port.rsplit_once(':').map_or(host_port, |(h, _)| h)
162    };
163
164    if host.is_empty() {
165        return false;
166    }
167
168    // Block cloud metadata endpoints
169    let lower = host.to_ascii_lowercase();
170    if matches!(
171        lower.as_str(),
172        "metadata.google.internal"
173            | "metadata.aws.internal"
174            | "metadata"
175            | "169.254.169.254"
176            | "169.254.170.2"
177    ) {
178        return false;
179    }
180
181    true
182}
183
184/// HTTP-based embedder using llama-server's `/v1/embeddings` endpoint.
185///
186/// Requires llama-server started with `--embeddings` flag.
187/// Dimension depends on the loaded GGUF model (e.g. 384 for bge-small,
188/// 768 for bge-base, 1024 for bge-large).
189pub struct HttpEmbedder {
190    config: EmbedderConfig,
191    agent: ureq::Agent,
192    available: bool,
193}
194
195impl HttpEmbedder {
196    /// Create a new HTTP embedder with the given config.
197    #[must_use]
198    pub fn new(config: EmbedderConfig) -> Self {
199        let agent = ureq::config::Config::builder()
200            .timeout_global(Some(config.timeout))
201            .build()
202            .new_agent();
203        Self {
204            config,
205            agent,
206            available: true,
207        }
208    }
209
210    /// Create from environment variables, if configured.
211    #[must_use]
212    pub fn from_env() -> Option<Self> {
213        EmbedderConfig::from_env().map(Self::new)
214    }
215
216    /// Build the embeddings endpoint URL.
217    fn embeddings_url(&self) -> String {
218        if self.config.endpoint.ends_with("/v1/embeddings") {
219            self.config.endpoint.clone()
220        } else if self.config.endpoint.ends_with('/') {
221            format!("{}v1/embeddings", self.config.endpoint)
222        } else {
223            format!("{}/v1/embeddings", self.config.endpoint)
224        }
225    }
226}
227
228impl Embedder for HttpEmbedder {
229    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
230        if texts.is_empty() {
231            return Ok(Vec::new());
232        }
233
234        let url = self.embeddings_url();
235
236        // OpenAI-compatible embeddings request
237        let request = EmbeddingsRequest {
238            model: &self.config.model,
239            input: texts,
240        };
241
242        let response = self
243            .agent
244            .post(&url)
245            .header("Content-Type", "application/json")
246            .send_json(&request)
247            .map_err(|e| CoreError::Memory(format!("Embedder HTTP error: {e}")))?;
248
249        let embed_resp: EmbeddingsResponse = response
250            .into_body()
251            .read_json()
252            .map_err(|e| CoreError::Memory(format!("Embedder response parse error: {e}")))?;
253
254        let vectors: Vec<Vec<f32>> = embed_resp.data.into_iter().map(|d| d.embedding).collect();
255
256        if vectors.len() != texts.len() {
257            return Err(CoreError::Memory(format!(
258                "Embedder returned {} vectors for {} inputs",
259                vectors.len(),
260                texts.len()
261            )));
262        }
263
264        Ok(vectors)
265    }
266
267    fn dimension(&self) -> usize {
268        self.config.dimension
269    }
270
271    fn is_available(&self) -> bool {
272        self.available
273    }
274
275    fn backend_name(&self) -> &'static str {
276        "http"
277    }
278
279    fn cache_namespace(&self) -> String {
280        format!("http:{}:{}", self.config.endpoint, self.config.model)
281    }
282}
283
284/// OpenAI-compatible embeddings request.
285#[derive(Debug, Serialize)]
286struct EmbeddingsRequest<'a> {
287    model: &'a str,
288    input: &'a [&'a str],
289}
290
291/// OpenAI-compatible embeddings response.
292#[derive(Debug, Deserialize)]
293struct EmbeddingsResponse {
294    data: Vec<EmbeddingData>,
295}
296
297#[derive(Debug, Deserialize)]
298struct EmbeddingData {
299    embedding: Vec<f32>,
300}
301
302/// Stub embedder — hash-based pseudo-embeddings for testing/fallback.
303///
304/// Generates deterministic embeddings from text content using SHA-256 hashing.
305/// Not useful for real semantic search, but provides a fallback when no
306/// embedder is available and allows tests to run without a server.
307pub struct StubEmbedder {
308    dimension: usize,
309}
310
311impl StubEmbedder {
312    /// Create a new stub embedder with the given dimensionality.
313    #[must_use]
314    pub const fn new(dimension: usize) -> Self {
315        Self { dimension }
316    }
317}
318
319impl Default for StubEmbedder {
320    fn default() -> Self {
321        Self::new(384)
322    }
323}
324
325impl Embedder for StubEmbedder {
326    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
327        use sha2::{Digest, Sha256};
328
329        let mut results = Vec::with_capacity(texts.len());
330        for text in texts {
331            let mut hasher = Sha256::new();
332            hasher.update(text.as_bytes());
333            let hash = hasher.finalize();
334
335            // Expand hash to fill the desired dimension
336            let mut embedding = Vec::with_capacity(self.dimension);
337            for i in 0..self.dimension {
338                let byte = f32::from(hash[i % hash.len()]);
339                embedding.push(byte.mul_add(2.0 / 255.0, -1.0)); // Normalize to [-1, 1]
340            }
341            results.push(embedding);
342        }
343        Ok(results)
344    }
345
346    fn dimension(&self) -> usize {
347        self.dimension
348    }
349
350    fn is_available(&self) -> bool {
351        true
352    }
353
354    fn backend_name(&self) -> &'static str {
355        "stub"
356    }
357}
358
359/// ONNX Runtime-based embedder using `fastembed-rs`.
360///
361/// Provides fully local embeddings without a server dependency.
362/// Downloads model files on first use (cached thereafter).
363///
364/// Requires the `onnx` feature to be enabled:
365/// ```toml
366/// wm-memory = { features = ["onnx"] }
367/// ```
368///
369/// Default model: BAAI/bge-small-en-v1.5 (384 dimensions, ~130MB).
370///
371/// # Environment Variables
372///
373/// | Variable | Default | Description |
374/// |----------|---------|-------------|
375/// | `WM_EMBEDDER_ORT_MODEL` | `BAAI/bge-small-en-v1.5` | Model name (`-q` suffixes select INT8-quantized variants, e.g. `bge-small-q`) |
376/// | `WM_EMBEDDER_CACHE_DIR` | — | Cache directory for model files |
377/// | `WM_EMBEDDER_ORT_THREADS` | min(logical cores, 4) | Number of intra-op threads |
378#[cfg(feature = "onnx")]
379pub struct OrtEmbedder {
380    /// Sharded session pool (v26 `parallel/pools.py` idea, finally wired):
381    /// one ORT session per shard, mutex per shard — a batch fans out
382    /// across shards concurrently instead of serializing on one session.
383    /// Ingest-scale workloads are the target; single-text calls use
384    /// shard 0 directly.
385    shards: Vec<std::sync::Mutex<Option<fastembed::TextEmbedding>>>,
386    model_name: String,
387    cache_dir: Option<std::path::PathBuf>,
388    threads: usize,
389    dimension: usize,
390    available: std::sync::atomic::AtomicBool,
391    /// Pinned fastembed batch_size (32–64 band per the ship list).
392    batch_size: usize,
393}
394
395/// Default ORT intra-op thread count.
396///
397/// Defaulting to every logical core (hyperthreads included) saturates small
398/// machines — on a 4C/8T laptop the FP32 embedder at 8 threads drove the
399/// box into swap thrash and OOM (see `docs/POLYGLOT_SIMD_MEMORY_STRATEGY.md`).
400/// Physical-core count is not exposed by std; `min(logical, 4)` approximates
401/// it and stays conservative on both small and large machines. Explicit
402/// `WM_EMBEDDER_ORT_THREADS` always wins over this default (values above 4
403/// are honored explicitly — they raise the TOTAL intra-op budget).
404#[cfg(feature = "onnx")]
405fn default_intra_threads() -> usize {
406    std::thread::available_parallelism()
407        .map(std::num::NonZero::get)
408        .unwrap_or(4)
409        .min(4)
410}
411
412#[cfg(feature = "onnx")]
413impl OrtEmbedder {
414    /// Create a new ONNX embedder with the given configuration.
415    #[must_use]
416    pub fn new(
417        model_name: &str,
418        cache_dir: Option<std::path::PathBuf>,
419        threads: usize,
420        dimension: usize,
421    ) -> Self {
422        let threads = threads.max(1);
423        // Total intra-op budget = `threads`, distributed across the pool.
424        // MEASURED on the reference machine (2026-09-01, ingest probe +
425        // pool-shape microbench): in-server, 2 shards × intra/2 ≈ parity
426        // with the legacy single session, while full fan-out (4×1) LOSES
427        // ~11% to tokio-worker contention, and 1 shard pays the cache's
428        // small tax. So the default halves the budget into shards,
429        // capped at 4; `WM_EMBEDDER_ORT_SHARDS` overrides explicitly
430        // (1 = legacy single-session shape, higher = fuller fan-out for
431        // batch-heavy hosts).
432        let shard_count = (threads / 2).clamp(1, 4);
433        let shards = (0..shard_count)
434            .map(|_| std::sync::Mutex::new(None))
435            .collect();
436        Self {
437            shards,
438            model_name: model_name.to_string(),
439            cache_dir,
440            threads,
441            dimension,
442            available: std::sync::atomic::AtomicBool::new(false),
443            batch_size: 32,
444        }
445    }
446
447    /// Create from environment variables.
448    ///
449    /// Returns `None` if the `onnx` feature is not enabled or model cannot be loaded.
450    #[must_use]
451    pub fn from_env() -> Option<Self> {
452        let model_name = std::env::var("WM_EMBEDDER_ORT_MODEL")
453            .unwrap_or_else(|_| "BAAI/bge-small-en-v1.5".into());
454        let cache_dir = std::env::var("WM_EMBEDDER_CACHE_DIR")
455            .ok()
456            .map(std::path::PathBuf::from);
457        let threads = std::env::var("WM_EMBEDDER_ORT_THREADS")
458            .ok()
459            .and_then(|v| v.parse::<usize>().ok())
460            .unwrap_or_else(default_intra_threads);
461        let dimension = std::env::var("WM_EMBEDDER_DIM")
462            .ok()
463            .and_then(|v| v.parse::<usize>().ok())
464            .unwrap_or(384);
465
466        Some(Self::new(&model_name, cache_dir, threads, dimension))
467    }
468
469    /// Override the derived shard count (`WM_EMBEDDER_ORT_SHARDS`).
470    ///
471    /// The derived count (min(threads, logical cores)) is a reasonable
472    /// default, but ORT scaling is model- and workload-dependent — this
473    /// explicit override exists so the pool shape can be tuned per
474    /// machine without recompiling (1 = legacy single-session shape).
475    #[must_use]
476    pub fn with_shard_override(mut self, shards: usize) -> Self {
477        self.shards = (0..shards.max(1))
478            .map(|_| std::sync::Mutex::new(None))
479            .collect();
480        self
481    }
482
483    /// Map model name string to fastembed EmbeddingModel enum.
484    ///
485    /// Names ending in `-q` select INT8-quantized variants: ~75% smaller
486    /// weights and lower memory bandwidth than FP32, executing via CPU INT8
487    /// SIMD. Prefer them on modest hardware (see
488    /// `docs/POLYGLOT_SIMD_MEMORY_STRATEGY.md` — FP32 + unbounded threads
489    /// OOM-crashed a 16GB machine during benchmark runs).
490    fn resolve_model(&self) -> Option<fastembed::EmbeddingModel> {
491        match self.model_name.as_str() {
492            "BAAI/bge-small-en-v1.5" | "bge-small-en-v1.5" | "bge-small" => {
493                Some(fastembed::EmbeddingModel::BGESmallENV15)
494            }
495            "BAAI/bge-small-en-v1.5-q" | "bge-small-en-v1.5-q" | "bge-small-q" => {
496                Some(fastembed::EmbeddingModel::BGESmallENV15Q)
497            }
498            "BAAI/bge-base-en-v1.5" | "bge-base-en-v1.5" | "bge-base" => {
499                Some(fastembed::EmbeddingModel::BGEBaseENV15)
500            }
501            "BAAI/bge-base-en-v1.5-q" | "bge-base-en-v1.5-q" | "bge-base-q" => {
502                Some(fastembed::EmbeddingModel::BGEBaseENV15Q)
503            }
504            "BAAI/bge-large-en-v1.5" | "bge-large-en-v1.5" | "bge-large" => {
505                Some(fastembed::EmbeddingModel::BGELargeENV15)
506            }
507            "sentence-transformers/all-MiniLM-L6-v2" | "all-MiniLM-L6-v2" | "minilm" => {
508                Some(fastembed::EmbeddingModel::AllMiniLML6V2)
509            }
510            "sentence-transformers/all-MiniLM-L6-v2-q" | "all-MiniLM-L6-v2-q" | "minilm-q" => {
511                Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
512            }
513            "sentence-transformers/all-MiniLM-L12-v2" | "all-MiniLM-L12-v2" => {
514                Some(fastembed::EmbeddingModel::AllMiniLML12V2)
515            }
516            "nomic-ai/nomic-embed-text-v1.5" | "nomic-embed-text-v1.5" | "nomic" => {
517                Some(fastembed::EmbeddingModel::NomicEmbedTextV15)
518            }
519            _ => {
520                tracing::warn!(
521                    "unknown embedder model '{}', falling back to bge-small-en-v1.5",
522                    self.model_name
523                );
524                Some(fastembed::EmbeddingModel::BGESmallENV15)
525            }
526        }
527    }
528
529    /// Number of intra-op threads configured for inference (the TOTAL
530    /// budget across the session pool).
531    #[cfg(feature = "onnx")]
532    #[must_use]
533    pub const fn threads(&self) -> usize {
534        self.threads
535    }
536
537    /// Number of ORT sessions in the pool.
538    #[cfg(feature = "onnx")]
539    #[must_use]
540    pub fn shard_count(&self) -> usize {
541        self.shards.len()
542    }
543
544    /// Intra-op threads per session (the budget divided across the pool).
545    #[cfg(feature = "onnx")]
546    #[must_use]
547    fn intra_per_shard(&self) -> usize {
548        (self.threads / self.shards.len().max(1)).max(1)
549    }
550
551    /// Lazy-load the pool on first use: every shard gets its own session
552    /// over the shared on-disk model files.
553    fn ensure_loaded(&self) -> bool {
554        // Fast path: already loaded
555        if self.available.load(std::sync::atomic::Ordering::Relaxed) {
556            return true;
557        }
558
559        let intra = self.intra_per_shard();
560        let mut loaded = Vec::with_capacity(self.shards.len());
561        for _ in 0..self.shards.len() {
562            let Some(embedding_model) = self.resolve_model() else {
563                self.available
564                    .store(false, std::sync::atomic::Ordering::Relaxed);
565                return false;
566            };
567            let mut options = fastembed::TextInitOptions::new(embedding_model)
568                .with_show_download_progress(false)
569                .with_intra_threads(intra);
570
571            if let Some(ref cache_dir) = self.cache_dir {
572                options = options.with_cache_dir(cache_dir.clone());
573            }
574
575            match fastembed::TextEmbedding::try_new(options) {
576                Ok(model) => loaded.push(Some(model)),
577                Err(e) => {
578                    tracing::warn!("failed to load ONNX embedding model: {e}");
579                    self.available
580                        .store(false, std::sync::atomic::Ordering::Relaxed);
581                    return false;
582                }
583            }
584        }
585
586        {
587            for (shard, model) in self.shards.iter().zip(loaded) {
588                let mut guard = shard.lock().expect("shard mutex poisoned");
589                *guard = model;
590            }
591        }
592        self.available
593            .store(true, std::sync::atomic::Ordering::Relaxed);
594        tracing::info!(
595            "ONNX embedding model loaded: {} ({} session shards × {} intra threads, dim={})",
596            self.model_name,
597            self.shards.len(),
598            intra,
599            self.dimension
600        );
601        true
602    }
603}
604
605#[cfg(feature = "onnx")]
606impl Embedder for OrtEmbedder {
607    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
608        if texts.is_empty() {
609            return Ok(Vec::new());
610        }
611
612        if !self.ensure_loaded() {
613            return Err(CoreError::Memory(
614                "ONNX embedder model not available".into(),
615            ));
616        }
617
618        let owned_texts: Vec<String> = texts.iter().map(|t| (*t).to_string()).collect();
619
620        // Single text (the query path): no fan-out machinery, shard 0.
621        if owned_texts.len() == 1 {
622            let mut guard = self.shards[0].lock().expect("shard mutex poisoned");
623            let Some(ref mut model) = guard.as_mut() else {
624                return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
625            };
626            let embeddings = model
627                .embed(owned_texts, Some(self.batch_size))
628                .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))?;
629            return Ok(embeddings);
630        }
631
632        // Fan out: round-robin split across shards, each embedding its
633        // subset concurrently on its own session (no cross-shard locks).
634        let shard_count = self.shards.len();
635        let mut split: Vec<Vec<String>> = vec![Vec::new(); shard_count];
636        for (i, text) in owned_texts.into_iter().enumerate() {
637            split[i % shard_count].push(text);
638        }
639
640        let results: Vec<Result<Vec<Vec<f32>>>> = std::thread::scope(|scope| {
641            let handles: Vec<_> = split
642                .into_iter()
643                .zip(&self.shards)
644                .map(|(subset, shard)| {
645                    scope.spawn(move || {
646                        let mut guard = shard.lock().expect("shard mutex poisoned");
647                        let Some(ref mut model) = guard.as_mut() else {
648                            return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
649                        };
650                        model
651                            .embed(subset, Some(self.batch_size))
652                            .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))
653                    })
654                })
655                .collect();
656            handles
657                .into_iter()
658                .map(|h| h.join().expect("shard worker panicked"))
659                .collect()
660        });
661
662        // Reassemble in the original order: position i came from shard
663        // i % shard_count (round-robin split is deterministic, so the
664        // interleave is too).
665        let mut per_shard: Vec<std::collections::VecDeque<Vec<f32>>> = results
666            .into_iter()
667            .map(|r| {
668                r.map(|vectors| {
669                    vectors
670                        .into_iter()
671                        .collect::<std::collections::VecDeque<_>>()
672                })
673            })
674            .collect::<Result<Vec<_>>>()?;
675        let mut out: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
676        for i in 0..texts.len() {
677            let shard_idx = i % shard_count;
678            let Some(vector) = per_shard[shard_idx].pop_front() else {
679                return Err(CoreError::Memory(format!(
680                    "embed_batch shard {shard_idx} returned too few vectors"
681                )));
682            };
683            out.push(vector);
684        }
685
686        if out.len() != texts.len() {
687            return Err(CoreError::Memory(format!(
688                "embed_batch returned {} vectors for {} inputs",
689                out.len(),
690                texts.len()
691            )));
692        }
693        Ok(out)
694    }
695
696    fn dimension(&self) -> usize {
697        self.dimension
698    }
699
700    fn is_available(&self) -> bool {
701        self.ensure_loaded()
702    }
703
704    fn backend_name(&self) -> &'static str {
705        "onnx"
706    }
707
708    fn cache_namespace(&self) -> String {
709        // Dimension included: quantization or an unknown-name fallback can
710        // change the effective model under the same requested name.
711        format!("onnx:{}:{}", self.model_name, self.dimension)
712    }
713
714    fn preferred_max_batch_texts(&self) -> usize {
715        // Big enough that each session shard sees a real batch (32+ texts)
716        // after the round-robin split.
717        128
718    }
719}
720
721/// Create an embedder from environment configuration, with fallback chain.
722///
723/// Priority (when `onnx` feature is enabled):
724/// 1. `OrtEmbedder` — if `WM_EMBEDDER_ORT_MODEL` is set or `onnx` feature is on
725/// 2. `HttpEmbedder` — if `WM_EMBEDDER_ENDPOINT` is set
726/// 3. `StubEmbedder` — always available fallback
727///
728/// Without `onnx` feature:
729/// 1. `HttpEmbedder` — if `WM_EMBEDDER_ENDPOINT` is set
730/// 2. `StubEmbedder` — fallback
731#[must_use]
732pub fn create_embedder() -> Box<dyn Embedder> {
733    #[cfg(feature = "onnx")]
734    {
735        let prefer_ort = std::env::var("WM_EMBEDDER_BACKEND")
736            .map(|v| v == "onnx" || v == "ort")
737            .unwrap_or(false);
738
739        if prefer_ort {
740            if let Some(mut ort) = OrtEmbedder::from_env() {
741                if let Ok(shards) = std::env::var("WM_EMBEDDER_ORT_SHARDS") {
742                    if let Ok(n) = shards.parse::<usize>() {
743                        ort = ort.with_shard_override(n);
744                    }
745                }
746                tracing::info!(
747                    "onnx embedder configured (dim={}, shards={})",
748                    ort.dimension(),
749                    ort.shard_count()
750                );
751                return Box::new(ort);
752            }
753        }
754    }
755
756    if let Some(http) = HttpEmbedder::from_env() {
757        tracing::info!("http embedder configured (dim={})", http.dimension());
758        return Box::new(http);
759    }
760
761    #[cfg(feature = "onnx")]
762    {
763        // Try ONNX as default when feature is enabled and no HTTP endpoint
764        if let Some(ort) = OrtEmbedder::from_env() {
765            tracing::info!(
766                "onnx embedder configured as default (dim={})",
767                ort.dimension()
768            );
769            return Box::new(ort);
770        }
771    }
772
773    tracing::info!("no embedder endpoint configured, using stub embedder");
774    Box::new(StubEmbedder::default())
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    // --- StubEmbedder tests ---
782
783    #[test]
784    fn stub_embedder_dimension() {
785        let embedder = StubEmbedder::new(128);
786        assert_eq!(embedder.dimension(), 128);
787    }
788
789    #[test]
790    fn stub_embedder_single() {
791        let embedder = StubEmbedder::new(64);
792        let vec = embedder.embed("hello world").unwrap();
793        assert_eq!(vec.len(), 64);
794        // Values should be in [-1, 1]
795        for v in &vec {
796            assert!(*v >= -1.0 && *v <= 1.0);
797        }
798    }
799
800    #[test]
801    fn stub_embedder_batch() {
802        let embedder = StubEmbedder::new(32);
803        let texts = ["hello", "world", "test"];
804        let vectors = embedder.embed_batch(&texts).unwrap();
805        assert_eq!(vectors.len(), 3);
806        for v in &vectors {
807            assert_eq!(v.len(), 32);
808        }
809    }
810
811    #[test]
812    fn stub_embedder_deterministic() {
813        let embedder = StubEmbedder::new(64);
814        let v1 = embedder.embed("same text").unwrap();
815        let v2 = embedder.embed("same text").unwrap();
816        assert_eq!(v1, v2);
817    }
818
819    #[test]
820    fn stub_embedder_different_texts_differ() {
821        let embedder = StubEmbedder::new(64);
822        let v1 = embedder.embed("hello").unwrap();
823        let v2 = embedder.embed("world").unwrap();
824        assert_ne!(v1, v2);
825    }
826
827    #[test]
828    fn stub_embedder_empty_batch() {
829        let embedder = StubEmbedder::new(64);
830        let vectors = embedder.embed_batch(&[]).unwrap();
831        assert!(vectors.is_empty());
832    }
833
834    #[test]
835    fn stub_embedder_is_available() {
836        let embedder = StubEmbedder::new(64);
837        assert!(embedder.is_available());
838    }
839
840    #[test]
841    fn stub_embedder_backend_name() {
842        let embedder = StubEmbedder::new(64);
843        assert_eq!(embedder.backend_name(), "stub");
844    }
845
846    #[test]
847    fn stub_embedder_default_dimension() {
848        let embedder = StubEmbedder::default();
849        assert_eq!(embedder.dimension(), 384);
850    }
851
852    // --- HttpEmbedder tests ---
853
854    #[test]
855    fn http_embedder_config_from_env_absent() {
856        // Test the config struct directly
857        let config = EmbedderConfig {
858            endpoint: "http://localhost:8080".into(),
859            model: "local".into(),
860            dimension: 384,
861            timeout: Duration::from_secs(30),
862        };
863        assert_eq!(config.endpoint, "http://localhost:8080");
864        assert_eq!(config.model, "local");
865        assert_eq!(config.dimension, 384);
866    }
867
868    #[test]
869    fn http_embedder_embeddings_url() {
870        let config = EmbedderConfig {
871            endpoint: "http://localhost:8080".into(),
872            model: "local".into(),
873            dimension: 384,
874            timeout: Duration::from_secs(30),
875        };
876        let embedder = HttpEmbedder::new(config);
877        assert_eq!(
878            embedder.embeddings_url(),
879            "http://localhost:8080/v1/embeddings"
880        );
881    }
882
883    #[test]
884    fn http_embedder_embeddings_url_trailing_slash() {
885        let config = EmbedderConfig {
886            endpoint: "http://localhost:8080/".into(),
887            model: "local".into(),
888            dimension: 384,
889            timeout: Duration::from_secs(30),
890        };
891        let embedder = HttpEmbedder::new(config);
892        assert_eq!(
893            embedder.embeddings_url(),
894            "http://localhost:8080/v1/embeddings"
895        );
896    }
897
898    #[test]
899    fn http_embedder_embeddings_url_full_path() {
900        let config = EmbedderConfig {
901            endpoint: "http://localhost:8080/v1/embeddings".into(),
902            model: "local".into(),
903            dimension: 384,
904            timeout: Duration::from_secs(30),
905        };
906        let embedder = HttpEmbedder::new(config);
907        assert_eq!(
908            embedder.embeddings_url(),
909            "http://localhost:8080/v1/embeddings"
910        );
911    }
912
913    #[test]
914    fn http_embedder_dimension() {
915        let config = EmbedderConfig {
916            endpoint: "http://localhost:8080".into(),
917            model: "local".into(),
918            dimension: 768,
919            timeout: Duration::from_secs(10),
920        };
921        let embedder = HttpEmbedder::new(config);
922        assert_eq!(embedder.dimension(), 768);
923    }
924
925    #[test]
926    fn http_embedder_backend_name() {
927        let config = EmbedderConfig {
928            endpoint: "http://localhost:8080".into(),
929            model: "local".into(),
930            dimension: 384,
931            timeout: Duration::from_secs(10),
932        };
933        let embedder = HttpEmbedder::new(config);
934        assert_eq!(embedder.backend_name(), "http");
935    }
936
937    #[test]
938    fn http_embedder_is_available() {
939        let config = EmbedderConfig {
940            endpoint: "http://localhost:8080".into(),
941            model: "local".into(),
942            dimension: 384,
943            timeout: Duration::from_secs(10),
944        };
945        let embedder = HttpEmbedder::new(config);
946        assert!(embedder.is_available());
947    }
948
949    // --- create_embedder tests ---
950
951    #[test]
952    fn create_embedder_falls_back_to_stub() {
953        // Without WM_EMBEDDER_ENDPOINT set, should return stub (or onnx if feature enabled)
954        let embedder = create_embedder();
955        let name = embedder.backend_name();
956        assert!(name == "stub" || name == "http" || name == "onnx");
957    }
958
959    // --- Embedder trait tests ---
960
961    #[test]
962    fn embedder_trait_object() {
963        let embedder: Box<dyn Embedder> = Box::new(StubEmbedder::new(128));
964        assert_eq!(embedder.dimension(), 128);
965        let vec = embedder.embed("test").unwrap();
966        assert_eq!(vec.len(), 128);
967    }
968
969    #[test]
970    fn embedder_embed_query() {
971        let embedder = StubEmbedder::new(64);
972        let v1 = embedder.embed("hello").unwrap();
973        let v2 = embedder.embed_query("hello").unwrap();
974        assert_eq!(v1, v2);
975    }
976
977    // --- OrtEmbedder tests (only when onnx feature is enabled) ---
978
979    #[cfg(feature = "onnx")]
980    #[test]
981    fn ort_embedder_backend_name() {
982        let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
983        assert_eq!(embedder.backend_name(), "onnx");
984    }
985
986    #[cfg(feature = "onnx")]
987    #[test]
988    fn ort_embedder_resolves_quantized_models() {
989        // INT8 variants must resolve to their Q enum counterparts, not
990        // silently fall back to FP32 bge-small.
991        let q = OrtEmbedder::new("bge-small-q", None, 2, 384);
992        assert!(matches!(
993            q.resolve_model(),
994            Some(fastembed::EmbeddingModel::BGESmallENV15Q)
995        ));
996        let q_full = OrtEmbedder::new("BAAI/bge-small-en-v1.5-q", None, 2, 384);
997        assert!(matches!(
998            q_full.resolve_model(),
999            Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1000        ));
1001        let minilm_q = OrtEmbedder::new("minilm-q", None, 2, 384);
1002        assert!(matches!(
1003            minilm_q.resolve_model(),
1004            Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
1005        ));
1006        // FP32 names keep resolving to the FP32 variants.
1007        let fp32 = OrtEmbedder::new("bge-small", None, 2, 384);
1008        assert!(matches!(
1009            fp32.resolve_model(),
1010            Some(fastembed::EmbeddingModel::BGESmallENV15)
1011        ));
1012    }
1013
1014    #[cfg(feature = "onnx")]
1015    #[test]
1016    fn ort_embedder_default_threads_are_capped() {
1017        // The default must never exceed 4 threads regardless of logical
1018        // core count — unbounded ORT threads OOM-crash small machines
1019        // (docs/POLYGLOT_SIMD_MEMORY_STRATEGY.md).
1020        assert!(
1021            default_intra_threads() <= 4,
1022            "default threads must be capped at 4, got {}",
1023            default_intra_threads()
1024        );
1025    }
1026
1027    #[cfg(feature = "onnx")]
1028    #[test]
1029    fn ort_embedder_dimension() {
1030        let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1031        assert_eq!(embedder.dimension(), 384);
1032    }
1033
1034    #[cfg(feature = "onnx")]
1035    #[test]
1036    fn ort_embedder_dimension_custom() {
1037        let embedder = OrtEmbedder::new("minilm", None, 1, 256);
1038        assert_eq!(embedder.dimension(), 256);
1039    }
1040
1041    #[cfg(feature = "onnx")]
1042    #[test]
1043    fn ort_embedder_pool_shards_the_thread_budget() {
1044        // V8 ship list #3: the pool distributes the TOTAL intra-op budget
1045        // across session shards. Threads=1 keeps the legacy single-session
1046        // shape; larger budgets fan out without exceeding the total.
1047        for threads in [1usize, 2, 4, 8] {
1048            let embedder = OrtEmbedder::new("bge-small", None, threads, 384);
1049            assert_eq!(
1050                embedder.threads(),
1051                threads,
1052                "total budget must be preserved"
1053            );
1054            let shards = embedder.shard_count();
1055            assert!(shards >= 1, "at least one session");
1056            assert!(
1057                shards <= threads,
1058                "shards ({shards}) must not exceed the total budget ({threads})"
1059            );
1060        }
1061        let single = OrtEmbedder::new("bge-small", None, 1, 384);
1062        assert_eq!(single.shard_count(), 1, "threads=1 is the legacy shape");
1063    }
1064
1065    #[cfg(feature = "onnx")]
1066    #[test]
1067    fn ort_embedder_namespace_carries_model_and_dimension() {
1068        // Cache keys must change when the effective model changes.
1069        let small = OrtEmbedder::new("bge-small", None, 2, 384);
1070        let large = OrtEmbedder::new("bge-large", None, 2, 1024);
1071        assert_ne!(
1072            small.cache_namespace(),
1073            large.cache_namespace(),
1074            "different models must not share cache entries"
1075        );
1076        assert!(small.cache_namespace().contains("bge-small"));
1077    }
1078
1079    /// Pool-shape microbench: run explicitly, it loads the real model
1080    /// (`cargo test -p wm-memory --features onnx ort_pool_shape_microbench
1081    /// -- --ignored --nocapture`). Measures embed_batch wall time for one
1082    /// ingest-shaped call per (threads, shards) config on realistic text.
1083    #[cfg(feature = "onnx")]
1084    #[test]
1085    #[ignore = "loads the real ONNX model; run explicitly for pool tuning"]
1086    fn ort_pool_shape_microbench() {
1087        let texts: Vec<String> = (0..128)
1088            .map(|i| {
1089                format!(
1090                    "Session {i} of the deployment retrospective covered the rollout \
1091                     schedule for the telemetry agent, the budget review outcomes, and \
1092                     the follow-up decisions about the quarterly report timeline {i}."
1093                )
1094            })
1095            .collect();
1096        let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
1097
1098        // (threads, shard_override) — shard_override None = derived.
1099        let configs: Vec<(usize, Option<usize>)> = vec![
1100            (1, Some(1)),
1101            (2, Some(1)),
1102            (4, Some(1)),
1103            (4, None),
1104            (4, Some(2)),
1105            (8, None),
1106            (8, Some(4)),
1107        ];
1108        for (threads, shards) in configs {
1109            let mut embedder = OrtEmbedder::new("bge-small-q", None, threads, 384);
1110            if let Some(n) = shards {
1111                embedder = embedder.with_shard_override(n);
1112            }
1113            // Warm the pool (model load) before timing.
1114            let warm = embedder.embed_batch(&refs[..1]).unwrap();
1115            assert_eq!(warm.len(), 1);
1116            let t0 = std::time::Instant::now();
1117            let out = embedder.embed_batch(&refs).unwrap();
1118            let dt = t0.elapsed();
1119            assert_eq!(out.len(), refs.len());
1120            println!(
1121                "pool shape: threads={threads} shards={} intra={} → {} texts in {:.2?} ({:.1} texts/s)",
1122                embedder.shard_count(),
1123                embedder.intra_per_shard(),
1124                refs.len(),
1125                dt,
1126                refs.len() as f64 / dt.as_secs_f64()
1127            );
1128
1129            // Ingest shape: ~30-text chunks, many calls (the write path
1130            // chunks by chars, so embed_batch sees small slices).
1131            let t1 = std::time::Instant::now();
1132            let mut total = 0usize;
1133            for chunk in refs.chunks(30) {
1134                total += embedder.embed_batch(chunk).unwrap().len();
1135            }
1136            let dt1 = t1.elapsed();
1137            assert_eq!(total, refs.len());
1138            println!(
1139                "  ingest shape (30-text chunks): {:.2?} ({:.1} texts/s)",
1140                dt1,
1141                refs.len() as f64 / dt1.as_secs_f64()
1142            );
1143        }
1144    }
1145
1146    /// Determinism across pool shapes: the same text must produce the same
1147    /// vector whether embedded on the single-session shape or the fanned
1148    /// out pool (intra/shard parallelism must not change the math).
1149    #[cfg(feature = "onnx")]
1150    #[test]
1151    #[ignore = "loads the real ONNX model; run explicitly as a pool-identity gate"]
1152    fn ort_pool_shapes_produce_identical_vectors() {
1153        let text = "determinism probe across session pool shapes";
1154        let single = OrtEmbedder::new("bge-small-q", None, 1, 384).with_shard_override(1);
1155        let pooled = OrtEmbedder::new("bge-small-q", None, 4, 384);
1156        let v1 = single.embed(text).unwrap();
1157        let v2 = pooled.embed(text).unwrap();
1158        assert_eq!(v1.len(), v2.len());
1159        let max_diff = v1
1160            .iter()
1161            .zip(v2.iter())
1162            .map(|(a, b)| (a - b).abs())
1163            .fold(0.0f32, f32::max);
1164        assert!(
1165            max_diff < 1e-6,
1166            "pool shape changed the vector math (max diff {max_diff})"
1167        );
1168    }
1169
1170    #[cfg(feature = "onnx")]
1171    #[test]
1172    fn ort_embedder_empty_batch() {
1173        let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1174        let result = embedder.embed_batch(&[]).unwrap();
1175        assert!(result.is_empty());
1176    }
1177
1178    #[cfg(feature = "onnx")]
1179    #[test]
1180    fn ort_embedder_trait_object() {
1181        let embedder: Box<dyn Embedder> = Box::new(OrtEmbedder::new("bge-small", None, 2, 384));
1182        assert_eq!(embedder.dimension(), 384);
1183        assert_eq!(embedder.backend_name(), "onnx");
1184    }
1185
1186    #[cfg(feature = "onnx")]
1187    #[test]
1188    fn ort_embedder_resolve_model_known() {
1189        let embedder = OrtEmbedder::new("bge-small-en-v1.5", None, 2, 384);
1190        assert!(embedder.resolve_model().is_some());
1191    }
1192
1193    #[cfg(feature = "onnx")]
1194    #[test]
1195    fn ort_embedder_resolve_model_unknown_falls_back() {
1196        let embedder = OrtEmbedder::new("some-unknown-model", None, 2, 384);
1197        // Should fall back to bge-small
1198        assert!(embedder.resolve_model().is_some());
1199    }
1200
1201    // --- SSRF validation tests ---
1202
1203    #[test]
1204    fn endpoint_safe_allows_localhost() {
1205        assert!(is_endpoint_safe("http://localhost:8080"));
1206        assert!(is_endpoint_safe("http://127.0.0.1:8080"));
1207        assert!(is_endpoint_safe("http://localhost:11434/v1/embeddings"));
1208    }
1209
1210    #[test]
1211    fn endpoint_safe_allows_private_ip() {
1212        assert!(is_endpoint_safe("http://10.0.0.2:8080"));
1213        assert!(is_endpoint_safe("http://192.168.1.100:8080"));
1214        assert!(is_endpoint_safe("http://172.16.0.5:8080"));
1215    }
1216
1217    #[test]
1218    fn endpoint_safe_blocks_non_http_schemes() {
1219        assert!(!is_endpoint_safe("file:///etc/passwd"));
1220        assert!(!is_endpoint_safe("gopher://localhost:8080"));
1221        assert!(!is_endpoint_safe("ftp://example.com"));
1222        assert!(!is_endpoint_safe("javascript:alert(1)"));
1223        assert!(!is_endpoint_safe("data:text/plain,hello"));
1224    }
1225
1226    #[test]
1227    fn endpoint_safe_blocks_metadata_endpoints() {
1228        assert!(!is_endpoint_safe("http://169.254.169.254/latest/meta-data"));
1229        assert!(!is_endpoint_safe("http://169.254.170.2/v2/metadata"));
1230        assert!(!is_endpoint_safe(
1231            "http://metadata.google.internal/computeMetadata"
1232        ));
1233        assert!(!is_endpoint_safe("http://metadata.aws.internal"));
1234        assert!(!is_endpoint_safe("http://metadata"));
1235    }
1236
1237    #[test]
1238    fn endpoint_safe_blocks_empty_host() {
1239        assert!(!is_endpoint_safe("http://"));
1240        assert!(!is_endpoint_safe("http:///path"));
1241    }
1242
1243    #[test]
1244    fn endpoint_safe_blocks_malformed_ipv6() {
1245        assert!(!is_endpoint_safe("http://[::1:8080"));
1246    }
1247
1248    #[test]
1249    fn endpoint_safe_allows_https() {
1250        assert!(is_endpoint_safe("https://localhost:8080"));
1251        assert!(is_endpoint_safe("https://example.com/api"));
1252    }
1253
1254    #[test]
1255    fn endpoint_safe_allows_ipv6_loopback() {
1256        assert!(is_endpoint_safe("http://[::1]:8080"));
1257    }
1258}