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