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}