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
20const MODEL_REPO: &str = "sentence-transformers/all-MiniLM-L6-v2";
22
23pub const EMBEDDING_DIM: usize = 384;
25
26#[derive(Debug, thiserror::Error)]
28pub enum EmbedError {
29 #[error("no user cache directory available to cache '{model}'")]
33 NoCacheDir {
34 model: &'static str,
36 },
37 #[error("failed to initialize the Hugging Face Hub client: {0}")]
39 HubClient(#[source] hf_hub::api::sync::ApiError),
40 #[error("failed to fetch '{file}' from '{repo}': {source}")]
42 Fetch {
43 repo: &'static str,
45 file: &'static str,
47 #[source]
49 source: hf_hub::api::sync::ApiError,
50 },
51 #[error("failed to read '{path}': {source}")]
53 ReadFile {
54 path: String,
56 #[source]
58 source: std::io::Error,
59 },
60 #[error("failed to parse model config: {0}")]
63 Config(#[from] serde_json::Error),
64 #[error("failed to load tokenizer: {0}")]
66 LoadTokenizer(#[source] tokenizers::Error),
67 #[error("failed to load model weights: {0}")]
69 Model(#[source] candle_core::Error),
70 #[error("failed to tokenize input text: {0}")]
72 Tokenize(#[source] tokenizers::Error),
73 #[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
215pub struct Embedder {
224 model: BertModel,
225 tokenizer: Tokenizer,
226 device: Device,
227}
228
229impl std::fmt::Debug for Embedder {
230 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 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 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
313fn 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
322fn 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
330fn 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
338fn 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 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}