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