Skip to main content

kernel/install/
file_selection.rs

1//! Choosing which files of a Hugging Face repo to download. A repo lists many
2//! siblings; this picks the model weights (preferring one GGUF quantization, or the
3//! safetensors set, dropping duplicate/converted formats) plus the small companion
4//! config/tokenizer files, and drops docs, images, and non-model directories.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use crate::discovery::gguf_shards;
9use crate::install::bytes::saturating_sum;
10
11const WEIGHT_EXTENSIONS: [&str; 6] = ["safetensors", "gguf", "bin", "ckpt", "pt", "pth"];
12/// Never downloaded: documentation, pictures, and whole models in a form nothing
13/// here runs (`msgpack` is flax, `h5` tensorflow, `ot` the rust `tch` bindings,
14/// and `onnx`/`tflite` exported graphs). Without the last of those a 90 MiB
15/// `rust_model.ot` sitting under the support cap rides along as a support file.
16const EXCLUDED_EXTENSIONS: [&str; 12] = [
17    "md", "png", "jpg", "jpeg", "gif", "webp", "svg", "msgpack", "h5", "ot", "onnx", "tflite",
18];
19const EXCLUDED_DIRECTORIES: [&str; 3] = ["onnx", "openvino", "coreml"];
20/// Quantizations in descending preference; the first one present is chosen.
21const QUANT_PREFERENCE: [&str; 6] = ["q4_k_m", "q4_0", "q5_k_m", "q6_k", "q8_0", "f16"];
22/// Companion files kept alongside GGUF weights must be at most this big (10 MiB).
23const COMPANION_CAP: i64 = 10 << 20;
24/// Support files kept for a transformers model must be at most this big (100 MiB).
25const CONFIG_CAP: i64 = 100 << 20;
26
27/// One file listed in a Hugging Face repo, as returned by the hub API.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct HFSibling {
30    /// The file's path within the repo.
31    pub rfilename: String,
32    /// The file's size in bytes, if the listing reported it.
33    pub bytes: Option<i64>,
34    /// The LFS object's SHA-256 (`lfs.sha256`), when the file is stored in LFS. The
35    /// download path uses it as the content-addressed blob name and to verify the
36    /// bytes; plain (non-LFS) files don't report one.
37    pub sha256: Option<String>,
38}
39
40impl HFSibling {
41    /// A sibling for `rfilename` with no known LFS hash.
42    pub fn new(rfilename: impl Into<String>, bytes: Option<i64>) -> Self {
43        Self {
44            rfilename: rfilename.into(),
45            bytes,
46            sha256: None,
47        }
48    }
49
50    /// This sibling with its LFS SHA-256 set.
51    pub fn with_sha256(mut self, sha256: Option<String>) -> Self {
52        self.sha256 = sha256;
53        self
54    }
55
56    /// Whether this file is a model weight (by extension).
57    pub fn is_weight(&self) -> bool {
58        is_weight_path(&self.rfilename)
59    }
60}
61
62/// Whether `path`'s extension marks it a model weight file.
63pub fn is_weight_path(path: &str) -> bool {
64    WEIGHT_EXTENSIONS.contains(&file_extension(path).as_str())
65}
66
67/// The lowercased extension of `path` (the part after the last `.`), or empty when
68/// there is none or the only dot is a leading one (a dotfile has no extension).
69pub fn file_extension(path: &str) -> String {
70    match path.rfind('.') {
71        Some(dot) if dot > 0 => path[dot + 1..].to_lowercase(),
72        _ => String::new(),
73    }
74}
75
76/// Select the files to download from a repo's `siblings`: the eligible weights
77/// (one GGUF quant group, or the safetensors/pytorch set) plus small companions.
78pub fn select(siblings: &[HFSibling]) -> Vec<HFSibling> {
79    let kept: Vec<HFSibling> = siblings
80        .iter()
81        .filter(|s| is_eligible(s))
82        .cloned()
83        .collect();
84    let ggufs: Vec<HFSibling> = kept
85        .iter()
86        .filter(|s| s.rfilename.to_lowercase().ends_with(".gguf"))
87        .cloned()
88        .collect();
89    if !ggufs.is_empty() {
90        let others: Vec<HFSibling> = kept
91            .iter()
92            .filter(|s| !ggufs.contains(s))
93            .cloned()
94            .collect();
95        return gguf_selection(&ggufs, &others);
96    }
97    if kept.iter().any(|s| s.rfilename == "model_index.json") {
98        return diffusers_selection(&kept);
99    }
100    transformers_selection(&kept)
101}
102
103/// The path split into non-empty `/`-segments (empty segments are dropped).
104fn segments(path: &str) -> Vec<&str> {
105    path.split('/').filter(|part| !part.is_empty()).collect()
106}
107
108/// Whether a sibling is a plausible model file: not hidden, not a readme, not an
109/// excluded extension, not a flax/tf checkpoint, and not under an excluded dir.
110fn is_eligible(sibling: &HFSibling) -> bool {
111    let path = &sibling.rfilename;
112    let segments = segments(path);
113    let Some(filename) = segments.last().copied() else {
114        return false;
115    };
116    if segments.iter().any(|segment| segment.starts_with('.')) {
117        return false;
118    }
119    if filename.to_lowercase().starts_with("readme") {
120        return false;
121    }
122    if EXCLUDED_EXTENSIONS.contains(&file_extension(filename).as_str()) {
123        return false;
124    }
125    let stem = filename.to_lowercase();
126    if stem.starts_with("flax_model") || stem.starts_with("tf_model") {
127        return false;
128    }
129    if segments.len() > 1
130        && let Some(first) = segments.first()
131        && EXCLUDED_DIRECTORIES.contains(&first.to_lowercase().as_str())
132    {
133        return false;
134    }
135    true
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
139struct GroupKey {
140    directory: String,
141    base: String,
142    total: usize,
143}
144
145/// Choose one complete GGUF quant group (plus any mmproj projector and small
146/// companions). Shards are grouped by `(directory, base, total)`; a sharded group
147/// is only a candidate once every shard index is present.
148fn gguf_selection(ggufs: &[HFSibling], others: &[HFSibling]) -> Vec<HFSibling> {
149    let mut groups: BTreeMap<GroupKey, Vec<HFSibling>> = BTreeMap::new();
150    let mut seen_indices: BTreeMap<GroupKey, BTreeSet<usize>> = BTreeMap::new();
151    for sibling in ggufs {
152        let segments = segments(&sibling.rfilename);
153        let filename = segments.last().copied().unwrap_or("");
154        let directory = segments[..segments.len().saturating_sub(1)].join("/");
155        if let Some(shard) = gguf_shards::parse(filename) {
156            let key = GroupKey {
157                directory,
158                base: shard.base,
159                total: shard.total,
160            };
161            groups.entry(key.clone()).or_default().push(sibling.clone());
162            seen_indices.entry(key).or_default().insert(shard.index);
163        } else {
164            // A non-sharded GGUF: its base is the name without the `.gguf` suffix
165            // (5 trailing bytes, regardless of case).
166            let stem = &filename[..filename.len() - ".gguf".len()];
167            let key = GroupKey {
168                directory,
169                base: stem.to_owned(),
170                total: 0,
171            };
172            groups.entry(key).or_default().push(sibling.clone());
173        }
174    }
175
176    let mmproj: Vec<HFSibling> = ggufs
177        .iter()
178        .filter(|s| s.rfilename.to_lowercase().contains("mmproj"))
179        .cloned()
180        .collect();
181
182    // `BTreeMap` iteration is already ordered by `(directory, base, total)`, so the
183    // surviving candidate groups come out in that sorted order.
184    let ordered: Vec<Vec<HFSibling>> = groups
185        .iter()
186        .filter(|(key, _)| {
187            !key.base.to_lowercase().contains("mmproj")
188                && (key.total == 0 || seen_indices.get(*key).map(BTreeSet::len) == Some(key.total))
189        })
190        .map(|(_, group)| group.clone())
191        .collect();
192
193    let chosen = pick_quant_group(&ordered);
194    if chosen.is_empty() {
195        return Vec::new();
196    }
197    let companions: Vec<HFSibling> = others
198        .iter()
199        .filter(|s| s.bytes.unwrap_or(0) <= COMPANION_CAP)
200        .cloned()
201        .collect();
202
203    let mut result = chosen.clone();
204    result.extend(mmproj.into_iter().filter(|s| !chosen.contains(s)));
205    result.extend(companions);
206    result
207}
208
209/// Pick the group matching the highest quant preference, else the smallest by total
210/// bytes.
211fn pick_quant_group(groups: &[Vec<HFSibling>]) -> Vec<HFSibling> {
212    if groups.is_empty() {
213        return Vec::new();
214    }
215    for token in QUANT_PREFERENCE {
216        if let Some(matched) = groups
217            .iter()
218            .find(|group| group.iter().any(|s| matches_quant(&s.rfilename, token)))
219        {
220            return matched.clone();
221        }
222    }
223    groups
224        .iter()
225        .min_by_key(|group| saturating_sum(group.iter().filter_map(|s| s.bytes)))
226        .cloned()
227        .unwrap_or_default()
228}
229
230/// Whether `rfilename` contains `token` as a whole quant word (bounded by non-quant
231/// characters on both sides), so `q4_0` matches `model-q4_0.gguf` but not `xq4_0y`.
232fn matches_quant(rfilename: &str, token: &str) -> bool {
233    let name = rfilename.to_lowercase();
234    let mut start = 0;
235    while let Some(offset) = name[start..].find(token) {
236        let at = start + offset;
237        let end = at + token.len();
238        let before_ok = match name[..at].chars().next_back() {
239            None => true,
240            Some(character) => !is_quant_character(character),
241        };
242        let after_ok = match name[end..].chars().next() {
243            None => true,
244            Some(character) => !is_quant_character(character),
245        };
246        if before_ok && after_ok {
247            return true;
248        }
249        start = end;
250    }
251    false
252}
253
254fn is_quant_character(character: char) -> bool {
255    character.is_alphanumeric() || character == '_'
256}
257
258/// Diffusers selection: drop root-level weights when a subtree carries them, drop a
259/// `.bin`/`.ckpt`/… when a `.safetensors` twin exists, and drop `.fp16.`/`.non_ema.`
260/// variants when the plain form is present.
261fn diffusers_selection(kept: &[HFSibling]) -> Vec<HFSibling> {
262    let paths: BTreeSet<&str> = kept.iter().map(|s| s.rfilename.as_str()).collect();
263    let tree_has_weights = kept
264        .iter()
265        .any(|s| s.rfilename.contains('/') && s.is_weight());
266    kept.iter()
267        .filter(|sibling| {
268            let path = &sibling.rfilename;
269            if tree_has_weights && !path.contains('/') && sibling.is_weight() {
270                return false;
271            }
272            let ext = file_extension(path);
273            if ["bin", "ckpt", "pt", "pth"].contains(&ext.as_str())
274                && let Some(stem) = path.get(..path.len().saturating_sub(ext.len() + 1))
275                && paths.contains(format!("{stem}.safetensors").as_str())
276            {
277                return false;
278            }
279            for variant in [".fp16.", ".non_ema."] {
280                if path.contains(variant) && paths.contains(path.replace(variant, ".").as_str()) {
281                    return false;
282                }
283            }
284            true
285        })
286        .cloned()
287        .collect()
288}
289
290/// Transformers selection: the root safetensors set (or the pytorch `.bin` set when
291/// none), plus small support files (config/tokenizer), excluding index sidecars.
292///
293/// Support files are taken from the whole tree, not just the root: a repo is free
294/// to keep its config under `encoder/` and its tokenizer under `tokenizer/`, and
295/// without those the weights arrive as a model nothing can load. What is not taken
296/// is a subtree carrying weights of its own: an alternative checkpoint, which
297/// brings its own config and tokenizer along and which the user did not ask for.
298fn transformers_selection(kept: &[HFSibling]) -> Vec<HFSibling> {
299    let root: Vec<&HFSibling> = kept.iter().filter(|s| !s.rfilename.contains('/')).collect();
300    let safetensors: Vec<&HFSibling> = root
301        .iter()
302        .copied()
303        .filter(|s| {
304            file_extension(&s.rfilename) == "safetensors"
305                || s.rfilename.ends_with(".safetensors.index.json")
306        })
307        .collect();
308    let has_safetensors_weight = safetensors
309        .iter()
310        .any(|s| file_extension(&s.rfilename) == "safetensors");
311    let weights: Vec<HFSibling> = if has_safetensors_weight {
312        safetensors.iter().copied().cloned().collect()
313    } else {
314        root.iter()
315            .copied()
316            .filter(|s| {
317                s.rfilename.starts_with("pytorch_model")
318                    && (file_extension(&s.rfilename) == "bin"
319                        || s.rfilename.ends_with(".bin.index.json"))
320            })
321            .cloned()
322            .collect()
323    };
324    let alternatives: BTreeSet<&str> = kept
325        .iter()
326        .filter(|s| s.is_weight())
327        .filter_map(|s| subtree(&s.rfilename))
328        .collect();
329    let support: Vec<HFSibling> = kept
330        .iter()
331        .filter(|s| {
332            !s.is_weight()
333                && !s.rfilename.ends_with(".index.json")
334                && s.bytes.unwrap_or(0) <= CONFIG_CAP
335                && subtree(&s.rfilename).is_none_or(|dir| !alternatives.contains(dir))
336        })
337        .cloned()
338        .collect();
339
340    let mut result = weights;
341    result.extend(support);
342    result
343}
344
345/// The top-level directory `path` sits under, or `None` when it is a root file.
346/// Nesting below that is not a subtree of its own: `typed-decisions/encoder` is
347/// part of the checkpoint under `typed-decisions`, and goes or stays with it.
348fn subtree(path: &str) -> Option<&str> {
349    let segments = segments(path);
350    (segments.len() > 1).then(|| segments[0])
351}