Skip to main content

mnemo_core/embedding/
onnx.rs

1//! ONNX Runtime local embedding provider.
2//!
3//! Provides local embedding inference using ONNX Runtime, eliminating the
4//! need for an external API. Supports sentence-transformer models such as
5//! `all-MiniLM-L6-v2` exported to ONNX format.
6//!
7//! # Feature gating
8//!
9//! When compiled **without** the `onnx` feature the module provides a stub
10//! that validates the model path but returns [`Error::Embedding`] from
11//! `embed()` and `embed_batch()`.
12//!
13//! When compiled **with** the `onnx` feature the module loads the ONNX
14//! session and a HuggingFace tokenizer, then performs real local inference
15//! with mean-pooling and L2 normalisation.
16//!
17//! ```toml
18//! [features]
19//! onnx = ["dep:ort", "dep:tokenizers", "dep:ndarray"]
20//!
21//! [dependencies]
22//! ort = { version = "2.0.0-rc.11", optional = true }
23//! tokenizers = { version = "0.23", optional = true, default-features = false, features = ["fancy-regex"] }
24//! ndarray = { version = "0.17", optional = true }
25//! ```
26//!
27//! These are the versions the workspace actually pins. The `#[cfg(feature =
28//! "onnx")]` inference path is written against — and builds + tests against —
29//! this `ort 2.0.0-rc.11` / `ndarray 0.17` / `tokenizers 0.23` API (the
30//! migration that repaired the old ndarray-0.16 drift). A dedicated `onnx
31//! feature` CI job (`.github/workflows/ci.yml`) builds and tests `--features
32//! onnx` so it cannot silently rot; it stays out of the workspace-wide jobs only
33//! because `ort` is a heavy native dependency. The one open item on
34//! <https://github.com/sattyamjjain/mnemo/issues/125> is a model-fetch CI job to
35//! make the ONNX MiniLM recall number itself reproducible (end-to-end inference
36//! needs a real model on disk via `MNEMO_ONNX_MODEL_PATH`, which the build+test
37//! job does not fetch). Build locally with `--features onnx`.
38//!
39//! # Example (stub)
40//!
41//! ```rust,no_run
42//! use mnemo_core::embedding::onnx::OnnxEmbedding;
43//! use mnemo_core::embedding::EmbeddingProvider;
44//!
45//! // Will succeed only if the path exists on disk.
46//! let provider = OnnxEmbedding::new("/models/all-MiniLM-L6-v2.onnx", 384)
47//!     .expect("model path must exist");
48//!
49//! assert_eq!(provider.dimensions(), 384);
50//! assert_eq!(provider.model_path(), "/models/all-MiniLM-L6-v2.onnx");
51//! ```
52
53use crate::embedding::EmbeddingProvider;
54use crate::error::{Error, Result};
55
56// ---------------------------------------------------------------------------
57// Real implementation (feature = "onnx")
58// ---------------------------------------------------------------------------
59#[cfg(feature = "onnx")]
60mod inner {
61    use super::*;
62    use ndarray::Array2;
63    use ort::session::Session;
64    use ort::value::Tensor;
65    use std::path::Path;
66    use std::sync::{Arc, Mutex};
67    use tokenizers::Tokenizer;
68
69    /// ONNX-based local embedding provider.
70    ///
71    /// Wraps an ONNX sentence-transformer model (e.g. `all-MiniLM-L6-v2`)
72    /// together with a HuggingFace tokenizer for on-device vector generation.
73    pub struct OnnxEmbedding {
74        dimensions: usize,
75        model_path: String,
76        // ort 2.0.0-rc.11's `Session::run` takes `&mut self`, so the session is
77        // behind a `Mutex` (interior mutability) rather than a bare `Arc` — the
78        // `Arc<Mutex<_>>` still moves cheaply into `spawn_blocking`.
79        session: Arc<Mutex<Session>>,
80        tokenizer: Arc<Tokenizer>,
81    }
82
83    // `ort::Session` is Send in ort v2; `Mutex<Session>` gives the `&mut` the
84    // run API needs. `tokenizers::Tokenizer` is Send + Sync.
85
86    // Manual Debug because Session/Tokenizer do not implement Debug.
87    impl std::fmt::Debug for OnnxEmbedding {
88        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89            f.debug_struct("OnnxEmbedding")
90                .field("dimensions", &self.dimensions)
91                .field("model_path", &self.model_path)
92                .finish_non_exhaustive()
93        }
94    }
95
96    impl OnnxEmbedding {
97        /// Create a new ONNX embedding provider from a model path.
98        ///
99        /// The model should be an ONNX file for a sentence-transformer model
100        /// (e.g. `all-MiniLM-L6-v2` exported to ONNX format).
101        ///
102        /// A `tokenizer.json` file **must** exist in the same directory as the
103        /// model file. This is the standard layout produced by
104        /// `optimum-cli export onnx` or manual HuggingFace model export.
105        ///
106        /// # Errors
107        ///
108        /// Returns [`Error::Validation`] if the model file does not exist.
109        /// Returns [`Error::Embedding`] if the ONNX session or tokenizer
110        /// fails to load.
111        pub fn new(model_path: &str, dimensions: usize) -> Result<Self> {
112            let model = Path::new(model_path);
113            if !model.exists() {
114                return Err(Error::Validation(format!(
115                    "ONNX model not found at: {model_path}"
116                )));
117            }
118
119            // Locate tokenizer.json next to the model file.
120            let tokenizer_path = model
121                .parent()
122                .map(|p| p.join("tokenizer.json"))
123                .unwrap_or_else(|| Path::new("tokenizer.json").to_path_buf());
124
125            if !tokenizer_path.exists() {
126                return Err(Error::Embedding(format!(
127                    "tokenizer.json not found next to ONNX model (expected at {})",
128                    tokenizer_path.display()
129                )));
130            }
131
132            let session = Session::builder()
133                .map_err(|e| {
134                    Error::Embedding(format!("failed to create ONNX session builder: {e}"))
135                })?
136                .with_intra_threads(4)
137                .map_err(|e| Error::Embedding(format!("failed to set intra threads: {e}")))?
138                .commit_from_file(model_path)
139                .map_err(|e| Error::Embedding(format!("failed to load ONNX model: {e}")))?;
140
141            let tokenizer = Tokenizer::from_file(&tokenizer_path)
142                .map_err(|e| Error::Embedding(format!("failed to load tokenizer: {e}")))?;
143
144            Ok(Self {
145                dimensions,
146                model_path: model_path.to_string(),
147                session: Arc::new(Mutex::new(session)),
148                tokenizer: Arc::new(tokenizer),
149            })
150        }
151
152        /// Get the model path.
153        #[must_use]
154        pub fn model_path(&self) -> &str {
155            &self.model_path
156        }
157
158        /// Tokenize a batch of texts and return (input_ids, attention_mask,
159        /// token_type_ids) as 2-D i64 arrays with shape `[batch, max_len]`.
160        fn tokenize_batch(
161            tokenizer: &Tokenizer,
162            texts: &[&str],
163        ) -> Result<(Array2<i64>, Array2<i64>, Array2<i64>)> {
164            let encodings = tokenizer
165                .encode_batch(texts.to_vec(), true)
166                .map_err(|e| Error::Embedding(format!("tokenization failed: {e}")))?;
167
168            let batch_size = encodings.len();
169            let max_len = encodings
170                .iter()
171                .map(|e| e.get_ids().len())
172                .max()
173                .unwrap_or(0);
174
175            let mut input_ids = Array2::<i64>::zeros((batch_size, max_len));
176            let mut attention_mask = Array2::<i64>::zeros((batch_size, max_len));
177            let mut token_type_ids = Array2::<i64>::zeros((batch_size, max_len));
178
179            for (i, enc) in encodings.iter().enumerate() {
180                for (j, &id) in enc.get_ids().iter().enumerate() {
181                    input_ids[[i, j]] = i64::from(id);
182                }
183                for (j, &mask) in enc.get_attention_mask().iter().enumerate() {
184                    attention_mask[[i, j]] = i64::from(mask);
185                }
186                for (j, &tid) in enc.get_type_ids().iter().enumerate() {
187                    token_type_ids[[i, j]] = i64::from(tid);
188                }
189            }
190
191            Ok((input_ids, attention_mask, token_type_ids))
192        }
193
194        /// Mean-pool the last hidden state over the token dimension, weighted
195        /// by the attention mask, then L2-normalise each vector.
196        fn mean_pool_and_normalize(
197            hidden: &Array2<f32>,
198            mask: &Array2<i64>,
199            batch_size: usize,
200            seq_len: usize,
201            hidden_dim: usize,
202        ) -> Vec<Vec<f32>> {
203            // hidden shape: [batch * seq_len, hidden_dim] (flattened) OR
204            // we receive it already as [batch, hidden_dim] after manual pooling.
205            // We handle the [batch, seq_len, hidden_dim] case by reshaping.
206            let _ = seq_len; // used only for the assertion below
207
208            let mut results = Vec::with_capacity(batch_size);
209
210            for i in 0..batch_size {
211                let mut pooled = vec![0.0f32; hidden_dim];
212                let mut count = 0.0f32;
213
214                for j in 0..seq_len {
215                    let m = mask[[i, j]] as f32;
216                    if m > 0.0 {
217                        for k in 0..hidden_dim {
218                            pooled[k] += hidden[[i * seq_len + j, k]] * m;
219                        }
220                        count += m;
221                    }
222                }
223
224                if count > 0.0 {
225                    for v in &mut pooled {
226                        *v /= count;
227                    }
228                }
229
230                // L2 normalise
231                let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
232                if norm > 0.0 {
233                    for v in &mut pooled {
234                        *v /= norm;
235                    }
236                }
237
238                results.push(pooled);
239            }
240
241            results
242        }
243
244        /// Run inference on a batch of texts. This is the shared
245        /// implementation used by both `embed` and `embed_batch`.
246        async fn run_inference(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
247            if texts.is_empty() {
248                return Ok(Vec::new());
249            }
250
251            let session = Arc::clone(&self.session);
252            let tokenizer = Arc::clone(&self.tokenizer);
253            let dims = self.dimensions;
254            let owned_texts: Vec<String> = texts.iter().map(|t| (*t).to_string()).collect();
255
256            tokio::task::spawn_blocking(move || -> Result<Vec<Vec<f32>>> {
257                let text_refs: Vec<&str> = owned_texts.iter().map(String::as_str).collect();
258                let (input_ids, attention_mask, token_type_ids) =
259                    Self::tokenize_batch(&tokenizer, &text_refs)?;
260
261                let batch_size = input_ids.nrows();
262                let seq_len = input_ids.ncols();
263
264                // `attention_mask` is consumed by the input tensor below but is
265                // still needed for mean-pooling, so keep an owned copy.
266                let mask_for_pool = attention_mask.clone();
267
268                // ort 2.0.0-rc.11: inputs are `Value`s built via
269                // `Tensor::from_array` (an owned ndarray impls
270                // `OwnedTensorArrayData`); the `inputs!` macro returns a `Vec`
271                // (not a `Result`), and `run` takes `&mut self`.
272                let ids_t = Tensor::from_array(input_ids)
273                    .map_err(|e| Error::Embedding(format!("input_ids tensor: {e}")))?;
274                let mask_t = Tensor::from_array(attention_mask)
275                    .map_err(|e| Error::Embedding(format!("attention_mask tensor: {e}")))?;
276                let tt_t = Tensor::from_array(token_type_ids)
277                    .map_err(|e| Error::Embedding(format!("token_type_ids tensor: {e}")))?;
278
279                let mut sess = session
280                    .lock()
281                    .map_err(|e| Error::Embedding(format!("onnx session lock poisoned: {e}")))?;
282                let outputs = sess
283                    .run(ort::inputs![
284                        "input_ids" => ids_t,
285                        "attention_mask" => mask_t,
286                        "token_type_ids" => tt_t,
287                    ])
288                    .map_err(|e| Error::Embedding(format!("ONNX inference failed: {e}")))?;
289
290                // Sentence-transformer models typically output
291                // "last_hidden_state" at index 0 with shape
292                // [batch, seq_len, hidden_dim]. rc.11: `try_extract_array`
293                // returns an `ndarray::ArrayViewD` (the old `try_extract_tensor`
294                // now returns a `(&Shape, &[T])` tuple). `.get()` yields a
295                // `ValueRef` and the iterator yields `&Value`, so each arm
296                // extracts and copies to an owned array to unify the type and
297                // release the borrow on `outputs`.
298                let extract_owned = |e: ort::Error| {
299                    Error::Embedding(format!("failed to extract output tensor: {e}"))
300                };
301                let output_array: ndarray::ArrayD<f32> = match outputs.get("last_hidden_state")
302                {
303                    Some(v) => v.try_extract_array::<f32>().map_err(extract_owned)?.to_owned(),
304                    None => outputs
305                        .iter()
306                        .next()
307                        .ok_or_else(|| {
308                            Error::Embedding("no output tensor from ONNX model".to_string())
309                        })?
310                        .1
311                        .try_extract_array::<f32>()
312                        .map_err(extract_owned)?
313                        .to_owned(),
314                };
315
316                let shape = output_array.shape();
317
318                // Handle different output shapes:
319                // - [batch, seq_len, hidden_dim]: needs mean-pooling
320                // - [batch, hidden_dim]: already pooled (e.g. sentence_embedding output)
321                if shape.len() == 3 {
322                    let hidden_dim = shape[2];
323                    if hidden_dim != dims {
324                        return Err(Error::Embedding(format!(
325                            "model hidden dim ({hidden_dim}) does not match configured dimensions ({dims})"
326                        )));
327                    }
328
329                    // Reshape to [batch * seq_len, hidden_dim] for pooling
330                    let flat = output_array
331                        .to_shape((batch_size * seq_len, hidden_dim))
332                        .map_err(|e| Error::Embedding(format!("reshape failed: {e}")))?;
333
334                    let flat_owned: Array2<f32> = flat.to_owned();
335                    Ok(Self::mean_pool_and_normalize(
336                        &flat_owned,
337                        &mask_for_pool,
338                        batch_size,
339                        seq_len,
340                        hidden_dim,
341                    ))
342                } else if shape.len() == 2 {
343                    // Already pooled output [batch, hidden_dim]
344                    let hidden_dim = shape[1];
345                    if hidden_dim != dims {
346                        return Err(Error::Embedding(format!(
347                            "model hidden dim ({hidden_dim}) does not match configured dimensions ({dims})"
348                        )));
349                    }
350
351                    let mut results = Vec::with_capacity(batch_size);
352                    for i in 0..batch_size {
353                        let mut vec = Vec::with_capacity(hidden_dim);
354                        for j in 0..hidden_dim {
355                            vec.push(output_array[[i, j]]);
356                        }
357                        // L2 normalise
358                        let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
359                        if norm > 0.0 {
360                            for v in &mut vec {
361                                *v /= norm;
362                            }
363                        }
364                        results.push(vec);
365                    }
366                    Ok(results)
367                } else {
368                    Err(Error::Embedding(format!(
369                        "unexpected output tensor shape: {shape:?}"
370                    )))
371                }
372            })
373            .await
374            .map_err(|e| Error::Embedding(format!("inference task panicked: {e}")))?
375        }
376    }
377
378    #[async_trait::async_trait]
379    impl EmbeddingProvider for OnnxEmbedding {
380        /// Generate an embedding vector for a single text input.
381        ///
382        /// Tokenizes the input, runs ONNX inference, applies mean-pooling
383        /// weighted by the attention mask, and L2-normalises the result.
384        ///
385        /// # Errors
386        ///
387        /// Returns [`Error::Embedding`] if tokenization or inference fails.
388        async fn embed(&self, text: &str) -> Result<Vec<f32>> {
389            let mut results = self.run_inference(&[text]).await?;
390            results
391                .pop()
392                .ok_or_else(|| Error::Embedding("empty inference result".to_string()))
393        }
394
395        /// Generate embedding vectors for a batch of text inputs.
396        ///
397        /// Processes all texts in a single batched ONNX inference call for
398        /// maximum throughput.
399        ///
400        /// # Errors
401        ///
402        /// Returns [`Error::Embedding`] if tokenization or inference fails.
403        async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
404            self.run_inference(texts).await
405        }
406
407        fn dimensions(&self) -> usize {
408            self.dimensions
409        }
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Stub implementation (no onnx feature)
415// ---------------------------------------------------------------------------
416#[cfg(not(feature = "onnx"))]
417mod inner {
418    use super::*;
419
420    /// ONNX-based local embedding provider.
421    ///
422    /// Wraps an ONNX sentence-transformer model for on-device vector generation.
423    /// When the `onnx` feature is not enabled, `embed` and `embed_batch` return
424    /// an [`Error::Embedding`] explaining how to enable full inference.
425    #[derive(Debug)]
426    pub struct OnnxEmbedding {
427        dimensions: usize,
428        model_path: String,
429        // In a full implementation, this would hold:
430        // session: ort::Session,
431        // tokenizer: tokenizers::Tokenizer,
432    }
433
434    impl OnnxEmbedding {
435        /// Create a new ONNX embedding provider from a model path.
436        ///
437        /// The model should be an ONNX sentence-transformer model
438        /// (e.g., `all-MiniLM-L6-v2` exported to ONNX format).
439        ///
440        /// # Errors
441        ///
442        /// Returns [`Error::Validation`] if the file at `model_path` does not
443        /// exist on disk.
444        pub fn new(model_path: &str, dimensions: usize) -> Result<Self> {
445            if !std::path::Path::new(model_path).exists() {
446                return Err(Error::Validation(format!(
447                    "ONNX model not found at: {model_path}"
448                )));
449            }
450            Ok(Self {
451                dimensions,
452                model_path: model_path.to_string(),
453            })
454        }
455
456        /// Get the model path.
457        #[must_use]
458        pub fn model_path(&self) -> &str {
459            &self.model_path
460        }
461    }
462
463    #[async_trait::async_trait]
464    impl EmbeddingProvider for OnnxEmbedding {
465        /// Generate an embedding vector for a single text input.
466        ///
467        /// # Errors
468        ///
469        /// Currently returns [`Error::Embedding`] because full ONNX Runtime
470        /// inference requires the `onnx` feature (with `ort`, `tokenizers`,
471        /// and `ndarray` crates).
472        async fn embed(&self, _text: &str) -> Result<Vec<f32>> {
473            Err(Error::Embedding(
474                "ONNX Runtime not available: compile with full onnx dependencies \
475                 (ort, tokenizers, ndarray) to enable local inference"
476                    .to_string(),
477            ))
478        }
479
480        /// Generate embedding vectors for a batch of text inputs.
481        ///
482        /// # Errors
483        ///
484        /// Currently returns [`Error::Embedding`] because full ONNX Runtime
485        /// inference requires the `onnx` feature (with `ort`, `tokenizers`,
486        /// and `ndarray` crates).
487        async fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>> {
488            Err(Error::Embedding(
489                "ONNX Runtime not available: compile with full onnx dependencies \
490                 (ort, tokenizers, ndarray) to enable local inference"
491                    .to_string(),
492            ))
493        }
494
495        fn dimensions(&self) -> usize {
496            self.dimensions
497        }
498    }
499}
500
501// Re-export `OnnxEmbedding` from the active inner module so that
502// downstream code can use `crate::embedding::onnx::OnnxEmbedding`
503// regardless of the feature flag.
504pub use inner::OnnxEmbedding;
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_onnx_missing_model() {
512        let result = OnnxEmbedding::new("/nonexistent/path/model.onnx", 384);
513        assert!(result.is_err());
514        let err = result.unwrap_err();
515        let msg = err.to_string();
516        assert!(
517            msg.contains("ONNX model not found"),
518            "unexpected error message: {msg}"
519        );
520    }
521
522    #[test]
523    fn test_onnx_dimensions() {
524        // Use Cargo.toml as a stand-in file that is guaranteed to exist.
525        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
526        #[cfg(not(feature = "onnx"))]
527        {
528            let provider = OnnxEmbedding::new(path, 384).expect("file should exist");
529            assert_eq!(provider.dimensions(), 384);
530        }
531        // When the onnx feature is on, construction also requires
532        // tokenizer.json, so we only test that the path validation
533        // passes for the stub variant.
534        #[cfg(feature = "onnx")]
535        {
536            // Without a tokenizer.json next to Cargo.toml, we expect an
537            // embedding error rather than a validation error.
538            let result = OnnxEmbedding::new(path, 384);
539            assert!(result.is_err());
540            let msg = result.unwrap_err().to_string();
541            assert!(
542                msg.contains("tokenizer.json"),
543                "expected tokenizer.json error, got: {msg}"
544            );
545        }
546    }
547
548    #[test]
549    fn test_onnx_model_path() {
550        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
551        #[cfg(not(feature = "onnx"))]
552        {
553            let provider = OnnxEmbedding::new(path, 768).expect("file should exist");
554            assert_eq!(provider.model_path(), path);
555        }
556        #[cfg(feature = "onnx")]
557        {
558            let result = OnnxEmbedding::new(path, 768);
559            assert!(result.is_err());
560        }
561    }
562
563    // End-to-end real-inference test (issue #125). Proves the `onnx` feature
564    // does ACTUAL work — tokenize -> ort inference -> mean-pool -> L2-normalise
565    // -> a sane embedding — not just that it compiles or that construction errors
566    // without a model. That distinction is the whole point of #125: a feature
567    // that builds but never demonstrably runs is the same class of latent defect
568    // as the Postgres semantic-recall stub fixed in v0.5.7.
569    //
570    // Gated on MNEMO_ONNX_MODEL_PATH so `cargo test --features onnx` stays green
571    // without a model on disk; the dedicated `onnx feature` CI job downloads
572    // all-MiniLM-L6-v2 and sets the var, so CI exercises this path on every push.
573    // Run locally:
574    //   MNEMO_ONNX_MODEL_PATH=/path/to/all-MiniLM-L6-v2/model.onnx \
575    //     cargo test -p mnemo-core --features onnx real_inference -- --nocapture
576    #[cfg(feature = "onnx")]
577    #[tokio::test]
578    async fn test_onnx_real_inference_end_to_end() {
579        let model_path = match std::env::var("MNEMO_ONNX_MODEL_PATH") {
580            Ok(p) => p,
581            Err(_) => {
582                eprintln!(
583                    "skipping onnx e2e inference: set MNEMO_ONNX_MODEL_PATH to an \
584                     all-MiniLM-L6-v2 model.onnx (tokenizer.json alongside) to run it"
585                );
586                return;
587            }
588        };
589        // all-MiniLM-L6-v2 is 384-dim.
590        let provider =
591            OnnxEmbedding::new(&model_path, 384).expect("onnx model + tokenizer.json should load");
592
593        let a = provider
594            .embed("the cat sat on the mat")
595            .await
596            .expect("embed a");
597        let b = provider
598            .embed("quarterly revenue guidance for the fiscal year")
599            .await
600            .expect("embed b");
601
602        assert_eq!(a.len(), 384, "embedding dimensionality");
603        assert!(a.iter().all(|x| x.is_finite()), "all components finite");
604        assert!(
605            a.iter().any(|&x| x.abs() > 1e-4),
606            "embedding is non-zero (real signal, not a NoopEmbedding-style constant)"
607        );
608        let norm = a.iter().map(|x| x * x).sum::<f32>().sqrt();
609        assert!((norm - 1.0).abs() < 1e-2, "L2-normalised (norm={norm})");
610        // Distinct sentences must not collapse to near-identical vectors.
611        let cos = a.iter().zip(&b).map(|(x, y)| x * y).sum::<f32>();
612        assert!(
613            cos < 0.99,
614            "distinct sentences must differ (cos={cos}) — proves real inference"
615        );
616    }
617
618    #[cfg(not(feature = "onnx"))]
619    #[tokio::test]
620    async fn test_onnx_embed_returns_error_without_runtime() {
621        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
622        let provider = OnnxEmbedding::new(path, 384).expect("file should exist");
623        let result = provider.embed("hello world").await;
624        assert!(result.is_err());
625        let msg = result.unwrap_err().to_string();
626        assert!(
627            msg.contains("ONNX Runtime not available"),
628            "unexpected error: {msg}"
629        );
630    }
631
632    #[cfg(not(feature = "onnx"))]
633    #[tokio::test]
634    async fn test_onnx_embed_batch_returns_error_without_runtime() {
635        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
636        let provider = OnnxEmbedding::new(path, 384).expect("file should exist");
637        let result = provider.embed_batch(&["a", "b"]).await;
638        assert!(result.is_err());
639        let msg = result.unwrap_err().to_string();
640        assert!(
641            msg.contains("ONNX Runtime not available"),
642            "unexpected error: {msg}"
643        );
644    }
645}