kernel/install/
file_selection.rs1use 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"];
12const 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"];
20const QUANT_PREFERENCE: [&str; 6] = ["q4_k_m", "q4_0", "q5_k_m", "q6_k", "q8_0", "f16"];
22const COMPANION_CAP: i64 = 10 << 20;
24const CONFIG_CAP: i64 = 100 << 20;
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct HFSibling {
30 pub rfilename: String,
32 pub bytes: Option<i64>,
34 pub sha256: Option<String>,
38}
39
40impl HFSibling {
41 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 pub fn with_sha256(mut self, sha256: Option<String>) -> Self {
52 self.sha256 = sha256;
53 self
54 }
55
56 pub fn is_weight(&self) -> bool {
58 is_weight_path(&self.rfilename)
59 }
60}
61
62pub fn is_weight_path(path: &str) -> bool {
64 WEIGHT_EXTENSIONS.contains(&file_extension(path).as_str())
65}
66
67pub 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
76pub 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
103fn segments(path: &str) -> Vec<&str> {
105 path.split('/').filter(|part| !part.is_empty()).collect()
106}
107
108fn 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
145fn 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 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 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
209fn 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
230fn 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
258fn 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
290fn 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
345fn subtree(path: &str) -> Option<&str> {
349 let segments = segments(path);
350 (segments.len() > 1).then(|| segments[0])
351}