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}