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 distinct_error_variants_map_to_distinct_problem_types() {
384 let fetch = EmbedError::Fetch {
385 repo: "sentence-transformers/all-MiniLM-L6-v2",
386 file: "config.json",
387 source: hf_hub::api::sync::ApiError::LockAcquisition(std::path::PathBuf::from(
388 "/tmp/lock",
389 )),
390 }
391 .to_problem();
392 assert_eq!(
393 fetch.problem_type,
394 "https://mif-spec.dev/errors/model-fetch-failure/v1"
395 );
396 assert_eq!(fetch.status, 503);
397 assert_eq!(fetch.retry_after, Some(30));
398
399 let no_cache = EmbedError::NoCacheDir {
400 model: "sentence-transformers/all-MiniLM-L6-v2",
401 }
402 .to_problem();
403 assert_eq!(
404 no_cache.problem_type,
405 "https://mif-spec.dev/errors/no-cache-dir/v1"
406 );
407 assert_eq!(no_cache.retry_after, None);
408 assert_ne!(fetch.problem_type, no_cache.problem_type);
409 }
410
411 #[test]
412 fn embed_produces_a_unit_norm_384_dim_vector() {
413 let embedder = match Embedder::load() {
414 Ok(embedder) => embedder,
415 Err(err) => {
416 eprintln!("skipping: could not load embedding model: {err}");
417 return;
418 },
419 };
420
421 let embedding = match embedder.embed("The quick brown fox jumps over the lazy dog.") {
422 Ok(embedding) => embedding,
423 Err(err) => {
424 eprintln!("skipping: could not run inference: {err}");
425 return;
426 },
427 };
428
429 assert_eq!(embedding.len(), EMBEDDING_DIM);
430 let norm: f32 = embedding.iter().map(|v| v * v).sum::<f32>().sqrt();
431 assert!((norm - 1.0).abs() < 1e-3, "expected unit norm, got {norm}");
432 }
433}