Skip to main content

qsm_core/models/
mod.rs

1//! Deep-learning model registry and weight management.
2//!
3//! Several QSM stages have deep-learning implementations (BFRnet, QSMnet,
4//! xQSM, χ-sepnet, …). QSM-Core runs them via **ONNX** rather than bundling a
5//! Python/PyTorch/TensorFlow runtime. The trained weights are *not* vendored in
6//! this crate — they are fetched on first use and cached on disk.
7//!
8//! This module is the single source of truth for *which* models exist and
9//! *where/how* to obtain their weights. It is split into three layers:
10//!
11//! - **Registry (always compiled).** [`ModelSpec`] / [`WeightFile`] describe each
12//!   model and its weight files (URL, SHA-256, size, license). [`all_models`] /
13//!   [`find_model`] query the table. This layer has no heavy dependencies and is
14//!   safe to expose through the WASM bindings so a JavaScript host (e.g. qsmbly)
15//!   can discover download URLs and fetch weights itself.
16//! - **Download & cache (`download` feature).** [`download::ensure_model`] fetches
17//!   any missing weight files over HTTP, verifies their SHA-256, and stores them
18//!   in a local cache. This is the path a native host (e.g. QSMxT) uses to
19//!   "download on use". WASM hosts skip this layer.
20//! - **Inference (`onnx` feature).** Runs an ONNX graph with the pure-Rust
21//!   [`tract`](https://docs.rs/tract-onnx) engine. The entry points take the
22//!   model as a **byte buffer** ([`onnx::OnnxModel::load`]) rather than a path,
23//!   so the *same* inference code runs natively and in WASM. A native host feeds
24//!   bytes from the download cache; a WASM host (e.g. qsmbly) fetches the weights
25//!   in JavaScript and supplies them with [`provide_weights`], after which the same
26//!   pipeline dispatchers run as natively.
27//!
28//! The registry stays runtime-agnostic: `tract` is the default portable engine,
29//! but a host is free to read a model's URLs from the registry and run it any
30//! other way.
31//!
32//! ### Weight resolution order
33//!
34//! Every pipeline dispatcher that runs a network ([`crate::pipeline`]: inversion, BFRnet,
35//! iQFM, iQSM, HD-BET/RS2-Net masking, χ-separation, …) and every host that loads a model
36//! through [`primary_weight`] / [`weights`] resolves each weight file in this order:
37//!
38//! 1. Bytes the host supplied with [`provide_weights`] (checked against the registry's
39//!    SHA-256 when they were supplied). This is the only source on
40//!    `wasm32-unknown-unknown`, where there is no file system or network: a browser host
41//!    fetches the files itself and hands them over, and the same dispatchers run.
42//! 2. `$QSM_MODEL_DIR/<file>` — an explicit directory of local weight files
43//!    (bring-your-own-weights; also how gated models like χ-sepnet are supplied). Native only.
44//! 3. The on-disk cache ([`cache_dir`]). Native only.
45//! 4. Download from [`WeightFile::url`] into the cache (`download` feature). Native only.
46//!
47//! On `wasm32-unknown-unknown` steps 2–4 are not compiled; a file that was not provided is an
48//! error naming the file and [`provide_weights`].
49//!
50//! ### Memory
51//!
52//! Every model in the registry carries [`ModelSpec::memory`], measured with tract, so a host can
53//! estimate a run's memory before it starts:
54//!
55//! - [`ModelSpec::resident_bytes`]: the parsed model, held for as long as it is loaded.
56//! - [`ModelSpec::inference_bytes`]: one inference at any input shape, above the resident model
57//!   (native): a fixed part plus bytes per voxel of the network's input.
58//! - [`ModelSpec::peak_bytes`]: loading the model and running it once, for a native process or a
59//!   `wasm32` heap ([`Heap`]).
60//! - [`crate::bet::hd_bet_memory_bytes`]: the whole HD-BET step on a scan, from its field of view.
61//!   HD-BET resamples to 1 mm, so its network input follows the head's size in mm, not the
62//!   acquired matrix ([`crate::bet::hd_bet_network_input`]).
63//!
64//! The bytes of the weight files themselves ([`WeightFile::bytes`]) are not included, nor are a
65//! wrapper's own buffers or further tiles in flight (for those, see
66//! [`ModelSpec::tile_bytes_per_voxel`]).
67//!
68//! **How they were measured.** Natively, `examples/inference_memory.rs` runs each network
69//! single-threaded under a counting global allocator, at 1–8 input shapes per network from 32³ to
70//! 192×256×128 (to 256³ for MindGrab and 176×256×256 for HD-BET; the fixed-patch networks at
71//! their patch). Per shape it takes the larger of a run that plans inside
72//! ([`onnx::OnnxModel::run`]) and a plan built once and then run ([`onnx::OnnxModel::plan_for`]),
73//! since wrappers use both. The figures are the least-squares line through those peaks, with the
74//! intercept raised to cover every one. From 64³ up they sit 0–7 % above the measured peaks
75//! (0–1.3 % from 96³ up); at 32³ up to 28 % above (at most 25 MB), where the fixed part
76//! dominates. MindGrab is fitted from 128³ up: it always runs at 256³, and smaller inputs fall
77//! well below the line. Threads (the `parallel` feature) add 0.1–0.6 % at 8–16 threads. Peak
78//! RSS adds the program, the weight file's bytes and the allocator's slack to these.
79//!
80//! In WASM, the `browser_memory::net_*` cases in `tests/wasm_pipeline.rs` run the same inferences
81//! under Node (`wasm32-unknown-unknown`, SIMD128, single-threaded) at one or two shapes per
82//! network that fit the 4 GiB heap, and read the size of the linear memory, which only grows. The
83//! WASM figures are a line through those points. The WASM heap grows by 0.8–1.6 times the native
84//! peak of the same run (median 1.2). The ratio varies with the network and the shape, because
85//! the allocator cannot always reuse a freed tensor's block for the next, larger one. Two runs of
86//! the same graph (QSMnet and QSMnet+) differed by 7 %, so budget a margin on top.
87//!
88//! `src/models/memory_calibration.rs` holds every measured peak, and its tests check that each
89//! figure covers its measurements and sits within 10 % (or 32 MB) of them. Re-measure after a
90//! tract upgrade or a new export. QSMnet+ shares QSMnet's figures and iQFM shares iQSM's, since
91//! they are the same graphs. SynthSeg 2.0's weights are not hosted, so its figures are SynthSeg
92//! 1.0's plus its 33rd output channel.
93
94use std::collections::BTreeMap;
95#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
96use std::path::PathBuf;
97use std::sync::{Arc, Mutex};
98
99mod registry;
100pub use registry::{all_models, find_model};
101
102#[cfg(test)]
103mod memory_calibration;
104
105#[cfg(all(
106    feature = "download",
107    not(all(target_arch = "wasm32", target_os = "unknown"))
108))]
109pub mod download;
110
111#[cfg(feature = "onnx")]
112pub mod onnx;
113
114/// The reconstruction stage a model implements.
115#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum ModelStage {
118    /// Wrapped/unwrapped phase → local tissue field (joint unwrap + background removal).
119    PhaseToField,
120    /// Total field → local tissue field (background field removal).
121    BackgroundRemoval,
122    /// Local tissue field → susceptibility (dipole inversion).
123    DipoleInversion,
124    /// Total field or phase → susceptibility in one network (single-step).
125    SingleStep,
126    /// Inputs → paramagnetic/diamagnetic susceptibility (χ+ / χ−).
127    ChiSeparation,
128    /// R2* → R2′ (reversible relaxation), for GRE-only χ-separation.
129    R2PrimeGeneration,
130    /// Magnitude image → binary brain mask (brain extraction).
131    BrainExtraction,
132    /// Magnitude image → anatomical label map (whole-brain segmentation).
133    Segmentation,
134}
135
136/// The framework the weights were originally trained in (before ONNX export).
137#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum Framework {
140    /// Authored/exported directly as ONNX.
141    Onnx,
142    /// PyTorch `.pth`/`.pt` → exported with `torch.onnx.export`.
143    PyTorch,
144    /// TensorFlow/Keras → exported with `tf2onnx`.
145    TensorFlow,
146    /// MATLAB Deep Learning Toolbox → exported with `exportONNXNetwork`.
147    Matlab,
148}
149
150/// Whether a model's weights are hosted and ready to run.
151#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum WeightStatus {
154    /// Weights are converted, hosted at [`WeightFile::url`], and ready to fetch.
155    Available,
156    /// A recognized target whose ONNX weights are not yet converted/hosted.
157    /// [`WeightFile::url`]/[`WeightFile::sha256`] may be empty. Listed so hosts
158    /// can surface the roadmap and accept bring-your-own-weights via
159    /// `$QSM_MODEL_DIR`.
160    Pending,
161}
162
163/// One weight file a model needs at inference time (an exported `.onnx`, plus
164/// any auxiliary file such as normalization statistics).
165#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
166#[derive(Clone, Copy, Debug)]
167pub struct WeightFile {
168    /// Filename used as the local cache key. Also the name looked up under
169    /// `$QSM_MODEL_DIR`.
170    pub name: &'static str,
171    /// Direct download URL (OSF/Hugging Face/…). Empty while [`WeightStatus::Pending`].
172    pub url: &'static str,
173    /// Lowercase hex SHA-256 of the file, for integrity and cache validation.
174    /// Empty while [`WeightStatus::Pending`].
175    pub sha256: &'static str,
176    /// File size in bytes (0 if not yet known).
177    pub bytes: u64,
178}
179
180/// A deep-learning QSM model and everything needed to obtain and run it.
181#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
182#[derive(Clone, Copy, Debug)]
183pub struct ModelSpec {
184    /// Stable identifier, e.g. `"bfrnet"`, `"qsmnet"`. Used by [`find_model`] and
185    /// as the env-var suffix `$QSM_MODEL_DIR`.
186    pub id: &'static str,
187    /// Human-readable name, e.g. `"BFRnet"`.
188    pub name: &'static str,
189    /// Reconstruction stage this model implements.
190    pub stage: ModelStage,
191    /// Whether the weights are hosted and ready.
192    pub status: WeightStatus,
193    /// Framework the weights came from (before ONNX export).
194    pub origin: Framework,
195    /// One-line description of the method.
196    pub description: &'static str,
197    /// Reference (citation / DOI / arXiv).
198    pub paper: &'static str,
199    /// Upstream code repository.
200    pub source: &'static str,
201    /// License / redistribution note for the weights.
202    pub license: &'static str,
203    /// ONNX weight file(s) required at inference time.
204    pub files: &'static [WeightFile],
205    /// ONNX graph input tensor name(s), in the order the runner feeds them.
206    pub inputs: &'static [&'static str],
207    /// ONNX graph output tensor name(s).
208    pub outputs: &'static [&'static str],
209    /// Spatial dimensions must be zero-padded to a multiple of this before
210    /// inference (`0`/`1` = no constraint). Fully-convolutional nets with
211    /// pooling need e.g. 8 or 16.
212    pub size_divisor: u32,
213    /// Heap one tile of this network holds while it runs, in bytes per voxel of its padded
214    /// patch, for the networks QSM-Core runs tiled (`None` for the rest).
215    ///
216    /// Covers the tile's activations and everything else its wrapper allocates per tile (the
217    /// input and output patches, and for the unrolled networks their FFT buffers), as measured
218    /// in WASM, fragmentation included: the slope of the heap's high-water against patch voxels,
219    /// one tile in flight (the `browser_memory::tile_cost_*` cases in `tests/wasm_pipeline.rs`;
220    /// `examples/tile_memory_footprint.rs` measures the same natively by counting allocations).
221    /// What a run holds once whatever the number of tiles (the parsed model and its plan, the
222    /// full-volume buffers) is not in it, except for the networks whose wrapper loads the network
223    /// inside every patch (LPCNN, MoDL-QSM, NeXtQSM): each of their tiles in flight holds its own
224    /// parsed model, so their figure is a whole 64³ tile's heap per voxel, which over-counts larger
225    /// patches slightly.
226    ///
227    /// `inversion::tile_bytes` turns it into a tile's bytes for a config, and it is what
228    /// `inversion::tile_concurrency` budgets with on WASM, so a host estimating a tiled run's
229    /// memory should use this same figure.
230    pub tile_bytes_per_voxel: Option<u64>,
231    /// Heap the network takes in tract: the parsed model, and one inference as a function of its
232    /// input shape. See [`ModelSpec::inference_bytes`] and the [module docs](self#memory).
233    pub memory: NetworkMemory,
234}
235
236/// Measured heap of one network in tract (see the [module docs](self#memory) for how).
237///
238/// Bytes of heap allocated by tract and QSM-Core's ONNX layer. The bytes of the weight file itself
239/// ([`WeightFile::bytes`], held by whoever supplied them) are not included.
240#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
242pub struct NetworkMemory {
243    /// Heap the parsed model ([`onnx::OnnxModel::load`]) holds for as long as it is loaded; for
244    /// NeXtQSM, both its networks.
245    pub resident_bytes: u64,
246    /// High-water of parsing the model, from nothing: about twice the resident size, as tract
247    /// holds the decoded protobuf while it builds the graph.
248    pub load_peak_bytes: u64,
249    /// Native: the fixed part of one inference's peak above the resident model.
250    pub inference_fixed_bytes: u64,
251    /// Native: the part of one inference's peak above the resident model that grows with the
252    /// input, per voxel of the network's input (activations, the input and output tensors, the
253    /// plan's per-shape tables and tract's scratch).
254    pub inference_bytes_per_voxel: u64,
255    /// WASM: the fixed part of the heap's growth over loading the model and running it once.
256    pub wasm_fixed_bytes: u64,
257    /// WASM: the part of that growth per voxel of the network's input.
258    pub wasm_bytes_per_voxel: u64,
259}
260
261/// Which heap a memory figure is for.
262#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
264pub enum Heap {
265    /// A native process, measured as live allocations (the resident set adds the program and the
266    /// allocator's slack).
267    Native,
268    /// A `wasm32` linear memory, measured as its size, which only grows: fragmentation included.
269    Wasm32,
270}
271
272/// What one tile of a tiled network costs, for budgeting how many fit in memory at once.
273#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub struct TileCost {
276    /// The network's size divisor: a tile's patch is `core + 2·halo` rounded up to a multiple of
277    /// it ([`ModelSpec::size_divisor`]).
278    pub divisor: usize,
279    /// Heap per patch voxel ([`ModelSpec::tile_bytes_per_voxel`]).
280    pub bytes_per_voxel: u64,
281}
282
283impl ModelSpec {
284    /// The cost of one tile of this network, for the networks QSM-Core runs tiled. The tiled
285    /// wrappers budget with it, and a host estimating a tiled run's memory should use it too
286    /// (`inversion::tile_bytes`, `inversion::wasm_tiles_in_flight`).
287    pub fn tile_cost(&self) -> Option<TileCost> {
288        self.tile_bytes_per_voxel
289            .map(|bytes_per_voxel| TileCost { divisor: self.size_divisor.max(1) as usize, bytes_per_voxel })
290    }
291
292    /// Native peak heap of one inference of this network at input spatial shape `shape`, above
293    /// the resident model ([`Self::resident_bytes`]), in bytes:
294    /// [`NetworkMemory::inference_fixed_bytes`] + voxels · [`NetworkMemory::inference_bytes_per_voxel`].
295    ///
296    /// `shape` is the spatial shape of the tensor the network itself sees: a tile's or window's
297    /// patch, or for a whole-volume run the volume after the wrapper's padding (to
298    /// [`Self::size_divisor`]; for HD-BET to multiples of 16×32×32 in its `(z, y, x)` order, see
299    /// [`crate::bet::hd_bet_network_input`]). Only its number of voxels matters. Pass the spatial
300    /// shape even for networks with several input channels (χ-separation, MoDL-QSM) or inputs
301    /// (iQSM): those are counted. Fixed-shape networks (RS2-Net, AutoQSM, QSMGAN) always see their
302    /// patch; pass that.
303    ///
304    /// It covers the plan tract builds for the shape (its copy of the weights and per-shape
305    /// tables), the input and output tensors, the activations and tract's scratch, both for a plan
306    /// built per call ([`onnx::OnnxModel::run`]) and for one built once and reused
307    /// ([`onnx::OnnxModel::plan_for`]). A wrapper's own buffers (the padded volume, overlap
308    /// accumulators, test-time flips, pre- and post-processing) come on top, as do further tiles
309    /// in flight. Measured single-threaded: with the `parallel` feature, 8–16 threads added
310    /// 1–13 MB (0.1–0.6 %) for HD-BET, SynthSeg and xQSM.
311    pub fn inference_bytes(&self, shape: [usize; 3]) -> u64 {
312        let m = &self.memory;
313        m.inference_fixed_bytes + voxels(shape) * m.inference_bytes_per_voxel
314    }
315
316    /// Heap the parsed model holds while loaded, in bytes ([`NetworkMemory::resident_bytes`]).
317    pub fn resident_bytes(&self) -> u64 {
318        self.memory.resident_bytes
319    }
320
321    /// Peak heap of loading this network and running it once at input spatial shape `shape`
322    /// (as for [`Self::inference_bytes`]), in bytes: what one inference needs besides the
323    /// caller's own buffers and the weight file's bytes.
324    ///
325    /// - [`Heap::Native`]: the larger of parsing the model ([`NetworkMemory::load_peak_bytes`])
326    ///   and the resident model plus [`Self::inference_bytes`].
327    /// - [`Heap::Wasm32`]: the growth of a `wasm32` linear memory over the same,
328    ///   [`NetworkMemory::wasm_fixed_bytes`] + voxels · [`NetworkMemory::wasm_bytes_per_voxel`]
329    ///   (at least the load peak). Between 0.8 and 1.6 times the native figure, depending on the
330    ///   network and the shape: the WASM allocator cannot reuse every freed block for the next,
331    ///   larger tensor.
332    pub fn peak_bytes(&self, shape: [usize; 3], heap: Heap) -> u64 {
333        let m = &self.memory;
334        let after_load = match heap {
335            Heap::Native => m.resident_bytes + self.inference_bytes(shape),
336            Heap::Wasm32 => m.wasm_fixed_bytes + voxels(shape) * m.wasm_bytes_per_voxel,
337        };
338        m.load_peak_bytes.max(after_load)
339    }
340
341    /// `true` if every weight file has a non-empty URL and hash (i.e. hosted).
342    pub fn is_available(&self) -> bool {
343        self.status == WeightStatus::Available
344            && self
345                .files
346                .iter()
347                .all(|f| !f.url.is_empty() && !f.sha256.is_empty())
348    }
349}
350
351fn voxels(shape: [usize; 3]) -> u64 {
352    shape.iter().map(|&d| d as u64).product()
353}
354
355/// The bytes of one weight file, shared rather than copied.
356///
357/// Returned by [`primary_weight`] / [`weights`] and friends. Bytes supplied with
358/// [`provide_weights`] are handed out as further references to the same buffer, so a run does
359/// not hold a second copy of a large model while the provider keeps it. Dereferences to
360/// `[u8]`, so `&weights` passes wherever `&[u8]` is expected.
361#[derive(Clone)]
362pub struct WeightBytes(Arc<Vec<u8>>);
363
364impl WeightBytes {
365    fn new(bytes: Vec<u8>) -> Self {
366        Self(Arc::new(bytes))
367    }
368}
369
370impl From<Vec<u8>> for WeightBytes {
371    fn from(bytes: Vec<u8>) -> Self {
372        Self::new(bytes)
373    }
374}
375
376impl std::ops::Deref for WeightBytes {
377    type Target = [u8];
378    fn deref(&self) -> &[u8] {
379        &self.0
380    }
381}
382
383impl AsRef<[u8]> for WeightBytes {
384    fn as_ref(&self) -> &[u8] {
385        &self.0
386    }
387}
388
389impl std::fmt::Debug for WeightBytes {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        write!(f, "WeightBytes({} bytes)", self.0.len())
392    }
393}
394
395/// Why [`provide_weights`] refused a buffer.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub enum ProvideWeightsError {
398    /// No model in the registry has a weight file of this name.
399    UnknownFile(String),
400    /// The bytes do not hash to the registry's SHA-256 for this file (a wrong, truncated or
401    /// corrupted download).
402    ChecksumMismatch {
403        file: String,
404        expected: String,
405        got: String,
406    },
407}
408
409impl std::fmt::Display for ProvideWeightsError {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            Self::UnknownFile(name) => write!(
413                f,
414                "'{name}' is not a weight file of any model in the registry"
415            ),
416            Self::ChecksumMismatch {
417                file,
418                expected,
419                got,
420            } => write!(
421                f,
422                "weights supplied for '{file}' do not match the registry: SHA-256 is {got}, \
423                 expected {expected} (wrong, truncated or corrupted file)"
424            ),
425        }
426    }
427}
428
429impl std::error::Error for ProvideWeightsError {}
430
431/// Weight files supplied from memory, keyed by the registry's file name.
432static PROVIDED: Mutex<BTreeMap<&'static str, WeightBytes>> = Mutex::new(BTreeMap::new());
433
434fn provided_map() -> std::sync::MutexGuard<'static, BTreeMap<&'static str, WeightBytes>> {
435    // Poisoning only means another thread panicked while holding the lock; the map itself is
436    // always in a consistent state (single inserts/removes), so carry on with it.
437    PROVIDED.lock().unwrap_or_else(|e| e.into_inner())
438}
439
440/// Look a weight file up by name across every model in the registry.
441pub fn find_weight_file(file_name: &str) -> Option<&'static WeightFile> {
442    all_models()
443        .iter()
444        .flat_map(|m| m.files.iter())
445        .find(|f| f.name == file_name)
446}
447
448/// Lowercase hex SHA-256 of a byte slice.
449pub fn sha256_hex(bytes: &[u8]) -> String {
450    use sha2::{Digest, Sha256};
451    let digest = Sha256::digest(bytes);
452    let mut out = String::with_capacity(64);
453    for b in digest {
454        use std::fmt::Write as _;
455        let _ = write!(out, "{b:02x}");
456    }
457    out
458}
459
460/// Supply a weight file from memory, so every dispatcher that needs it uses these bytes before
461/// looking in `$QSM_MODEL_DIR`, the cache or the network.
462///
463/// `file_name` is the registry's [`WeightFile::name`] (e.g. `"xqsm.onnx"`; for a multi-file
464/// model such as NeXtQSM, supply each of its [`ModelSpec::files`]). The bytes are checked
465/// against the registry's SHA-256 and refused on a mismatch; a file whose model is
466/// [`WeightStatus::Pending`] has no hash yet and is taken as given, as `$QSM_MODEL_DIR` is.
467///
468/// This is how a host without a file system (a browser, `wasm32-unknown-unknown`) runs the
469/// deep-learning stages: fetch each file the run needs, call this, then call the pipeline
470/// functions as usual. The buffer is moved in, not copied, and stays until
471/// [`forget_weights`] (or until replaced by another call for the same file); runs borrow it
472/// without copying. Supplying the same file again replaces the earlier buffer.
473pub fn provide_weights(file_name: &str, bytes: Vec<u8>) -> Result<(), ProvideWeightsError> {
474    let file = find_weight_file(file_name)
475        .ok_or_else(|| ProvideWeightsError::UnknownFile(file_name.to_string()))?;
476    if !file.sha256.is_empty() {
477        let got = sha256_hex(&bytes);
478        if got != file.sha256 {
479            return Err(ProvideWeightsError::ChecksumMismatch {
480                file: file.name.to_string(),
481                expected: file.sha256.to_string(),
482                got,
483            });
484        }
485    }
486    provided_map().insert(file.name, WeightBytes::new(bytes));
487    Ok(())
488}
489
490/// Drop a buffer supplied with [`provide_weights`], releasing its memory once no running stage
491/// still holds it. Returns `true` if one was held.
492pub fn forget_weights(file_name: &str) -> bool {
493    provided_map().remove(file_name).is_some()
494}
495
496/// Drop every buffer supplied with [`provide_weights`].
497pub fn forget_all_weights() {
498    provided_map().clear();
499}
500
501/// The bytes supplied for `file_name` with [`provide_weights`], if any.
502pub fn provided_weights(file_name: &str) -> Option<WeightBytes> {
503    provided_map().get(file_name).cloned()
504}
505
506/// `true` if `file` can be resolved without a download: it was supplied with
507/// [`provide_weights`], or (native only) it is in `$QSM_MODEL_DIR` or the cache.
508pub fn weight_file_present(file: &WeightFile) -> bool {
509    if provided_map().contains_key(file.name) {
510        return true;
511    }
512    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
513    {
514        resolve_local(file).is_some()
515    }
516    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
517    {
518        false
519    }
520}
521
522/// Root directory for cached weight files (native only).
523///
524/// Resolved from, in order: `$QSM_MODEL_CACHE`, `$XDG_CACHE_HOME/qsm-rs/models`,
525/// `$HOME/.cache/qsm-rs/models`, else `./.qsm-rs-models`.
526#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
527pub fn cache_dir() -> PathBuf {
528    if let Ok(dir) = std::env::var("QSM_MODEL_CACHE") {
529        return PathBuf::from(dir);
530    }
531    if let Ok(dir) = std::env::var("XDG_CACHE_HOME") {
532        return PathBuf::from(dir).join("qsm-rs").join("models");
533    }
534    if let Ok(home) = std::env::var("HOME") {
535        return PathBuf::from(home)
536            .join(".cache")
537            .join("qsm-rs")
538            .join("models");
539    }
540    PathBuf::from(".qsm-rs-models")
541}
542
543/// The path a weight file would occupy in the cache (whether or not it exists). Native only.
544#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
545pub fn cache_path(file: &WeightFile) -> PathBuf {
546    cache_dir().join(file.name)
547}
548
549/// Find an already-present copy of `file` on disk without downloading, honoring the
550/// `$QSM_MODEL_DIR` override first, then the cache. Returns `None` if absent. Native only;
551/// does not see bytes supplied with [`provide_weights`] (see [`weight_file_present`]).
552///
553/// This is std-only and works even without the `download` feature, so a host
554/// can supply bring-your-own-weights for pending or gated models.
555#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
556pub fn resolve_local(file: &WeightFile) -> Option<PathBuf> {
557    if let Ok(dir) = std::env::var("QSM_MODEL_DIR") {
558        let p = PathBuf::from(dir).join(file.name);
559        if p.is_file() {
560            return Some(p);
561        }
562    }
563    let p = cache_path(file);
564    if p.is_file() {
565        Some(p)
566    } else {
567        None
568    }
569}
570
571/// The bytes of a model's primary (first) weight file.
572///
573/// Resolved as described in the [module docs](self): supplied bytes ([`provide_weights`])
574/// first, then (native) `$QSM_MODEL_DIR`, the cache, and a download with the `download`
575/// feature. Returns a human-readable error if the file is in none of them.
576pub fn primary_weight_bytes(spec: &ModelSpec) -> Result<WeightBytes, String> {
577    let file = spec
578        .files
579        .first()
580        .ok_or_else(|| format!("model '{}' has no weight files", spec.id))?;
581    weight_file_bytes(spec.id, file)
582}
583
584/// The bytes of every weight file of a model, in registry order.
585///
586/// Multi-file models (e.g. NeXtQSM's BFR + VJP U-Nets) need all pieces; the order
587/// matches [`ModelSpec::files`], which the per-model glue relies on. Same
588/// resolution as [`primary_weight_bytes`], per file.
589pub fn all_weight_bytes(spec: &ModelSpec) -> Result<Vec<WeightBytes>, String> {
590    if spec.files.is_empty() {
591        return Err(format!("model '{}' has no weight files", spec.id));
592    }
593    spec.files
594        .iter()
595        .map(|f| weight_file_bytes(spec.id, f))
596        .collect()
597}
598
599/// Convenience: resolve a model **by id** and read its primary weight file. Combines
600/// [`find_model`] + [`primary_weight_bytes`] so callers (pipeline runners, external
601/// hosts) don't repeat the lookup+fetch boilerplate. Errors if the id is unknown.
602pub fn primary_weight(id: &str) -> Result<WeightBytes, String> {
603    let spec = find_model(id).ok_or_else(|| format!("'{id}' not in model registry"))?;
604    primary_weight_bytes(spec)
605}
606
607/// Convenience: resolve a model **by id** and read all its weight files, in registry
608/// order (for multi-file models like NeXtQSM). See [`all_weight_bytes`].
609pub fn weights(id: &str) -> Result<Vec<WeightBytes>, String> {
610    let spec = find_model(id).ok_or_else(|| format!("'{id}' not in model registry"))?;
611    all_weight_bytes(spec)
612}
613
614/// Pre-fetch (download + cache) all of a model's weight files, reporting per-file
615/// progress via `on_progress(file_name, downloaded_bytes, total_bytes)`.
616///
617/// Intended for hosts (e.g. QSMxT) that want to show a download bar before running
618/// inference: call this first, then the normal `primary_weight`/`weights` path finds the
619/// files and does no further network I/O. Files already present (supplied with
620/// [`provide_weights`], in `$QSM_MODEL_DIR` or the cache) are skipped silently. Unknown `id`
621/// (a non-model / classical algorithm) is a no-op — returns `Ok(())` so callers can invoke it
622/// unconditionally. On `wasm32-unknown-unknown` a file that was not supplied is an error.
623pub fn prefetch_with_progress(
624    id: &str,
625    #[allow(unused_variables)] on_progress: &mut dyn FnMut(&str, u64, u64),
626) -> Result<(), String> {
627    let Some(spec) = find_model(id) else {
628        return Ok(());
629    };
630    for file in spec.files {
631        if weight_file_present(file) {
632            continue;
633        }
634        #[cfg(all(
635            feature = "download",
636            not(all(target_arch = "wasm32", target_os = "unknown"))
637        ))]
638        {
639            download::ensure_file_with_progress(id, file, &mut |done, total| {
640                on_progress(file.name, done, total)
641            })
642            .map_err(|e| e.to_string())?;
643        }
644        #[cfg(not(all(
645            feature = "download",
646            not(all(target_arch = "wasm32", target_os = "unknown"))
647        )))]
648        {
649            return Err(missing_weights_message(id, file, HOST_FS));
650        }
651    }
652    Ok(())
653}
654
655/// Whether this build can reach a file system and network for weights (`$QSM_MODEL_DIR`, the
656/// cache, a download). `false` on `wasm32-unknown-unknown`, where [`provide_weights`] is the
657/// only source.
658#[cfg_attr(
659    all(
660        feature = "download",
661        not(all(target_arch = "wasm32", target_os = "unknown"))
662    ),
663    allow(dead_code)
664)]
665const HOST_FS: bool = !cfg!(all(target_arch = "wasm32", target_os = "unknown"));
666
667/// The error for a weight file that is nowhere this build can look.
668#[cfg_attr(
669    all(
670        feature = "download",
671        not(all(target_arch = "wasm32", target_os = "unknown"))
672    ),
673    allow(dead_code)
674)]
675fn missing_weights_message(id: &str, file: &WeightFile, host_fs: bool) -> String {
676    if !host_fs {
677        format!(
678            "weights for '{id}' ('{}') were not provided. This build has no file system or \
679             network: fetch the file (registry URL {}) and pass its bytes to \
680             qsm_core::models::provide_weights(\"{}\", bytes) before running.",
681            file.name,
682            if file.url.is_empty() {
683                "<not hosted>"
684            } else {
685                file.url
686            },
687            file.name
688        )
689    } else {
690        format!(
691            "weights for '{id}' ('{}') not found. Supply them with \
692             qsm_core::models::provide_weights, set $QSM_MODEL_DIR to a directory containing \
693             the file, or build with the 'download' feature to fetch it.",
694            file.name
695        )
696    }
697}
698
699/// Resolve one weight file to its bytes: supplied bytes, then (native) local override / cache,
700/// then download.
701fn weight_file_bytes(id: &str, file: &WeightFile) -> Result<WeightBytes, String> {
702    if let Some(bytes) = provided_weights(file.name) {
703        return Ok(bytes);
704    }
705    host_weight_bytes(id, file)
706}
707
708#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
709fn host_weight_bytes(id: &str, file: &WeightFile) -> Result<WeightBytes, String> {
710    Err(missing_weights_message(id, file, HOST_FS))
711}
712
713#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
714fn host_weight_bytes(id: &str, file: &WeightFile) -> Result<WeightBytes, String> {
715    let read = |p: &std::path::Path| {
716        std::fs::read(p)
717            .map(WeightBytes::new)
718            .map_err(|e| format!("reading {}: {e}", p.display()))
719    };
720    // With `download`, every path goes through `ensure_file`, which checks a local or cached
721    // file's SHA-256 and re-fetches a cache entry that doesn't match. Reading the cache directly
722    // trusted whatever was there: a truncated entry (left by an interrupted or racing write, or
723    // restored from a CI cache) then failed at model load ("buffer underflow", "model proto does
724    // not contain a graph") instead of being replaced.
725    #[cfg(feature = "download")]
726    {
727        download::ensure_file(id, file)
728            .map_err(|e| e.to_string())
729            .and_then(|p| read(&p))
730    }
731    #[cfg(not(feature = "download"))]
732    {
733        match resolve_local(file) {
734            Some(path) => read(&path),
735            None => Err(missing_weights_message(id, file, HOST_FS)),
736        }
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    /// The provider and `$QSM_MODEL_DIR` are process-wide; tests that touch them take this.
745    static LOCK: Mutex<()> = Mutex::new(());
746
747    fn lock() -> std::sync::MutexGuard<'static, ()> {
748        LOCK.lock().unwrap_or_else(|e| e.into_inner())
749    }
750
751    #[test]
752    fn sha256_matches_known_vector() {
753        assert_eq!(
754            sha256_hex(b""),
755            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
756        );
757        assert_eq!(
758            sha256_hex(b"abc"),
759            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
760        );
761    }
762
763    #[test]
764    fn weight_file_names_are_unique() {
765        // The provider (like `$QSM_MODEL_DIR` and the cache) is keyed by file name.
766        let mut names: Vec<_> = all_models()
767            .iter()
768            .flat_map(|m| m.files.iter().map(|f| f.name))
769            .collect();
770        let n = names.len();
771        names.sort_unstable();
772        names.dedup();
773        assert_eq!(
774            names.len(),
775            n,
776            "duplicate weight file names in the registry"
777        );
778    }
779
780    #[test]
781    fn unknown_file_is_refused() {
782        let _g = lock();
783        assert_eq!(
784            provide_weights("no-such-model.onnx", vec![1, 2, 3]),
785            Err(ProvideWeightsError::UnknownFile(
786                "no-such-model.onnx".into()
787            ))
788        );
789    }
790
791    #[test]
792    fn checksum_mismatch_is_refused_and_not_stored() {
793        let _g = lock();
794        forget_weights("xqsm.onnx");
795        let err = provide_weights("xqsm.onnx", b"not the xqsm weights".to_vec()).unwrap_err();
796        match &err {
797            ProvideWeightsError::ChecksumMismatch {
798                file,
799                expected,
800                got,
801            } => {
802                assert_eq!(file, "xqsm.onnx");
803                assert_eq!(expected, find_weight_file("xqsm.onnx").unwrap().sha256);
804                assert_eq!(got, &sha256_hex(b"not the xqsm weights"));
805            }
806            other => panic!("expected a checksum mismatch, got {other:?}"),
807        }
808        assert!(err.to_string().contains("xqsm.onnx"));
809        assert!(provided_weights("xqsm.onnx").is_none());
810    }
811
812    #[test]
813    fn provided_bytes_take_precedence_over_model_dir() {
814        let _g = lock();
815        let dir = std::env::temp_dir().join(format!("qsm-provider-test-{}", std::process::id()));
816        std::fs::create_dir_all(&dir).unwrap();
817        // A pending model has no registry hash, so any bytes are accepted (as from $QSM_MODEL_DIR).
818        let name = "synthseg-2.0.onnx";
819        std::fs::write(dir.join(name), b"from the model dir").unwrap();
820        let old = std::env::var_os("QSM_MODEL_DIR");
821        std::env::set_var("QSM_MODEL_DIR", &dir);
822
823        assert_eq!(
824            &*primary_weight("synthseg-2.0").unwrap(),
825            b"from the model dir"
826        );
827        assert!(weight_file_present(find_weight_file(name).unwrap()));
828
829        let supplied = b"supplied in memory".to_vec();
830        let ptr = supplied.as_ptr();
831        provide_weights(name, supplied).unwrap();
832        let got = primary_weight("synthseg-2.0").unwrap();
833        assert_eq!(&*got, b"supplied in memory");
834        // Handed out by reference: the run sees the host's buffer, not a copy.
835        assert_eq!(got.as_ptr(), ptr);
836        assert_eq!(weights("synthseg-2.0").unwrap()[0].as_ptr(), ptr);
837
838        assert!(forget_weights(name));
839        assert!(!forget_weights(name));
840        assert_eq!(
841            &*primary_weight("synthseg-2.0").unwrap(),
842            b"from the model dir"
843        );
844
845        match old {
846            Some(v) => std::env::set_var("QSM_MODEL_DIR", v),
847            None => std::env::remove_var("QSM_MODEL_DIR"),
848        }
849        let _ = std::fs::remove_dir_all(&dir);
850    }
851
852    /// A corrupt local copy (wrong SHA-256) must not be loaded: it is re-fetched, or, with no
853    /// URL to fetch from, refused. Before, a truncated cache entry was read as-is and failed at
854    /// model load.
855    #[cfg(feature = "download")]
856    #[test]
857    fn corrupt_local_weights_are_not_loaded() {
858        let _g = lock();
859        let dir = std::env::temp_dir().join(format!("qsm-corrupt-test-{}", std::process::id()));
860        std::fs::create_dir_all(&dir).unwrap();
861        let name = "corrupt-test.onnx";
862        let good = b"the real weights";
863        let sha = Box::leak(sha256_hex(good).into_boxed_str());
864        let file = WeightFile { name, url: "", sha256: sha, bytes: good.len() as u64 };
865        let old = std::env::var_os("QSM_MODEL_DIR");
866        std::env::set_var("QSM_MODEL_DIR", &dir);
867
868        std::fs::write(dir.join(name), b"the real wei").unwrap(); // truncated
869        assert!(host_weight_bytes("corrupt-test", &file).is_err());
870        std::fs::write(dir.join(name), good).unwrap();
871        assert_eq!(&*host_weight_bytes("corrupt-test", &file).unwrap(), good);
872
873        match old {
874            Some(v) => std::env::set_var("QSM_MODEL_DIR", v),
875            None => std::env::remove_var("QSM_MODEL_DIR"),
876        }
877        let _ = std::fs::remove_dir_all(&dir);
878    }
879
880    #[test]
881    fn prefetch_skips_provided_files() {
882        let _g = lock();
883        provide_weights("synthseg-2.0.onnx", b"x".to_vec()).unwrap();
884        // Pending (no URL): without the provided bytes this would have to fail.
885        prefetch_with_progress("synthseg-2.0", &mut |_, _, _| panic!("nothing to fetch")).unwrap();
886        forget_weights("synthseg-2.0.onnx");
887    }
888
889    #[test]
890    fn missing_weights_without_a_file_system_names_the_provider() {
891        // The message `wasm32-unknown-unknown` builds return when a file was not provided.
892        let spec = find_model("xqsm").unwrap();
893        let msg = missing_weights_message(spec.id, &spec.files[0], false);
894        assert!(msg.contains("xqsm.onnx"), "{msg}");
895        assert!(msg.contains("provide_weights"), "{msg}");
896        assert!(msg.contains(spec.files[0].url), "{msg}");
897        assert!(!msg.contains("QSM_MODEL_DIR"), "{msg}");
898    }
899}