1#![allow(clippy::cast_possible_wrap)]
23
24use serde::{Deserialize, Serialize};
25use std::time::Duration;
26use wm_core::{CoreError, Result};
27
28pub trait Embedder: Send + Sync {
34 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
38
39 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 fn embed_query(&self, query: &str) -> Result<Vec<f32>> {
49 self.embed(query)
50 }
51
52 fn dimension(&self) -> usize;
54
55 fn is_available(&self) -> bool;
57
58 fn backend_name(&self) -> &'static str;
60
61 fn cache_namespace(&self) -> String {
67 self.backend_name().to_string()
68 }
69
70 fn preferred_max_batch_texts(&self) -> usize {
77 usize::MAX
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct EmbedderConfig {
84 pub endpoint: String,
86 pub model: String,
88 pub dimension: usize,
90 pub timeout: Duration,
92}
93
94impl EmbedderConfig {
95 #[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#[must_use]
136pub fn is_endpoint_safe(endpoint: &str) -> bool {
137 if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
139 return false;
140 }
141
142 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 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; }
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 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
184pub struct HttpEmbedder {
190 config: EmbedderConfig,
191 agent: ureq::Agent,
192 available: bool,
193}
194
195impl HttpEmbedder {
196 #[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 #[must_use]
212 pub fn from_env() -> Option<Self> {
213 EmbedderConfig::from_env().map(Self::new)
214 }
215
216 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 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#[derive(Debug, Serialize)]
286struct EmbeddingsRequest<'a> {
287 model: &'a str,
288 input: &'a [&'a str],
289}
290
291#[derive(Debug, Deserialize)]
293struct EmbeddingsResponse {
294 data: Vec<EmbeddingData>,
295}
296
297#[derive(Debug, Deserialize)]
298struct EmbeddingData {
299 embedding: Vec<f32>,
300}
301
302pub struct StubEmbedder {
308 dimension: usize,
309}
310
311impl StubEmbedder {
312 #[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 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)); }
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#[cfg(feature = "onnx")]
379pub struct OrtEmbedder {
380 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 batch_size: usize,
393}
394
395#[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 #[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 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 #[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 #[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 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 #[cfg(feature = "onnx")]
532 #[must_use]
533 pub const fn threads(&self) -> usize {
534 self.threads
535 }
536
537 #[cfg(feature = "onnx")]
539 #[must_use]
540 pub fn shard_count(&self) -> usize {
541 self.shards.len()
542 }
543
544 #[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 fn ensure_loaded(&self) -> bool {
554 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 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 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 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 format!("onnx:{}:{}", self.model_name, self.dimension)
712 }
713
714 fn preferred_max_batch_texts(&self) -> usize {
715 128
718 }
719}
720
721#[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 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 #[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 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 #[test]
855 fn http_embedder_config_from_env_absent() {
856 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 #[test]
952 fn create_embedder_falls_back_to_stub() {
953 let embedder = create_embedder();
955 let name = embedder.backend_name();
956 assert!(name == "stub" || name == "http" || name == "onnx");
957 }
958
959 #[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 #[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 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 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 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 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 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 #[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 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 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 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 #[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 assert!(embedder.resolve_model().is_some());
1199 }
1200
1201 #[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}