Skip to main content

ffai_models/
lib.rs

1//! # ffai-models
2//!
3//! Weight management for `FFai`. Core principle: **weights are data, not
4//! code** — the repo holds only TOML *manifests* describing each model (files,
5//! source, checksums, and crucially its *license*, which is often different
6//! from `FFai`'s). Weights are fetched into a local cache, never vendored.
7//!
8//! Phase 0 ships manifests + cache resolution; the downloader (Hugging Face
9//! hub, resumable, checksum-verified) lands in Phase 1.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use ffai_core::error::{Error, Result};
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17
18/// A model whose files are all present locally, with their resolved paths.
19#[derive(Debug, Clone)]
20pub struct ResolvedModel {
21    pub name: String,
22    /// The WEIGHTS' license — may be more restrictive than `FFai`'s own.
23    pub license: String,
24    /// Filename → local path.
25    pub files: BTreeMap<String, PathBuf>,
26}
27
28impl ResolvedModel {
29    /// Path of a required file, or a clear error naming what's missing.
30    pub fn file(&self, name: &str) -> Result<&Path> {
31        self.files
32            .get(name)
33            .map(PathBuf::as_path)
34            .ok_or_else(|| Error::Model(format!("model `{}` has no file `{name}`", self.name)))
35    }
36}
37
38/// Resolve one file from the shared Hugging Face cache, downloading it when
39/// `cache_only` is false. Uses the same cache as `transformers` and
40/// `faster-whisper`, so a model either side already has is not fetched twice.
41#[cfg(feature = "fetch")]
42fn hub_download(repo: &str, filename: &str, cache_only: bool) -> Result<PathBuf> {
43    let (owner, name) = repo
44        .split_once('/')
45        .ok_or_else(|| Error::Model(format!("hf_repo `{repo}` is not in `owner/name` form")))?;
46    let client = hf_hub::HFClientSync::new()
47        .map_err(|e| Error::Model(format!("hugging face client init failed: {e}")))?;
48    client
49        .model(owner, name)
50        .download_file()
51        .filename(filename)
52        .local_files_only(cache_only)
53        .send()
54        .map_err(|e| Error::Model(format!("{repo}/{filename}: {e}")))
55}
56
57/// Without `fetch`, nothing is downloaded — and the error says which file to
58/// supply rather than failing as a missing symbol.
59#[cfg(not(feature = "fetch"))]
60fn hub_download(repo: &str, filename: &str, _cache_only: bool) -> Result<PathBuf> {
61    Err(Error::Model(format!(
62        "{repo}/{filename} is not present locally and this build has the          `fetch` feature disabled — place the file in the model directory, or          enable `ffai-models/fetch` to download it"
63    )))
64}
65
66/// Verify a downloaded file against its manifest checksum, when one is
67/// declared. A mismatch is an error, never a warning: silently running on
68/// unexpected weights would invalidate every measurement taken with them.
69fn verify_checksum(path: &Path, file: &ModelFile) -> Result<()> {
70    let Some(expected) = &file.sha256 else {
71        return Ok(());
72    };
73    let bytes = std::fs::read(path)?;
74    let actual: String = Sha256::digest(&bytes)
75        .iter()
76        .map(|b| format!("{b:02x}"))
77        .collect();
78    if actual != expected.to_ascii_lowercase() {
79        return Err(Error::Model(format!(
80            "checksum mismatch for {}: manifest says {expected}, file is {actual}",
81            path.display()
82        )));
83    }
84    Ok(())
85}
86
87/// One weight/config file belonging to a model.
88#[derive(Debug, Clone, Deserialize)]
89pub struct ModelFile {
90    /// Filename within the model's cache directory (and its HF repo).
91    pub name: String,
92    /// Hex SHA-256, verified after download when present.
93    pub sha256: Option<String>,
94}
95
96/// A model manifest (`models/*.toml`).
97#[derive(Debug, Clone, Deserialize)]
98pub struct ModelManifest {
99    pub name: String,
100    /// Task tag: "asr", "tts", "ocr", "vlm".
101    pub task: String,
102    pub description: Option<String>,
103    /// The WEIGHTS' license — surfaced to users because it may be more
104    /// restrictive than `FFai`'s MIT/Apache code license.
105    pub license: String,
106    /// Hugging Face repo id, e.g. "openai/whisper-tiny".
107    pub hf_repo: Option<String>,
108    #[serde(default)]
109    pub files: Vec<ModelFile>,
110}
111
112impl ModelManifest {
113    pub fn from_toml(text: &str) -> Result<Self> {
114        toml::from_str(text).map_err(|e| Error::Model(format!("bad manifest: {e}")))
115    }
116
117    pub fn load(path: &Path) -> Result<Self> {
118        let text = std::fs::read_to_string(path)?;
119        Self::from_toml(&text)
120    }
121
122    /// Directory this model's files live in when placed manually.
123    #[must_use]
124    pub fn cache_path(&self) -> PathBuf {
125        cache_dir().join("models").join(&self.name)
126    }
127
128    /// True when every listed file already resolves locally — no network.
129    #[must_use]
130    pub fn is_cached(&self) -> bool {
131        !self.files.is_empty()
132            && self
133                .files
134                .iter()
135                .all(|f| self.local_path(&f.name).is_some())
136    }
137
138    /// Resolve one file without touching the network: a manual placement
139    /// under [`Self::cache_path`] wins, otherwise the shared Hugging Face
140    /// cache (the same one `transformers`/`faster-whisper` use, so a model
141    /// downloaded by either side is not downloaded twice).
142    #[must_use]
143    pub fn local_path(&self, name: &str) -> Option<PathBuf> {
144        let manual = self.cache_path().join(name);
145        if manual.exists() {
146            return Some(manual);
147        }
148        hub_download(self.hf_repo.as_ref()?, name, true).ok()
149    }
150
151    /// Resolve every file, downloading from the Hugging Face hub as needed.
152    ///
153    /// Downloads are cached, so this is cheap on repeat calls — but it is
154    /// still network I/O the first time, which is why benchmarks warm the
155    /// cache outside any timed region (see docs/benchmarking.md).
156    pub fn fetch(&self) -> Result<ResolvedModel> {
157        let all: Vec<&str> = self.files.iter().map(|f| f.name.as_str()).collect();
158        self.fetch_only(&all)
159    }
160
161    /// [`Self::fetch`], for just the named files — the cache-and-download twin
162    /// of [`Self::resolve_only_in`].
163    ///
164    /// A manifest can list files no engine loads (`ppocrv5-mobile-det` lists
165    /// its training checkpoint, the source of the fused weights), and a hosted
166    /// repo need not carry them. Fetching every declared file made the first
167    /// download of the document engine fail on a file it never opens. A name
168    /// the manifest does not declare is an error: it has no checksum.
169    pub fn fetch_only(&self, needed: &[&str]) -> Result<ResolvedModel> {
170        for name in needed {
171            if !self.files.iter().any(|f| f.name == *name) {
172                return Err(Error::Model(format!(
173                    "model `{}` declares no file `{name}` — it has no checksum to verify against",
174                    self.name
175                )));
176            }
177        }
178        let mut files = BTreeMap::new();
179        for file in self.files.iter().filter(|f| needed.contains(&f.name.as_str())) {
180            if let Some(path) = self.local_path(&file.name) {
181                verify_checksum(&path, file)?;
182                files.insert(file.name.clone(), path);
183                continue;
184            }
185            let repo = self.hf_repo.as_ref().ok_or_else(|| {
186                Error::Model(format!(
187                    "model `{}` declares no hf_repo and `{}` is not present under {}",
188                    self.name,
189                    file.name,
190                    self.cache_path().display()
191                ))
192            })?;
193            let path = hub_download(repo, &file.name, false)?;
194            verify_checksum(&path, file)?;
195            files.insert(file.name.clone(), path);
196        }
197        Ok(ResolvedModel {
198            name: self.name.clone(),
199            license: self.license.clone(),
200            files,
201        })
202    }
203
204    /// Resolve every file from `root/<model name>/<file>` and nowhere else —
205    /// **no cache lookup, no network, ever.**
206    ///
207    /// This is the production form for a scheduled or offline job: the caller
208    /// states where weights live, a missing file is an error naming the exact
209    /// path that was expected, and nothing is fetched mid-job
210    /// (docs/plans/commercial-gaps.md, gap 7). Checksums are verified exactly
211    /// as [`Self::fetch`] verifies them.
212    pub fn resolve_in(&self, root: &Path) -> Result<ResolvedModel> {
213        let all: Vec<&str> = self.files.iter().map(|f| f.name.as_str()).collect();
214        self.resolve_only_in(root, &all)
215    }
216
217    /// [`Self::resolve_in`], for just the named files.
218    ///
219    /// A manifest documents provenance as well as runtime needs, so it can list
220    /// files no engine loads. `ppocrv5-mobile-det`, for example, lists its
221    /// training checkpoint, the source of the fused weights. An offline
222    /// deployment must not be made to ship those, so a loader asks for what it
223    /// will open. A name the manifest does not declare is an error, because it
224    /// has no checksum to verify against.
225    pub fn resolve_only_in(&self, root: &Path, needed: &[&str]) -> Result<ResolvedModel> {
226        let dir = root.join(&self.name);
227        let mut files = BTreeMap::new();
228        for name in needed {
229            if !self.files.iter().any(|f| f.name == *name) {
230                return Err(Error::Model(format!(
231                    "model `{}` declares no file `{name}` — it has no checksum to verify against",
232                    self.name
233                )));
234            }
235        }
236        for file in self
237            .files
238            .iter()
239            .filter(|f| needed.contains(&f.name.as_str()))
240        {
241            let path = dir.join(&file.name);
242            if !path.is_file() {
243                return Err(Error::Model(format!(
244                    "model `{}` needs `{}`, expected at {} — not present (offline resolution \
245                     never downloads)",
246                    self.name,
247                    file.name,
248                    path.display()
249                )));
250            }
251            verify_checksum(&path, file)?;
252            files.insert(file.name.clone(), path);
253        }
254        Ok(ResolvedModel {
255            name: self.name.clone(),
256            license: self.license.clone(),
257            files,
258        })
259    }
260
261    /// Check that every file is already present locally and matches its
262    /// checksum, **without downloading anything** — the `verify` half of
263    /// `prefetch`/`verify`. Resolves exactly where [`Self::fetch`] would look.
264    pub fn verify(&self) -> Result<ResolvedModel> {
265        let mut files = BTreeMap::new();
266        for file in &self.files {
267            let path = self.local_path(&file.name).ok_or_else(|| {
268                Error::Model(format!(
269                    "model `{}` is missing `{}` (looked under {}{})",
270                    self.name,
271                    file.name,
272                    self.cache_path().display(),
273                    if self.hf_repo.is_some() {
274                        " and the Hugging Face cache"
275                    } else {
276                        ""
277                    }
278                ))
279            })?;
280            verify_checksum(&path, file)?;
281            files.insert(file.name.clone(), path);
282        }
283        Ok(ResolvedModel {
284            name: self.name.clone(),
285            license: self.license.clone(),
286            files,
287        })
288    }
289}
290
291/// Load every `*.toml` manifest in a directory (typically `models/`).
292///
293/// A missing directory is an error that NAMES the directory. The bare i/o
294/// error ("The system cannot find the path specified") is what a crates.io
295/// user got when `models/` did not exist relative to their working directory,
296/// and it reads like missing weights rather than a missing manifest folder.
297pub fn load_dir(dir: &Path) -> Result<Vec<ModelManifest>> {
298    let mut out = Vec::new();
299    let entries = std::fs::read_dir(dir).map_err(|e| {
300        Error::Model(format!(
301            "cannot read model manifest directory {}: {e}",
302            dir.display()
303        ))
304    })?;
305    for entry in entries {
306        let path = entry?.path();
307        if path.extension().and_then(|e| e.to_str()) == Some("toml") {
308            out.push(
309                ModelManifest::load(&path)
310                    .map_err(|e| Error::Model(format!("{}: {e}", path.display())))?,
311            );
312        }
313    }
314    out.sort_by(|a, b| a.task.cmp(&b.task).then_with(|| a.name.cmp(&b.name)));
315    Ok(out)
316}
317
318/// The `FFai` model cache root: `$FFAI_CACHE` or `<os cache dir>/ffai`.
319pub fn cache_dir() -> PathBuf {
320    if let Ok(dir) = std::env::var("FFAI_CACHE") {
321        return PathBuf::from(dir);
322    }
323    os_cache_dir()
324        .unwrap_or_else(std::env::temp_dir)
325        .join("ffai")
326}
327
328/// The platform's per-user cache directory, by the rules `dirs::cache_dir`
329/// applies: `%LOCALAPPDATA%` on Windows, `~/Library/Caches` on macOS,
330/// `$XDG_CACHE_HOME` (when absolute) or `~/.cache` elsewhere.
331///
332/// Written out rather than taken from `dirs`, whose `dirs-sys` pulls
333/// `option-ext`, an MPL-2.0 crate: one call site was enough to turn a
334/// permissive-only `cargo deny` policy red for every consumer (`PdfCraft`,
335/// 2026-10-30).
336fn os_cache_dir() -> Option<PathBuf> {
337    let abs = |v: std::ffi::OsString| Some(PathBuf::from(v)).filter(|p| p.is_absolute());
338    if cfg!(windows) {
339        std::env::var_os("LOCALAPPDATA").and_then(abs)
340    } else if cfg!(target_os = "macos") {
341        std::env::var_os("HOME").and_then(abs).map(|h| h.join("Library/Caches"))
342    } else {
343        std::env::var_os("XDG_CACHE_HOME")
344            .and_then(abs)
345            .or_else(|| std::env::var_os("HOME").and_then(abs).map(|h| h.join(".cache")))
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn manifest_parses_and_surfaces_license() {
355        let m = ModelManifest::from_toml(
356            r#"
357            name = "whisper-tiny"
358            task = "asr"
359            license = "Apache-2.0"
360            hf_repo = "openai/whisper-tiny"
361
362            [[files]]
363            name = "model.safetensors"
364            "#,
365        )
366        .unwrap();
367        assert_eq!(m.name, "whisper-tiny");
368        assert_eq!(m.license, "Apache-2.0");
369        assert_eq!(m.files.len(), 1);
370        assert!(!m.is_cached());
371    }
372
373    fn scratch(tag: &str) -> PathBuf {
374        let d = std::env::temp_dir().join(format!("ffai-models-{tag}-{}", std::process::id()));
375        let _ = std::fs::remove_dir_all(&d);
376        std::fs::create_dir_all(&d).unwrap();
377        d
378    }
379
380    fn manifest_for(sha: &str) -> ModelManifest {
381        ModelManifest::from_toml(&format!(
382            r#"
383            name = "tiny-test"
384            task = "ocr"
385            license = "MIT"
386
387            [[files]]
388            name = "w.bin"
389            sha256 = "{sha}"
390            "#
391        ))
392        .unwrap()
393    }
394
395    /// sha256("abc")
396    const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
397
398    #[test]
399    fn resolve_in_names_the_exact_missing_path_and_never_fetches() {
400        let root = scratch("missing");
401        let err = manifest_for(ABC).resolve_in(&root).unwrap_err().to_string();
402        let want = root.join("tiny-test").join("w.bin");
403        assert!(
404            err.contains(&want.display().to_string()),
405            "error must name {}: {err}",
406            want.display()
407        );
408        assert!(err.contains("never downloads"), "{err}");
409        let _ = std::fs::remove_dir_all(&root);
410    }
411
412    #[test]
413    fn resolve_in_finds_and_checksums_a_placed_file() {
414        let root = scratch("present");
415        std::fs::create_dir_all(root.join("tiny-test")).unwrap();
416        std::fs::write(root.join("tiny-test").join("w.bin"), b"abc").unwrap();
417        let r = manifest_for(ABC).resolve_in(&root).unwrap();
418        assert_eq!(
419            r.file("w.bin").unwrap(),
420            root.join("tiny-test").join("w.bin")
421        );
422
423        // Same bytes, wrong declared checksum: an error, not a warning.
424        let bad = manifest_for(&"0".repeat(64))
425            .resolve_in(&root)
426            .unwrap_err()
427            .to_string();
428        assert!(bad.contains("checksum mismatch"), "{bad}");
429        let _ = std::fs::remove_dir_all(&root);
430    }
431
432    #[test]
433    fn load_dir_names_a_missing_directory() {
434        let missing = std::env::temp_dir().join("ffai-models-definitely-absent-dir");
435        let err = load_dir(&missing).unwrap_err().to_string();
436        assert!(err.contains(&missing.display().to_string()), "{err}");
437    }
438
439    /// Only the named files are required, so a provenance-only file (a
440    /// training checkpoint) need not be shipped; but an undeclared name is
441    /// refused, because there is no checksum to verify it against.
442    #[test]
443    fn resolve_only_in_needs_just_the_named_files_and_refuses_undeclared_ones() {
444        let root = scratch("subset");
445        std::fs::create_dir_all(root.join("tiny-test")).unwrap();
446        std::fs::write(root.join("tiny-test").join("w.bin"), b"abc").unwrap();
447        let m = ModelManifest::from_toml(&format!(
448            r#"
449            name = "tiny-test"
450            task = "ocr"
451            license = "MIT"
452
453            [[files]]
454            name = "w.bin"
455            sha256 = "{ABC}"
456
457            [[files]]
458            name = "train-only.bin"
459            "#
460        ))
461        .unwrap();
462        assert!(
463            m.resolve_in(&root).is_err(),
464            "the full set includes an absent file"
465        );
466        let r = m.resolve_only_in(&root, &["w.bin"]).unwrap();
467        assert_eq!(r.files.len(), 1);
468        let err = m
469            .resolve_only_in(&root, &["nope.bin"])
470            .unwrap_err()
471            .to_string();
472        assert!(err.contains("declares no file `nope.bin`"), "{err}");
473        let _ = std::fs::remove_dir_all(&root);
474    }
475
476    /// The download path refuses an undeclared name before touching the
477    /// network or the cache, exactly as the offline path does.
478    #[test]
479    fn fetch_only_refuses_undeclared_names_before_any_lookup() {
480        let m = ModelManifest::from_toml(
481            r#"
482            name = "tiny-test"
483            task = "ocr"
484            license = "MIT"
485            hf_repo = "example/does-not-matter"
486
487            [[files]]
488            name = "w.bin"
489            "#,
490        )
491        .unwrap();
492        let err = m.fetch_only(&["nope.bin"]).unwrap_err().to_string();
493        assert!(err.contains("declares no file `nope.bin`"), "{err}");
494    }
495}