Skip to main content

mif_embed/
lib.rs

1//! Local sentence-embedding inference for the [MIF (Modeled Information
2//! Format)](https://mif-spec.dev) ecosystem, via [`candle`](https://github.com/huggingface/candle).
3//!
4//! [`Embedder`] loads `sentence-transformers/all-MiniLM-L6-v2` (384-dimensional,
5//! mean-pooled, L2-normalized sentence embeddings) from the Hugging Face Hub on
6//! first use, caching the model files under the platform cache directory so
7//! later runs are offline. Inference runs on CPU only.
8
9use std::path::PathBuf;
10
11use candle_core::{DType, Device, Tensor};
12use candle_nn::VarBuilder;
13use candle_transformers::models::bert::{BertModel, Config as BertConfig, DTYPE};
14use hf_hub::api::sync::{Api, ApiBuilder};
15use mif_problem::{
16    Applicability, CodeAction, ProblemDetails, ProblemMeta, SuggestedFix, ToProblem,
17};
18use tokenizers::{Tokenizer, TruncationParams};
19
20/// Hugging Face Hub repository providing the model this crate embeds with.
21const MODEL_REPO: &str = "sentence-transformers/all-MiniLM-L6-v2";
22
23/// Output embedding dimensionality of `sentence-transformers/all-MiniLM-L6-v2`.
24pub const EMBEDDING_DIM: usize = 384;
25
26/// Errors from loading the embedding model or running inference.
27#[derive(Debug, thiserror::Error)]
28pub enum EmbedError {
29    /// The platform has no resolvable user cache directory
30    /// (`dirs::cache_dir()` returned `None`), so the model has nowhere to be
31    /// cached.
32    #[error("no user cache directory available to cache '{model}'")]
33    NoCacheDir {
34        /// The Hugging Face Hub repository that could not be cached.
35        model: &'static str,
36    },
37    /// Failed to initialize the Hugging Face Hub API client.
38    #[error("failed to initialize the Hugging Face Hub client: {0}")]
39    HubClient(#[source] hf_hub::api::sync::ApiError),
40    /// Failed to fetch a required model file from the Hugging Face Hub.
41    #[error("failed to fetch '{file}' from '{repo}': {source}")]
42    Fetch {
43        /// The Hugging Face Hub repository the file was fetched from.
44        repo: &'static str,
45        /// The name of the file that failed to fetch.
46        file: &'static str,
47        /// The underlying Hub API error.
48        #[source]
49        source: hf_hub::api::sync::ApiError,
50    },
51    /// Failed to read a locally cached model file.
52    #[error("failed to read '{path}': {source}")]
53    ReadFile {
54        /// The path that failed to read.
55        path: String,
56        /// The underlying I/O error.
57        #[source]
58        source: std::io::Error,
59    },
60    /// The model's `config.json` was not valid JSON or did not match the
61    /// expected BERT config shape.
62    #[error("failed to parse model config: {0}")]
63    Config(#[from] serde_json::Error),
64    /// Failed to load the tokenizer definition from `tokenizer.json`.
65    #[error("failed to load tokenizer: {0}")]
66    LoadTokenizer(#[source] tokenizers::Error),
67    /// Failed to load the model weights into the BERT architecture.
68    #[error("failed to load model weights: {0}")]
69    Model(#[source] candle_core::Error),
70    /// Failed to tokenize input text.
71    #[error("failed to tokenize input text: {0}")]
72    Tokenize(#[source] tokenizers::Error),
73    /// A tensor operation failed during inference.
74    #[error("model inference failed: {0}")]
75    Inference(#[from] candle_core::Error),
76}
77
78impl EmbedError {
79    const fn meta(&self) -> ProblemMeta {
80        match self {
81            Self::NoCacheDir { .. } => ProblemMeta {
82                slug: "no-cache-dir",
83                version: "v1",
84                title: "No user cache directory available",
85                status: 500,
86                exit_code: 1,
87            },
88            Self::HubClient(_) => ProblemMeta {
89                slug: "hub-client-init-failure",
90                version: "v1",
91                title: "Failed to initialize the Hugging Face Hub client",
92                status: 500,
93                exit_code: 1,
94            },
95            Self::Fetch { .. } => ProblemMeta {
96                slug: "model-fetch-failure",
97                version: "v1",
98                title: "Failed to fetch a model file from the Hugging Face Hub",
99                status: 503,
100                exit_code: 1,
101            },
102            Self::ReadFile { .. } => ProblemMeta {
103                slug: "read-cached-model-file-failure",
104                version: "v1",
105                title: "Failed to read a locally cached model file",
106                status: 500,
107                exit_code: 1,
108            },
109            Self::Config(_) => ProblemMeta {
110                slug: "invalid-model-config",
111                version: "v1",
112                title: "Model config.json is not valid or unexpected",
113                status: 500,
114                exit_code: 1,
115            },
116            Self::LoadTokenizer(_) => ProblemMeta {
117                slug: "load-tokenizer-failure",
118                version: "v1",
119                title: "Failed to load the tokenizer definition",
120                status: 500,
121                exit_code: 1,
122            },
123            Self::Model(_) => ProblemMeta {
124                slug: "load-model-weights-failure",
125                version: "v1",
126                title: "Failed to load model weights",
127                status: 500,
128                exit_code: 1,
129            },
130            Self::Tokenize(_) => ProblemMeta {
131                slug: "tokenize-failure",
132                version: "v1",
133                title: "Failed to tokenize input text",
134                status: 422,
135                exit_code: 2,
136            },
137            Self::Inference(_) => ProblemMeta {
138                slug: "inference-failure",
139                version: "v1",
140                title: "Model inference failed",
141                status: 500,
142                exit_code: 1,
143            },
144        }
145    }
146}
147
148impl ToProblem for EmbedError {
149    fn to_problem(&self) -> ProblemDetails {
150        let internal = || {
151            (
152                SuggestedFix::new(
153                    "This indicates an internal problem with mif-embed or the cached model \
154                     files. Try clearing the model cache directory and retrying, or report it \
155                     upstream if the problem persists.",
156                    Applicability::Unspecified,
157                ),
158                CodeAction::new(
159                    "Clear the model cache and retry",
160                    "quickfix",
161                    Applicability::Unspecified,
162                ),
163            )
164        };
165
166        let mut problem = self
167            .meta()
168            .into_details(env!("CARGO_PKG_NAME"), self.to_string());
169
170        match self {
171            Self::ReadFile { source, .. } => {
172                let (status, fix, action) = mif_problem::classify_io_error(source);
173                problem.status = status;
174                problem.with_suggested_fix(fix).with_code_action(action)
175            },
176            Self::Fetch { .. } => {
177                let (fix, action) = (
178                    SuggestedFix::new(
179                        "Check network connectivity to huggingface.co and retry.",
180                        Applicability::MaybeIncorrect,
181                    ),
182                    CodeAction::new(
183                        "Retry the model fetch",
184                        "quickfix",
185                        Applicability::MaybeIncorrect,
186                    ),
187                );
188                problem
189                    .with_retry_after(30)
190                    .with_suggested_fix(fix)
191                    .with_code_action(action)
192            },
193            Self::Tokenize(_) => {
194                let (fix, action) = (
195                    SuggestedFix::new(
196                        "Supply text the tokenizer can encode (valid UTF-8, non-empty).",
197                        Applicability::MaybeIncorrect,
198                    ),
199                    CodeAction::new(
200                        "Fix the input text",
201                        "quickfix",
202                        Applicability::MaybeIncorrect,
203                    ),
204                );
205                problem.with_suggested_fix(fix).with_code_action(action)
206            },
207            _ => {
208                let (fix, action) = internal();
209                problem.with_suggested_fix(fix).with_code_action(action)
210            },
211        }
212    }
213}
214
215/// Loads `sentence-transformers/all-MiniLM-L6-v2` once and computes
216/// normalized sentence embeddings from it.
217///
218/// Construction fetches the model's `config.json`, `tokenizer.json`, and
219/// `model.safetensors` from the Hugging Face Hub on first use (cached under
220/// the platform cache directory afterward), and loads them into a CPU-only
221/// `candle` BERT model. Reuse one `Embedder` across calls to [`Self::embed`]
222/// rather than reloading the model per call.
223pub struct Embedder {
224    model: BertModel,
225    tokenizer: Tokenizer,
226    device: Device,
227}
228
229impl std::fmt::Debug for Embedder {
230    // `candle_transformers::models::bert::BertModel` does not implement
231    // `Debug`, so this is hand-written rather than derived.
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        f.debug_struct("Embedder")
234            .field("device", &self.device)
235            .finish_non_exhaustive()
236    }
237}
238
239impl Embedder {
240    /// Fetches (or loads from cache) and initializes the embedding model.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`EmbedError`] if the platform cache directory cannot be
245    /// resolved, the model files cannot be fetched from the Hugging Face Hub,
246    /// or the fetched files cannot be parsed/loaded as a BERT model.
247    pub fn load() -> Result<Self, EmbedError> {
248        let cache_dir = dirs::cache_dir()
249            .map(|dir| dir.join("mif").join("models"))
250            .ok_or(EmbedError::NoCacheDir { model: MODEL_REPO })?;
251        let api = ApiBuilder::new()
252            .with_cache_dir(cache_dir)
253            .build()
254            .map_err(EmbedError::HubClient)?;
255        let repo = Api::model(&api, MODEL_REPO.to_string());
256
257        let config_path = fetch(&repo, "config.json")?;
258        let tokenizer_path = fetch(&repo, "tokenizer.json")?;
259        let weights_path = fetch(&repo, "model.safetensors")?;
260
261        let config_text = read_to_string(&config_path)?;
262        let config: BertConfig = serde_json::from_str(&config_text)?;
263
264        let mut tokenizer =
265            Tokenizer::from_file(&tokenizer_path).map_err(EmbedError::LoadTokenizer)?;
266        tokenizer
267            .with_truncation(Some(TruncationParams {
268                max_length: config.max_position_embeddings,
269                ..TruncationParams::default()
270            }))
271            .map_err(EmbedError::LoadTokenizer)?;
272
273        let weights = read(&weights_path)?;
274        let device = Device::Cpu;
275        let vb = VarBuilder::from_buffered_safetensors(weights, DTYPE, &device)
276            .map_err(EmbedError::Model)?;
277        let model = BertModel::load(vb, &config).map_err(EmbedError::Model)?;
278
279        Ok(Self {
280            model,
281            tokenizer,
282            device,
283        })
284    }
285
286    /// Computes a 384-dimensional, mean-pooled, L2-normalized sentence
287    /// embedding for `text`.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`EmbedError`] if tokenization or model inference fails.
292    pub fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
293        let encoding = self
294            .tokenizer
295            .encode(text, true)
296            .map_err(EmbedError::Tokenize)?;
297
298        let input_ids = Tensor::new(encoding.get_ids(), &self.device)?.unsqueeze(0)?;
299        let token_type_ids = Tensor::new(encoding.get_type_ids(), &self.device)?.unsqueeze(0)?;
300        let attention_mask =
301            Tensor::new(encoding.get_attention_mask(), &self.device)?.unsqueeze(0)?;
302
303        let sequence_output =
304            self.model
305                .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
306
307        mean_pool(&sequence_output, &attention_mask)?
308            .to_vec1::<f32>()
309            .map_err(EmbedError::Inference)
310    }
311}
312
313/// Fetches `file` from `repo`, caching it locally.
314fn fetch(repo: &hf_hub::api::sync::ApiRepo, file: &'static str) -> Result<PathBuf, EmbedError> {
315    repo.get(file).map_err(|source| EmbedError::Fetch {
316        repo: MODEL_REPO,
317        file,
318        source,
319    })
320}
321
322/// Reads a cached model file as UTF-8 text.
323fn read_to_string(path: &std::path::Path) -> Result<String, EmbedError> {
324    std::fs::read_to_string(path).map_err(|source| EmbedError::ReadFile {
325        path: path.display().to_string(),
326        source,
327    })
328}
329
330/// Reads a cached model file as raw bytes.
331fn read(path: &std::path::Path) -> Result<Vec<u8>, EmbedError> {
332    std::fs::read(path).map_err(|source| EmbedError::ReadFile {
333        path: path.display().to_string(),
334        source,
335    })
336}
337
338/// Mean-pools `sequence_output` (`(1, seq_len, hidden)`) over non-padding
339/// tokens per `attention_mask` (`(1, seq_len)`), then L2-normalizes the
340/// result, returning a `(hidden,)` tensor.
341fn mean_pool(sequence_output: &Tensor, attention_mask: &Tensor) -> Result<Tensor, EmbedError> {
342    let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?;
343    let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?;
344    let counts = mask.sum(1)?;
345    let pooled = summed.broadcast_div(&counts)?.squeeze(0)?;
346
347    let norm = pooled.sqr()?.sum_keepdim(0)?.sqrt()?;
348    Ok(pooled.broadcast_div(&norm)?)
349}
350
351#[cfg(test)]
352mod tests {
353    use mif_problem::{Applicability, ToProblem};
354
355    use super::{EMBEDDING_DIM, EmbedError, Embedder};
356
357    #[test]
358    fn read_file_error_status_is_classified_by_the_underlying_error_kind() {
359        let not_found = EmbedError::ReadFile {
360            path: "/nonexistent/config.json".to_string(),
361            source: std::io::Error::from(std::io::ErrorKind::NotFound),
362        }
363        .to_problem();
364        assert_eq!(not_found.status, 404);
365        assert_eq!(
366            not_found.suggested_fix.unwrap().applicability,
367            Applicability::MaybeIncorrect
368        );
369
370        let generic_fault = EmbedError::ReadFile {
371            path: "/cache/config.json".to_string(),
372            source: std::io::Error::from(std::io::ErrorKind::Other),
373        }
374        .to_problem();
375        assert_eq!(generic_fault.status, 500);
376        assert_eq!(
377            generic_fault.suggested_fix.unwrap().applicability,
378            Applicability::Unspecified
379        );
380    }
381
382    #[test]
383    fn remaining_error_variants_map_to_their_own_slug_and_status() {
384        let cases: [(EmbedError, &str, u16); 5] = [
385            (
386                EmbedError::HubClient(hf_hub::api::sync::ApiError::LockAcquisition(
387                    std::path::PathBuf::from("/tmp/lock"),
388                )),
389                "hub-client-init-failure",
390                500,
391            ),
392            (
393                EmbedError::Config(serde_json::from_str::<i32>("not json").unwrap_err()),
394                "invalid-model-config",
395                500,
396            ),
397            (
398                EmbedError::LoadTokenizer(tokenizers::Error::from("bad tokenizer definition")),
399                "load-tokenizer-failure",
400                500,
401            ),
402            (
403                EmbedError::Model(candle_core::Error::msg("bad weights")),
404                "load-model-weights-failure",
405                500,
406            ),
407            (
408                EmbedError::Inference(candle_core::Error::msg("bad tensor op")),
409                "inference-failure",
410                500,
411            ),
412        ];
413
414        for (error, slug, status) in cases {
415            let problem = error.to_problem();
416            assert_eq!(
417                problem.problem_type,
418                format!(
419                    "https://modeled-information-format.github.io/mif-rs/references/errors/{slug}/v1"
420                )
421            );
422            assert_eq!(problem.status, status);
423            assert_eq!(
424                problem.suggested_fix.unwrap().applicability,
425                Applicability::Unspecified
426            );
427        }
428    }
429
430    #[test]
431    fn tokenize_error_maps_to_a_client_side_status_and_input_fix() {
432        let problem = EmbedError::Tokenize(tokenizers::Error::from("bad input text")).to_problem();
433        assert_eq!(
434            problem.problem_type,
435            "https://modeled-information-format.github.io/mif-rs/references/errors/tokenize-failure/v1"
436        );
437        assert_eq!(problem.status, 422);
438        assert_eq!(
439            problem.suggested_fix.unwrap().applicability,
440            Applicability::MaybeIncorrect
441        );
442    }
443
444    #[test]
445    fn read_to_string_wraps_a_missing_file_as_a_read_file_variant() {
446        let path = std::path::Path::new("/nonexistent-mif-embed-test-path/config.json");
447        let err = super::read_to_string(path).unwrap_err();
448        assert!(matches!(err, EmbedError::ReadFile { .. }));
449    }
450
451    #[test]
452    fn read_wraps_a_missing_file_as_a_read_file_variant() {
453        let path = std::path::Path::new("/nonexistent-mif-embed-test-path/model.safetensors");
454        let err = super::read(path).unwrap_err();
455        assert!(matches!(err, EmbedError::ReadFile { .. }));
456    }
457
458    #[test]
459    fn distinct_error_variants_map_to_distinct_problem_types() {
460        let fetch = EmbedError::Fetch {
461            repo: "sentence-transformers/all-MiniLM-L6-v2",
462            file: "config.json",
463            source: hf_hub::api::sync::ApiError::LockAcquisition(std::path::PathBuf::from(
464                "/tmp/lock",
465            )),
466        }
467        .to_problem();
468        assert_eq!(
469            fetch.problem_type,
470            "https://modeled-information-format.github.io/mif-rs/references/errors/model-fetch-failure/v1"
471        );
472        assert_eq!(fetch.status, 503);
473        assert_eq!(fetch.retry_after, Some(30));
474
475        let no_cache = EmbedError::NoCacheDir {
476            model: "sentence-transformers/all-MiniLM-L6-v2",
477        }
478        .to_problem();
479        assert_eq!(
480            no_cache.problem_type,
481            "https://modeled-information-format.github.io/mif-rs/references/errors/no-cache-dir/v1"
482        );
483        assert_eq!(no_cache.retry_after, None);
484        assert_ne!(fetch.problem_type, no_cache.problem_type);
485    }
486
487    // `cargo test` runs tests in parallel threads within one process. Every
488    // test below that calls `Embedder::load()` races the others to download
489    // and lock the same model blob on a cold cache — `hf-hub`'s lock
490    // acquisition is not reliably concurrent across platforms. Warming the
491    // cache once, serialized through `Once`, means every real
492    // `Embedder::load()` call below hits an already-populated cache and
493    // never contends on the download lock.
494    fn warm_embedding_model_cache() {
495        static ONCE: std::sync::Once = std::sync::Once::new();
496        ONCE.call_once(|| {
497            let _ = Embedder::load();
498        });
499    }
500
501    #[test]
502    fn embedder_debug_output_names_the_type_without_dumping_internal_fields() {
503        warm_embedding_model_cache();
504        let embedder = match Embedder::load() {
505            Ok(embedder) => embedder,
506            Err(err) => {
507                eprintln!("skipping: could not load embedding model: {err}");
508                return;
509            },
510        };
511
512        let debug_output = format!("{embedder:?}");
513        assert!(debug_output.starts_with("Embedder"));
514        assert!(debug_output.contains("device"));
515    }
516
517    #[test]
518    fn embed_produces_a_unit_norm_384_dim_vector() {
519        warm_embedding_model_cache();
520        let embedder = match Embedder::load() {
521            Ok(embedder) => embedder,
522            Err(err) => {
523                eprintln!("skipping: could not load embedding model: {err}");
524                return;
525            },
526        };
527
528        let embedding = match embedder.embed("The quick brown fox jumps over the lazy dog.") {
529            Ok(embedding) => embedding,
530            Err(err) => {
531                eprintln!("skipping: could not run inference: {err}");
532                return;
533            },
534        };
535
536        assert_eq!(embedding.len(), EMBEDDING_DIM);
537        let norm: f32 = embedding.iter().map(|v| v * v).sum::<f32>().sqrt();
538        assert!((norm - 1.0).abs() < 1e-3, "expected unit norm, got {norm}");
539    }
540}