1use serde_json::Value;
18
19use crate::Error;
20use crate::catalogue::{
21 CatalogueEntry, Download, EntryKind, License, MAX_RESPONSE_BYTES, PREFERRED_DOWNLOADS,
22 feed_client, get_bounded_text,
23};
24
25#[derive(Debug, Clone)]
29pub struct CleanupSource {
30 pub listing: String,
32 pub listing_tail: String,
35 pub tags: Vec<String>,
40 pub blobs_base: String,
42 pub files_base: String,
45}
46
47impl Default for CleanupSource {
48 fn default() -> Self {
49 Self {
50 listing: "https://huggingface.co/api/models?filter=".to_owned(),
51 listing_tail: "&filter=gguf&sort=downloads&direction=-1&limit=100&full=true".to_owned(),
52 tags: [
53 "text-normalization",
54 "text-transformation",
55 "punctuation",
56 "truecasing",
57 "asr-postprocessing",
58 "post-processing",
59 ]
60 .iter()
61 .map(|tag| (*tag).to_owned())
62 .collect(),
63 blobs_base: "https://huggingface.co/api/models/".to_owned(),
64 files_base: "https://huggingface.co/".to_owned(),
65 }
66 }
67}
68
69const DOWNLOADS_FLOOR: u64 = 100;
72
73const TEXT_TASKS: [&str; 5] = [
79 "text-generation",
80 "text2text-generation",
81 "token-classification",
82 "fill-mask",
83 "text-classification",
84];
85
86use crate::normalize::CAUSAL_ARCHS;
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90struct Repo {
91 id: String,
92 downloads: u64,
93 license: Option<String>,
94 pipeline: Option<String>,
96 bases: Vec<String>,
99 language_tags: Vec<String>,
102}
103
104impl Repo {
105 fn from_row(row: &Value) -> Option<Self> {
106 let id = row.get("id")?.as_str()?.to_owned();
107 let downloads = row.get("downloads").and_then(Value::as_u64).unwrap_or_default();
108 let pipeline = row.get("pipeline_tag").and_then(Value::as_str).map(str::to_owned);
109 let mut license = None;
110 let mut bases: Vec<String> = Vec::new();
111 let mut language_tags: Vec<String> = Vec::new();
112 for tag in row.get("tags").and_then(Value::as_array).into_iter().flatten() {
113 let Some(tag) = tag.as_str() else { continue };
114 if let Some(spdx) = tag.strip_prefix("license:") {
115 license = Some(spdx.to_owned());
116 }
117 if let Some(base) = tag.strip_prefix("base_model:") {
118 let base = base.strip_prefix("quantized:").unwrap_or(base);
119 let base = base.strip_prefix("finetune:").unwrap_or(base);
120 if !bases.iter().any(|seen| seen == base) {
121 bases.push(base.to_owned());
122 }
123 }
124 if tag.len() == 2 && tag.chars().all(|c| c.is_ascii_lowercase()) {
125 language_tags.push(tag.to_owned());
126 }
127 }
128 Some(Self { id, downloads, license, pipeline, bases, language_tags })
129 }
130
131 fn is_text(&self) -> bool {
134 match self.pipeline.as_deref() {
135 Some(task) => TEXT_TASKS.contains(&task),
136 None => true,
137 }
138 }
139}
140
141fn candidates(rows: &[Repo]) -> Vec<Repo> {
152 let ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
153 let mut kept: Vec<Repo> = Vec::new();
154 let mut claimed: Vec<String> = Vec::new();
155 for row in rows.iter().filter(|row| row.is_text()) {
156 let upstream = row.bases.iter().find(|base| corroborates(base, &row.id));
157 if let Some(upstream) = upstream {
158 if ids.contains(&upstream.as_str()) {
159 continue;
161 }
162 if claimed.iter().any(|seen| seen == upstream) {
163 continue;
165 }
166 claimed.push(upstream.clone());
167 }
168 kept.push(row.clone());
169 }
170 kept
171}
172
173fn corroborates(base: &str, id: &str) -> bool {
178 let name = base_name(base);
179 !name.is_empty() && id.to_lowercase().contains(&name.to_lowercase())
180}
181
182fn base_name(id: &str) -> &str {
184 id.rsplit('/').next().unwrap_or(id)
185}
186
187fn declared_languages(card: Option<&Value>, language_tags: &[String]) -> Vec<String> {
191 let from_card: Vec<String> = card
192 .and_then(|card| card.get("language"))
193 .and_then(Value::as_array)
194 .map(|list| {
195 list.iter().filter_map(Value::as_str).map(str::to_lowercase).collect::<Vec<_>>()
196 })
197 .unwrap_or_default();
198 if from_card.is_empty() {
199 return language_tags.to_vec();
200 }
201 from_card
202}
203
204fn downloads_from(files: &[Value], repo: &str, files_base: &str) -> Vec<Download> {
207 let mut downloads = Vec::new();
208 for file in files {
209 let Some(name) = file.get("rfilename").and_then(Value::as_str) else {
210 continue;
211 };
212 if !name.to_lowercase().ends_with(".gguf") {
213 continue;
214 }
215 let Some(quant) = quant_of(name) else {
216 continue;
217 };
218 downloads.push(Download {
219 quant,
220 filename: base_name(name).to_owned(),
221 size_bytes: file.get("size").and_then(Value::as_u64).unwrap_or_default(),
222 sha256: file
223 .get("lfs")
224 .and_then(|lfs| lfs.get("sha256"))
225 .and_then(Value::as_str)
226 .map(str::to_owned),
227 url: Some(format!("{files_base}{repo}/resolve/main/{name}")),
228 });
229 }
230 downloads
231}
232
233fn quant_of(name: &str) -> Option<String> {
239 let upper = name.to_uppercase();
240 PREFERRED_DOWNLOADS
241 .iter()
242 .filter(|quant| {
243 upper.match_indices(**quant).any(|(at, _)| {
244 let before = upper[..at].chars().next_back();
245 let after = upper[at + quant.len()..].chars().next();
246 before.is_none_or(|c| !c.is_alphanumeric())
247 && after.is_none_or(|c| !c.is_alphanumeric())
248 })
249 })
250 .max_by_key(|quant| quant.len())
251 .map(|quant| (*quant).to_owned())
252}
253
254pub fn fetch_cleanup(source: &CleanupSource) -> Result<Vec<CatalogueEntry>, Error> {
265 let client = feed_client(&source.listing)?;
266 let mut repos: Vec<Repo> = Vec::new();
267 let mut answered = 0_usize;
268 for tag in &source.tags {
269 let url = format!("{}{tag}{}", source.listing, source.listing_tail);
270 let Ok(listing) = get_bounded_text(&client, &url, MAX_RESPONSE_BYTES) else {
271 tracing::debug!(
272 event_name = "cleanup_feed_tag_listing_unreadable",
273 url = %url,
274 "cleanup feed: one tag's listing could not be read"
275 );
276 continue;
277 };
278 let Ok(rows) = serde_json::from_str::<Vec<Value>>(&listing) else {
279 continue;
280 };
281 answered += 1;
282 for repo in rows.iter().filter_map(Repo::from_row) {
283 match repos.iter_mut().find(|seen| seen.id == repo.id) {
286 Some(seen) => seen.downloads = seen.downloads.max(repo.downloads),
287 None => repos.push(repo),
288 }
289 }
290 }
291 if answered == 0 {
292 return Err(Error::Catalogue {
293 message: format!("no listing under {} answered", source.listing),
294 });
295 }
296 repos.sort_by_key(|repo| std::cmp::Reverse(repo.downloads));
297
298 let mut entries = Vec::new();
299 for repo in candidates(&repos) {
300 if repo.downloads < DOWNLOADS_FLOOR {
301 continue;
302 }
303 let url = format!("{}{}?blobs=true", source.blobs_base, repo.id);
304 let Ok(blobs) = get_bounded_text(&client, &url, MAX_RESPONSE_BYTES) else {
305 tracing::debug!(
306 event_name = "cleanup_feed_repo_blobs_unreadable",
307 url = %url,
308 "cleanup feed: one repo's blobs could not be read"
309 );
310 continue;
311 };
312 let Ok(blobs) = serde_json::from_str::<Value>(&blobs) else {
313 continue;
314 };
315 let downloads = downloads_from(
316 blobs.get("siblings").and_then(Value::as_array).map_or(&[], Vec::as_slice),
317 &repo.id,
318 &source.files_base,
319 );
320 if downloads.is_empty() {
321 continue;
323 }
324 if !runs_here(&blobs) {
325 tracing::debug!(
326 event_name = "cleanup_feed_repo_not_runnable",
327 repo = %repo.id,
328 "cleanup feed: a repo the generator cannot run"
329 );
330 continue;
331 }
332 let languages = declared_languages(blobs.get("cardData"), &repo.language_tags);
333 if !languages.is_empty() && !languages.iter().any(|language| language == "en") {
334 continue;
335 }
336 entries.push(entry_for(&repo, downloads, languages));
337 }
338 Ok(entries)
339}
340
341fn runs_here(blobs: &Value) -> bool {
346 blobs
347 .get("gguf")
348 .and_then(|gguf| gguf.get("architecture"))
349 .and_then(Value::as_str)
350 .is_some_and(|arch| CAUSAL_ARCHS.contains(&arch))
351}
352
353fn entry_for(repo: &Repo, downloads: Vec<Download>, languages: Vec<String>) -> CatalogueEntry {
356 CatalogueEntry {
357 variant: repo.id.clone(),
358 display_name: repo.id.clone(),
359 family: repo.id.split_once('/').map(|(owner, _)| owner.to_owned()).unwrap_or_default(),
360 params: None,
361 license: repo.license.clone().map(|spdx| License { display: spdx.clone(), spdx }),
362 languages,
363 capabilities: crate::catalogue::Capabilities::default(),
364 headline_benchmark: None,
365 published_repo: Some(repo.id.clone()),
366 downloads,
367 speed_benchmarks: Vec::new(),
368 accuracy_benchmarks: Vec::new(),
369 kind: EntryKind::Normalizer,
370 download_count: Some(repo.downloads),
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
384 fn a_quant_is_matched_on_its_own_token() {
385 assert_eq!(quant_of("model-BF16.gguf").as_deref(), Some("BF16"));
386 assert_eq!(quant_of("model.F16.gguf").as_deref(), Some("F16"));
387 assert_eq!(quant_of("model-Q4_K_M.gguf").as_deref(), Some("Q4_K_M"));
388 assert_eq!(quant_of("model-Q8_0.gguf").as_deref(), Some("Q8_0"));
389 assert_eq!(quant_of("model-Q6_K.gguf"), None);
390 assert_eq!(quant_of("model-Q5_K_M.gguf"), None);
391 assert_eq!(quant_of("model-Q4_K_S.gguf"), None);
392 assert_eq!(
395 quant_of("model-Q4_K_Mx.gguf"),
396 None,
397 "a quant with a letter after it is another word"
398 );
399 assert_eq!(
400 quant_of("modelxQ4_K_M.gguf"),
401 None,
402 "a quant with a letter before it is another word"
403 );
404 }
405
406 fn listing() -> Vec<Repo> {
409 let raw = include_str!("../tests/fixtures/hf-listing.json");
410 serde_json::from_str::<Vec<Value>>(raw)
411 .expect("the capture parses")
412 .iter()
413 .filter_map(Repo::from_row)
414 .collect()
415 }
416
417 fn kept(listing: &[Repo]) -> Vec<String> {
419 candidates(listing).into_iter().map(|repo| repo.id).collect()
420 }
421
422 #[test]
428 fn a_reupload_collapses_into_the_familys_own_repo() {
429 let kept = kept(&listing());
430
431 assert!(kept.contains(&"superwhisper/s1-mini-GGUF".to_owned()), "got {kept:?}");
432 assert!(
433 !kept.contains(&"Joni000000000/s1-mini-de-v3".to_owned()),
434 "a reupload that names its upstream collapses, got {kept:?}"
435 );
436 assert!(
437 kept.contains(&"Mungert/s1-mini-GGUF".to_owned()),
438 "a card naming a different base is its uploader's own claim, got {kept:?}"
439 );
440 assert!(
441 kept.contains(&"DRTR-J/amnis-light-cleanup-en-v1".to_owned()),
442 "an independent model that merely shares a training base stays, got {kept:?}"
443 );
444 }
445
446 #[test]
450 fn a_family_with_no_repo_of_its_own_keeps_its_best_reupload() {
451 let kept = kept(&listing());
452
453 assert!(
454 kept.contains(&"mradermacher/CeluneNorm-0.6B-v2.0-ctx2048-GGUF".to_owned()),
455 "got {kept:?}"
456 );
457 assert!(
458 kept.contains(&"mradermacher/CeluneNorm-0.6B-v2.0-ctx1024-GGUF".to_owned()),
459 "a different upstream is a different family, got {kept:?}"
460 );
461 }
462
463 #[test]
466 fn the_blobs_become_downloads_with_their_own_sizes_and_digests() {
467 let raw = include_str!("../tests/fixtures/hf-blobs.json");
468 let blobs: Value = serde_json::from_str(raw).expect("the capture parses");
469 let files = blobs.get("siblings").and_then(Value::as_array).expect("siblings");
470
471 let downloads = downloads_from(files, "superwhisper/s1-mini-GGUF", "https://hf/");
472
473 assert_eq!(downloads.len(), 2, "the repo's two gguf files, got {downloads:?}");
474 let f16 = downloads.iter().find(|d| d.quant == "F16").expect("the f16 build");
475 assert_eq!(f16.filename, "s1-mini-f16.gguf");
476 assert_eq!(f16.size_bytes, 1_509_347_232);
477 assert_eq!(
478 f16.sha256.as_deref(),
479 Some("0370da4f1bae19e3150bcafa33c5d396c15f97bf25519540a3e013db5cc00af4"),
480 "the digest the Hub publishes for the file, which is the pin's own"
481 );
482 assert_eq!(
483 f16.url.as_deref(),
484 Some("https://hf/superwhisper/s1-mini-GGUF/resolve/main/s1-mini-f16.gguf")
485 );
486 assert!(
487 downloads.iter().all(|download| std::path::Path::new(&download.filename)
488 .extension()
489 .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf"))),
490 "a banner or a licence is not a download"
491 );
492 }
493
494 #[test]
500 fn only_an_architecture_this_runtime_runs_is_offered() {
501 let raw = include_str!("../tests/fixtures/hf-blobs.json");
502 let blobs: Value = serde_json::from_str(raw).expect("the capture parses");
503 assert!(runs_here(&blobs), "the s1-mini capture is a qwen3 build");
504
505 for arch in ["t5", "bert", "fireredpunc", "pcs"] {
506 let other: Value = serde_json::from_str(
507 &serde_json::json!({ "gguf": { "architecture": arch } }).to_string(),
508 )
509 .unwrap();
510 assert!(!runs_here(&other), "{arch} must not be offered");
511 }
512 assert!(
513 !runs_here(&serde_json::json!({})),
514 "a blobs answer with no architecture is not a promise either"
515 );
516 }
517
518 #[test]
523 fn declared_languages_read_the_card_first_then_the_tags() {
524 let card: Value = serde_json::from_str(r#"{"language":["en","de"]}"#).unwrap();
525 assert_eq!(declared_languages(Some(&card), &["fr".to_owned()]), ["en", "de"]);
526
527 assert_eq!(declared_languages(None, &["en".to_owned()]), ["en"]);
528 assert!(
529 declared_languages(Some(&serde_json::json!({})), &[]).is_empty(),
530 "a card with no language declares nothing"
531 );
532
533 let row: Value = serde_json::from_str(
534 r#"{"id":"a/b","downloads":1,"tags":["gguf","asr","en","license:mit"]}"#,
535 )
536 .unwrap();
537 let repo = Repo::from_row(&row).expect("the row parses");
538 assert_eq!(repo.language_tags, ["en"], "an ordinary tag is not a language");
539 assert_eq!(repo.license.as_deref(), Some("mit"));
540 }
541}