Skip to main content

remem/retrieval/embedding/
local_semantic.rs

1#[cfg(feature = "local-onnx")]
2use std::cell::RefCell;
3#[cfg(feature = "local-onnx")]
4use std::collections::{hash_map::Entry, HashMap};
5use std::path::{Component, Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::{EmbeddingConfig, TextEmbedding};
12
13pub(super) const DEFAULT_LOCAL_SEMANTIC_DIMENSIONS: usize = 384;
14pub(super) const DEFAULT_LOCAL_SEMANTIC_MODEL: &str = "fastembed-intfloat-multilingual-e5-small-v1";
15
16const MANIFEST_FILE: &str = "remem-model-manifest.json";
17const MANIFEST_SCHEMA_VERSION: u32 = 1;
18const FASTEMBED_RUNTIME: &str = "fastembed-rs/onnxruntime";
19const HUGGING_FACE_BASE_URL: &str = "https://huggingface.co";
20
21#[derive(Debug)]
22struct LocalEmbeddingModelUnavailableError(String);
23
24impl std::fmt::Display for LocalEmbeddingModelUnavailableError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.write_str(&self.0)
27    }
28}
29
30impl std::error::Error for LocalEmbeddingModelUnavailableError {}
31
32pub(super) fn is_model_unavailable_error(error: &anyhow::Error) -> bool {
33    error
34        .downcast_ref::<LocalEmbeddingModelUnavailableError>()
35        .is_some()
36}
37
38pub(super) fn model_unavailable_error(reason: impl Into<String>) -> anyhow::Error {
39    LocalEmbeddingModelUnavailableError(reason.into()).into()
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(super) enum LocalEmbeddingInputKind {
44    Query,
45    Passage,
46    Generic,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub(super) enum LocalEmbeddingPreset {
51    MultilingualE5Small,
52    BgeM3,
53}
54
55#[cfg(feature = "local-onnx")]
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57struct LocalModelCacheKey {
58    preset: LocalEmbeddingPreset,
59    install_dir: PathBuf,
60}
61
62#[cfg(feature = "local-onnx")]
63thread_local! {
64    static LOCAL_MODEL_CACHE: RefCell<HashMap<LocalModelCacheKey, fastembed::TextEmbedding>> =
65        RefCell::new(HashMap::new());
66}
67
68impl LocalEmbeddingPreset {
69    fn all() -> &'static [Self] {
70        &[Self::MultilingualE5Small, Self::BgeM3]
71    }
72
73    fn default() -> Self {
74        Self::MultilingualE5Small
75    }
76
77    fn parse(raw: &str) -> Result<Self> {
78        match raw.trim().to_ascii_lowercase().as_str() {
79            "" => Ok(Self::default()),
80            "multilingual-e5-small"
81            | "intfloat/multilingual-e5-small"
82            | DEFAULT_LOCAL_SEMANTIC_MODEL => Ok(Self::MultilingualE5Small),
83            "bge-m3" | "baai/bge-m3" | "fastembed-bge-m3-v1" => Ok(Self::BgeM3),
84            other => bail!(
85                "unsupported local embedding model preset {other}; supported presets: multilingual-e5-small, bge-m3"
86            ),
87        }
88    }
89
90    fn label(self) -> &'static str {
91        match self {
92            Self::MultilingualE5Small => "multilingual-e5-small",
93            Self::BgeM3 => "bge-m3",
94        }
95    }
96
97    fn model_id(self) -> &'static str {
98        match self {
99            Self::MultilingualE5Small => DEFAULT_LOCAL_SEMANTIC_MODEL,
100            Self::BgeM3 => "fastembed-bge-m3-v1",
101        }
102    }
103
104    fn upstream_model(self) -> &'static str {
105        match self {
106            Self::MultilingualE5Small => "intfloat/multilingual-e5-small",
107            Self::BgeM3 => "BAAI/bge-m3",
108        }
109    }
110
111    fn source_url(self) -> String {
112        format!("{HUGGING_FACE_BASE_URL}/{}", self.upstream_model())
113    }
114
115    fn dimensions(self) -> usize {
116        match self {
117            Self::MultilingualE5Small => DEFAULT_LOCAL_SEMANTIC_DIMENSIONS,
118            Self::BgeM3 => 1024,
119        }
120    }
121
122    #[cfg(feature = "local-onnx")]
123    fn prefix_input(self, text: &str, kind: LocalEmbeddingInputKind) -> String {
124        match (self, kind) {
125            (Self::MultilingualE5Small, LocalEmbeddingInputKind::Query) => {
126                format!("query: {text}")
127            }
128            (Self::MultilingualE5Small, LocalEmbeddingInputKind::Passage) => {
129                format!("passage: {text}")
130            }
131            _ => text.to_string(),
132        }
133    }
134
135    #[cfg(feature = "local-onnx")]
136    fn fastembed_model(self) -> fastembed::EmbeddingModel {
137        match self {
138            Self::MultilingualE5Small => fastembed::EmbeddingModel::MultilingualE5Small,
139            Self::BgeM3 => fastembed::EmbeddingModel::BGEM3,
140        }
141    }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub(super) struct LocalModelProfile {
146    pub(super) model: String,
147    pub(super) dimensions: usize,
148    pub(super) install_dir: PathBuf,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct LocalEmbeddingDownloadReport {
153    pub preset: String,
154    pub model_id: String,
155    pub upstream_model: String,
156    pub dimensions: usize,
157    pub install_dir: String,
158    pub files_verified: usize,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct LocalEmbeddingModelInventory {
163    pub preset: String,
164    pub model_id: String,
165    pub upstream_model: String,
166    pub dimensions: usize,
167    pub install_dir: String,
168    pub installed: bool,
169    pub checksum_verified: bool,
170    pub unavailable_reason: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
174pub struct LocalEmbeddingInventoryReport {
175    pub model_root: String,
176    pub configured_preset: String,
177    pub models: Vec<LocalEmbeddingModelInventory>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181struct LocalModelManifest {
182    schema_version: u32,
183    preset: String,
184    model_id: String,
185    upstream_model: String,
186    dimensions: usize,
187    runtime: String,
188    source_url: Option<String>,
189    downloaded_at_epoch: i64,
190    files: Vec<LocalModelFile>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194struct LocalModelFile {
195    path: String,
196    sha256: String,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    source_sha256: Option<String>,
199    bytes: u64,
200}
201
202pub(super) fn model_root(config: &EmbeddingConfig) -> PathBuf {
203    config
204        .model_dir
205        .as_ref()
206        .map(PathBuf::from)
207        .unwrap_or_else(|| crate::db::data_dir().join("models"))
208}
209
210pub(super) fn installed_model_profile(config: &EmbeddingConfig) -> Result<LocalModelProfile> {
211    let preset = configured_preset(config)?;
212    verified_profile_for_preset(config, preset)
213}
214
215pub(super) fn download_model(model: Option<&str>) -> Result<LocalEmbeddingDownloadReport> {
216    let config = super::resolve_embedding_config()?;
217    let preset = match model {
218        Some(raw) => LocalEmbeddingPreset::parse(raw)?,
219        None => configured_local_preset_or_default(&config)?,
220    };
221    let install_dir = install_dir_for_preset(&config, preset);
222    std::fs::create_dir_all(&install_dir)
223        .with_context(|| format!("create local embedding model dir {}", install_dir.display()))?;
224    materialize_fastembed_model(preset, &install_dir)?;
225    let files = collect_model_files(&install_dir)?;
226    if files.is_empty() {
227        bail!(
228            "local embedding download did not materialize model files in {}",
229            install_dir.display()
230        );
231    }
232    let manifest = LocalModelManifest {
233        schema_version: MANIFEST_SCHEMA_VERSION,
234        preset: preset.label().to_string(),
235        model_id: preset.model_id().to_string(),
236        upstream_model: preset.upstream_model().to_string(),
237        dimensions: preset.dimensions(),
238        runtime: FASTEMBED_RUNTIME.to_string(),
239        source_url: Some(preset.source_url()),
240        downloaded_at_epoch: chrono::Utc::now().timestamp(),
241        files,
242    };
243    write_manifest(&install_dir, &manifest)?;
244    let verified = read_verified_manifest(&install_dir, Some(preset))?;
245    Ok(LocalEmbeddingDownloadReport {
246        preset: verified.preset,
247        model_id: verified.model_id,
248        upstream_model: verified.upstream_model,
249        dimensions: verified.dimensions,
250        install_dir: install_dir.display().to_string(),
251        files_verified: verified.files.len(),
252    })
253}
254
255pub(super) fn inventory() -> Result<LocalEmbeddingInventoryReport> {
256    let config = super::resolve_embedding_config()?;
257    let root = model_root(&config);
258    let configured = configured_local_preset_or_default(&config)?;
259    let models = LocalEmbeddingPreset::all()
260        .iter()
261        .copied()
262        .map(|preset| inventory_for_preset(&config, preset))
263        .collect::<Result<Vec<_>>>()?;
264    Ok(LocalEmbeddingInventoryReport {
265        model_root: root.display().to_string(),
266        configured_preset: configured.label().to_string(),
267        models,
268    })
269}
270
271pub(super) fn embed_text(
272    text: &str,
273    config: &EmbeddingConfig,
274    kind: LocalEmbeddingInputKind,
275) -> Result<TextEmbedding> {
276    let preset = configured_preset(config)?;
277    let profile = verified_profile_for_preset(config, preset)?;
278    let values = embed_with_fastembed(preset, &profile.install_dir, text, kind)?;
279    if values.len() != profile.dimensions {
280        bail!(
281            "local embedding model {} returned {} dimensions, expected {}",
282            profile.model,
283            values.len(),
284            profile.dimensions
285        );
286    }
287    TextEmbedding::new(profile.model, values)
288}
289
290fn configured_preset(config: &EmbeddingConfig) -> Result<LocalEmbeddingPreset> {
291    let raw = config.model.trim();
292    if raw.is_empty() || raw == super::OPENAI_DEFAULT_MODEL {
293        return Ok(LocalEmbeddingPreset::default());
294    }
295    LocalEmbeddingPreset::parse(raw)
296}
297
298pub(super) fn configured_model_id(config: &EmbeddingConfig) -> Result<String> {
299    Ok(configured_preset(config)?.model_id().to_string())
300}
301
302fn configured_local_preset_or_default(config: &EmbeddingConfig) -> Result<LocalEmbeddingPreset> {
303    if config.provider == super::EmbeddingProvider::Local {
304        configured_preset(config)
305    } else {
306        Ok(LocalEmbeddingPreset::default())
307    }
308}
309
310fn verified_profile_for_preset(
311    config: &EmbeddingConfig,
312    preset: LocalEmbeddingPreset,
313) -> Result<LocalModelProfile> {
314    let install_dir = install_dir_for_preset(config, preset);
315    let manifest = read_verified_manifest(&install_dir, Some(preset)).map_err(|error| {
316        model_unavailable_error(format!(
317            "local embedding model {} is not ready in {}: {error}",
318            preset.label(),
319            install_dir.display()
320        ))
321    })?;
322    Ok(LocalModelProfile {
323        model: manifest.model_id,
324        dimensions: manifest.dimensions,
325        install_dir,
326    })
327}
328
329fn inventory_for_preset(
330    config: &EmbeddingConfig,
331    preset: LocalEmbeddingPreset,
332) -> Result<LocalEmbeddingModelInventory> {
333    let install_dir = install_dir_for_preset(config, preset);
334    match read_verified_manifest(&install_dir, Some(preset)) {
335        Ok(_) => Ok(LocalEmbeddingModelInventory {
336            preset: preset.label().to_string(),
337            model_id: preset.model_id().to_string(),
338            upstream_model: preset.upstream_model().to_string(),
339            dimensions: preset.dimensions(),
340            install_dir: install_dir.display().to_string(),
341            installed: true,
342            checksum_verified: true,
343            unavailable_reason: None,
344        }),
345        Err(error) => Ok(LocalEmbeddingModelInventory {
346            preset: preset.label().to_string(),
347            model_id: preset.model_id().to_string(),
348            upstream_model: preset.upstream_model().to_string(),
349            dimensions: preset.dimensions(),
350            install_dir: install_dir.display().to_string(),
351            installed: false,
352            checksum_verified: false,
353            unavailable_reason: Some(error.to_string()),
354        }),
355    }
356}
357
358fn install_dir_for_preset(config: &EmbeddingConfig, preset: LocalEmbeddingPreset) -> PathBuf {
359    model_root(config).join(preset.model_id())
360}
361
362#[cfg(feature = "local-onnx")]
363fn materialize_fastembed_model(preset: LocalEmbeddingPreset, install_dir: &Path) -> Result<()> {
364    let options = fastembed::TextInitOptions::new(preset.fastembed_model())
365        .with_cache_dir(install_dir.to_path_buf())
366        .with_show_download_progress(true);
367    let mut model = fastembed::TextEmbedding::try_new(options)
368        .with_context(|| format!("initialize local embedding model {}", preset.label()))?;
369    let probe = preset.prefix_input(
370        "remem local embedding readiness probe",
371        LocalEmbeddingInputKind::Generic,
372    );
373    let embeddings = model
374        .embed([probe.as_str()], Some(1))
375        .with_context(|| format!("probe local embedding model {}", preset.label()))?;
376    if embeddings.len() != 1 {
377        bail!(
378            "local embedding model {} returned {} probe embeddings",
379            preset.label(),
380            embeddings.len()
381        );
382    }
383    Ok(())
384}
385
386#[cfg(not(feature = "local-onnx"))]
387fn materialize_fastembed_model(preset: LocalEmbeddingPreset, _install_dir: &Path) -> Result<()> {
388    bail!(
389        "local semantic embedding runtime is not built; rebuild remem with the local-onnx feature to download {}",
390        preset.label()
391    )
392}
393
394#[cfg(feature = "local-onnx")]
395fn embed_with_fastembed(
396    preset: LocalEmbeddingPreset,
397    install_dir: &Path,
398    text: &str,
399    kind: LocalEmbeddingInputKind,
400) -> Result<Vec<f32>> {
401    let input = preset.prefix_input(text, kind);
402    let key = LocalModelCacheKey {
403        preset,
404        install_dir: install_dir.to_path_buf(),
405    };
406    LOCAL_MODEL_CACHE.with(|cache| {
407        let mut cache = cache.borrow_mut();
408        let model = match cache.entry(key) {
409            Entry::Occupied(entry) => entry.into_mut(),
410            Entry::Vacant(entry) => {
411                let options = fastembed::TextInitOptions::new(preset.fastembed_model())
412                    .with_cache_dir(install_dir.to_path_buf())
413                    .with_show_download_progress(false);
414                let model = fastembed::TextEmbedding::try_new(options).with_context(|| {
415                    format!("initialize local embedding model {}", preset.label())
416                })?;
417                entry.insert(model)
418            }
419        };
420        let mut embeddings = model
421            .embed([input.as_str()], Some(1))
422            .with_context(|| format!("embed text with local model {}", preset.label()))?;
423        let first = embeddings
424            .pop()
425            .context("local embedding model did not return an embedding")?;
426        if !embeddings.is_empty() {
427            bail!("local embedding model returned multiple embeddings for single input");
428        }
429        Ok(first)
430    })
431}
432
433#[cfg(not(feature = "local-onnx"))]
434fn embed_with_fastembed(
435    preset: LocalEmbeddingPreset,
436    _install_dir: &Path,
437    _text: &str,
438    _kind: LocalEmbeddingInputKind,
439) -> Result<Vec<f32>> {
440    Err(model_unavailable_error(format!(
441        "local semantic embedding runtime is not built; rebuild remem with the local-onnx feature to use {}",
442        preset.label()
443    )))
444}
445
446fn read_verified_manifest(
447    install_dir: &Path,
448    expected_preset: Option<LocalEmbeddingPreset>,
449) -> Result<LocalModelManifest> {
450    let path = install_dir.join(MANIFEST_FILE);
451    let content =
452        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
453    let manifest: LocalModelManifest =
454        serde_json::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
455    verify_manifest_header(&manifest, expected_preset)?;
456    for file in &manifest.files {
457        verify_manifest_file(install_dir, file)?;
458    }
459    Ok(manifest)
460}
461
462fn verify_manifest_header(
463    manifest: &LocalModelManifest,
464    expected_preset: Option<LocalEmbeddingPreset>,
465) -> Result<()> {
466    if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
467        bail!(
468            "unsupported manifest schema {}, expected {}",
469            manifest.schema_version,
470            MANIFEST_SCHEMA_VERSION
471        );
472    }
473    let preset = LocalEmbeddingPreset::parse(&manifest.preset)?;
474    if let Some(expected) = expected_preset {
475        if preset != expected {
476            bail!(
477                "manifest preset {} does not match expected {}",
478                manifest.preset,
479                expected.label()
480            );
481        }
482    }
483    if manifest.model_id != preset.model_id() {
484        bail!(
485            "manifest model_id {} does not match preset {}",
486            manifest.model_id,
487            preset.model_id()
488        );
489    }
490    if manifest.dimensions != preset.dimensions() {
491        bail!(
492            "manifest dimensions {} do not match preset {} dimensions {}",
493            manifest.dimensions,
494            preset.label(),
495            preset.dimensions()
496        );
497    }
498    if manifest.runtime != FASTEMBED_RUNTIME {
499        bail!("unsupported local embedding runtime {}", manifest.runtime);
500    }
501    if let Some(source_url) = manifest.source_url.as_deref() {
502        let expected = preset.source_url();
503        if source_url != expected {
504            bail!(
505                "manifest source_url {} does not match preset {} source {}",
506                source_url,
507                preset.label(),
508                expected
509            );
510        }
511    }
512    if manifest.files.is_empty() {
513        bail!("local embedding manifest has no verified files");
514    }
515    Ok(())
516}
517
518fn verify_manifest_file(install_dir: &Path, file: &LocalModelFile) -> Result<()> {
519    let relative = checked_relative_path(&file.path)?;
520    let path = install_dir.join(relative);
521    let metadata = std::fs::metadata(&path).with_context(|| format!("stat {}", path.display()))?;
522    if !metadata.is_file() {
523        bail!("manifest path is not a file: {}", path.display());
524    }
525    if metadata.len() != file.bytes {
526        bail!(
527            "checksum target {} size changed: expected {} bytes, got {}",
528            path.display(),
529            file.bytes,
530            metadata.len()
531        );
532    }
533    let actual = sha256_file(&path)?;
534    if actual != file.sha256 {
535        bail!(
536            "checksum mismatch for {}: expected {}, got {}",
537            path.display(),
538            file.sha256,
539            actual
540        );
541    }
542    if let Some(source_sha256) = file.source_sha256.as_deref() {
543        if actual != source_sha256 {
544            bail!(
545                "source checksum mismatch for {}: expected {}, got {}",
546                path.display(),
547                source_sha256,
548                actual
549            );
550        }
551    }
552    Ok(())
553}
554
555fn checked_relative_path(raw: &str) -> Result<PathBuf> {
556    let path = PathBuf::from(raw);
557    if path.is_absolute() {
558        bail!("manifest path must be relative: {raw}");
559    }
560    if path
561        .components()
562        .any(|component| !matches!(component, Component::Normal(_)))
563    {
564        bail!("manifest path must not contain parent/current components: {raw}");
565    }
566    Ok(path)
567}
568
569fn write_manifest(install_dir: &Path, manifest: &LocalModelManifest) -> Result<()> {
570    let path = install_dir.join(MANIFEST_FILE);
571    let tmp = install_dir.join(format!("{MANIFEST_FILE}.tmp"));
572    let content = serde_json::to_vec_pretty(manifest).context("serialize local model manifest")?;
573    std::fs::write(&tmp, content).with_context(|| format!("write {}", tmp.display()))?;
574    std::fs::rename(&tmp, &path)
575        .with_context(|| format!("replace local model manifest {}", path.display()))?;
576    Ok(())
577}
578
579fn collect_model_files(root: &Path) -> Result<Vec<LocalModelFile>> {
580    let mut files = Vec::new();
581    collect_model_files_inner(root, root, &mut files)?;
582    files.sort_by(|left, right| left.path.cmp(&right.path));
583    Ok(files)
584}
585
586fn collect_model_files_inner(
587    root: &Path,
588    current: &Path,
589    files: &mut Vec<LocalModelFile>,
590) -> Result<()> {
591    for entry in
592        std::fs::read_dir(current).with_context(|| format!("read {}", current.display()))?
593    {
594        let entry = entry?;
595        let path = entry.path();
596        let file_name = entry.file_name();
597        let file_name = file_name.to_string_lossy();
598        if file_name == MANIFEST_FILE || file_name == format!("{MANIFEST_FILE}.tmp") {
599            continue;
600        }
601        if file_name == ".locks" || file_name.ends_with(".lock") || file_name.ends_with(".tmp") {
602            continue;
603        }
604        let metadata = entry.metadata()?;
605        if metadata.is_dir() {
606            collect_model_files_inner(root, &path, files)?;
607        } else if metadata.is_file() {
608            let relative = path.strip_prefix(root).with_context(|| {
609                format!("make {} relative to {}", path.display(), root.display())
610            })?;
611            let relative = relative
612                .components()
613                .map(|component| match component {
614                    Component::Normal(value) => Ok(value.to_string_lossy().to_string()),
615                    _ => bail!("unexpected non-normal cache path {}", path.display()),
616                })
617                .collect::<Result<Vec<_>>>()?
618                .join("/");
619            let sha256 = sha256_file(&path)?;
620            let source_sha256 = source_sha256_from_hf_blob_path(&relative, &sha256)?;
621            files.push(LocalModelFile {
622                path: relative,
623                sha256,
624                source_sha256,
625                bytes: metadata.len(),
626            });
627        }
628    }
629    Ok(())
630}
631
632fn source_sha256_from_hf_blob_path(relative: &str, actual_sha256: &str) -> Result<Option<String>> {
633    let parts = relative.split('/').collect::<Vec<_>>();
634    let Some(file_name) = parts.last().copied() else {
635        return Ok(None);
636    };
637    if parts.len() < 2 || parts[parts.len() - 2] != "blobs" || !is_sha256_hex(file_name) {
638        return Ok(None);
639    }
640    if file_name != actual_sha256 {
641        bail!(
642            "source checksum mismatch for Hugging Face cache blob {relative}: expected {file_name}, got {actual_sha256}"
643        );
644    }
645    Ok(Some(file_name.to_string()))
646}
647
648fn is_sha256_hex(value: &str) -> bool {
649    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
650}
651
652fn sha256_file(path: &Path) -> Result<String> {
653    let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
654    let mut hasher = Sha256::new();
655    let mut buffer = [0_u8; 64 * 1024];
656    loop {
657        let read = std::io::Read::read(&mut file, &mut buffer)
658            .with_context(|| format!("read {}", path.display()))?;
659        if read == 0 {
660            break;
661        }
662        hasher.update(&buffer[..read]);
663    }
664    Ok(hasher
665        .finalize()
666        .iter()
667        .map(|byte| format!("{byte:02x}"))
668        .collect())
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn hf_cache_blob_source_sha_is_verified() -> Result<()> {
677        let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
678
679        let verified = source_sha256_from_hf_blob_path(&format!("models--demo/blobs/{sha}"), sha)?;
680
681        assert_eq!(verified.as_deref(), Some(sha));
682        Ok(())
683    }
684
685    #[test]
686    fn hf_cache_blob_source_sha_mismatch_fails() {
687        let source = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
688        let actual = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
689
690        let error =
691            source_sha256_from_hf_blob_path(&format!("models--demo/blobs/{source}"), actual)
692                .unwrap_err();
693
694        assert!(error.to_string().contains("source checksum mismatch"));
695    }
696}