1use 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
20pub const MODEL_ID: &str = "sentence-transformers/all-MiniLM-L6-v2";
26
27const MODEL_REPO: &str = MODEL_ID;
29
30pub const EMBEDDING_DIM: usize = 384;
32
33#[derive(Debug, thiserror::Error)]
35pub enum EmbedError {
36 #[error("no user cache directory available to cache '{model}'")]
40 NoCacheDir {
41 model: &'static str,
43 },
44 #[error("failed to initialize the Hugging Face Hub client: {0}")]
46 HubClient(#[source] hf_hub::api::sync::ApiError),
47 #[error("failed to fetch '{file}' from '{repo}': {source}")]
49 Fetch {
50 repo: &'static str,
52 file: &'static str,
54 #[source]
56 source: hf_hub::api::sync::ApiError,
57 },
58 #[error("failed to read '{path}': {source}")]
60 ReadFile {
61 path: String,
63 #[source]
65 source: std::io::Error,
66 },
67 #[error("failed to parse model config: {0}")]
70 Config(#[from] serde_json::Error),
71 #[error("failed to load tokenizer: {0}")]
73 LoadTokenizer(#[source] tokenizers::Error),
74 #[error("failed to load model weights: {0}")]
76 Model(#[source] candle_core::Error),
77 #[error("failed to tokenize input text: {0}")]
79 Tokenize(#[source] tokenizers::Error),
80 #[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
222pub struct Embedder {
231 model: BertModel,
232 tokenizer: Tokenizer,
233 device: Device,
234}
235
236impl std::fmt::Debug for Embedder {
237 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 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 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
320fn 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
329fn 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
337fn 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
345fn 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 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}