Skip to main content

forge_dictate/
cleanup.rs

1//! The cleanup feed: the normalizers the Hub carries, as the page's cleanup
2//! candidates.
3//!
4//! **A second source, parsed rather than constructed.** The runtime's own
5//! feed carries the speech models; this one is the Hub's models listing,
6//! filtered by the Hub's own query (`text-normalization` + `gguf`,
7//! downloads-sorted) and then by ours: a repo that only reuploads another
8//! listing member collapses into it, a repo that declares languages must
9//! declare English, a repo nobody has fetched is not a candidate, and a repo
10//! with no quantisation this runtime loads is not one either. Every surviving
11//! repo's blobs answer with each file's size AND its sha256, so unlike the
12//! speech feed a cleanup download can be digest-checked.
13//!
14//! **The kind is what separates the two feeds' entries**, and it is
15//! exhaustive: a consumer that ignores it does not compile.
16
17use 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/// Where the cleanup feed is enumerated: the Hub's models listing, with the
26/// filters that are the HUB's as query parameters, and the per-repo blobs
27/// endpoint that answers with the files.
28#[derive(Debug, Clone)]
29pub struct CleanupSource {
30    /// The listing up to the first tag: `<listing><tag><listing_tail>`.
31    pub listing: String,
32    /// What follows a tag in the query: the gguf filter, the order and the
33    /// page size.
34    pub listing_tail: String,
35    /// The tags a cleanup model's repo carries. **One `filter=` per call
36    /// narrows by AND**, so the union is fetched tag by tag and merged - a
37    /// model whose card says `punctuation` and never says
38    /// `text-normalization` is a cleanup candidate just the same.
39    pub tags: Vec<String>,
40    /// URL a repo id is appended to for its blobs: `<blobs_base><id>?blobs=true`.
41    pub blobs_base: String,
42    /// URL a repo id and file are appended to for the file itself:
43    /// `<files_base><id>/resolve/main/<file>`.
44    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
69/// The downloads floor: a repo nobody has fetched says nothing about whether
70/// it works, and the listing's tail is full of them.
71const DOWNLOADS_FLOOR: u64 = 100;
72
73/// The task tags a text model carries. A repo that DECLARES one of these is a
74/// text model; a repo that declares some other task is not one, whatever its
75/// other tags say - the union of tags catches a VAE post-processor whose card
76/// says `post-processing` and nothing else. A card that declares no task is
77/// kept, because silence is not a claim.
78const 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/// One repo as the listing describes it, before our own filters.
89#[derive(Debug, Clone, PartialEq, Eq)]
90struct Repo {
91    id: String,
92    downloads: u64,
93    license: Option<String>,
94    /// The task the repo declares, when it declares one.
95    pipeline: Option<String>,
96    /// Every `base_model:` target, the quantised and fine-tuned prefixes
97    /// stripped, deduplicated in tag order.
98    bases: Vec<String>,
99    /// The two-letter codes among the tags, where the Hub mirrors the card's
100    /// language field when the listing does not carry the card.
101    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    /// Whether the repo's card says it is a text model, on the terms
132    /// [`TEXT_TASKS`] sets out.
133    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
141/// The repos worth offering as candidates, in the listing's own order - the
142/// Hub sorts by downloads, which is the only ranking it offers.
143///
144/// **A reupload collapses into the family's own repo.** A reupload keeps the
145/// upstream's name and names it in `base_model`, so the tag is trusted only
146/// where it CORROBORATES the repo's own name; an uploader whose card names
147/// some other base is making its own claim, and grouping by it would collapse
148/// unrelated models. When the family's own repo is not in the listing - the
149/// upstream has no GGUF build - the first reupload stands for the family,
150/// which is the most downloaded one, since the listing arrives sorted.
151fn 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                // The family's own repo is here: this one is a reupload of it.
160                continue;
161            }
162            if claimed.iter().any(|seen| seen == upstream) {
163                // Another reupload of the same upstream already stands.
164                continue;
165            }
166            claimed.push(upstream.clone());
167        }
168        kept.push(row.clone());
169    }
170    kept
171}
172
173/// Whether `base` names the repo's own model: the base's last segment
174/// appears in the repo id. `superwhisper/s1-mini` corroborates
175/// `Mungert/s1-mini-GGUF` where that repo names it; `Qwen/Qwen3-0.6B` does
176/// not.
177fn 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
182/// An id's last segment: a repo's own name, or a file's own name.
183fn base_name(id: &str) -> &str {
184    id.rsplit('/').next().unwrap_or(id)
185}
186
187/// The languages a repo declares: the card's own list when the blobs carried
188/// one, else the two-letter codes among its tags. Empty means the card says
189/// nothing, which is not a claim about English either way.
190fn 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
204/// One repo's blobs, as the entry's downloads: every file whose name carries
205/// a quant this runtime loads, with its own size, digest and URL.
206fn 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
233/// The quant a file name spells, when it spells one this runtime loads.
234///
235/// **Matched on a token boundary, longest first**, because `BF16` contains
236/// `F16`: a substring search labels a BF16 file `F16`, and the download
237/// behind it would then be asked for a quantisation the repo does not carry.
238fn 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
254/// Fetch the cleanup feed. Blocking, like everything else here.
255///
256/// The listing is read once per tag and merged, because one `filter=` per
257/// call is an AND: a repo tagged `punctuation` and never
258/// `text-normalization` would otherwise be invisible. A tag whose listing
259/// cannot be read is skipped - one tag's outage is not the feed's - and a
260/// repo whose blobs cannot be read is skipped too, exactly as a bad document
261/// is on the speech side. A feed that answers nothing at all is refused,
262/// because a page drawn over an empty candidate list reads the same as a
263/// page over a healthy one.
264pub 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            // One repo can carry several of the tags: it is one candidate,
284            // read at the most downloads it was listed with.
285            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            // No quantisation this runtime loads: not a candidate.
322            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
341/// Whether a blobs answer declares an architecture this runtime's generator
342/// runs. **The declaration must be there**: a candidate with no declared arch
343/// is one nobody can promise, and the failure is a process abort rather than
344/// a refusal.
345fn 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
353/// One candidate as a catalogue entry, in the same shape the speech feed's
354/// entries carry so the page draws both with one row.
355fn 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    /// **A quant is matched on its own token, longest first, and the ladder
379    /// is the runtime's.** `BF16` contains `F16`: a substring search labels a
380    /// BF16 file F16, and the download behind it would ask the repo for a
381    /// quantisation it does not carry. And a quant the runtime cannot run is
382    /// no download at all, so a repo offering only those is not a candidate.
383    #[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        // The quant has to sit on its own token: inside a longer run of
393        // letters it is part of another word, not this quant.
394        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    /// A live capture of the listing - the Hub's own answer to the default
407    /// query, trimmed to the rows these rules are about.
408    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    /// The rows the filters keep, by id.
418    fn kept(listing: &[Repo]) -> Vec<String> {
419        candidates(listing).into_iter().map(|repo| repo.id).collect()
420    }
421
422    /// **A reupload collapses into the family's own repo.** The capture
423    /// carries two reuploads of `s1-mini`: one names the upstream in its own
424    /// `base_model` and goes, the family's own repo stands - and a repo whose
425    /// card names some other base (`Qwen/Qwen3-0.6B`) is that uploader's own
426    /// claim, so it neither collapses nor drags an unrelated model with it.
427    #[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    /// Each CeluneNorm version names its own upstream, which has no GGUF repo
447    /// of its own - so each stands as its family's representative rather than
448    /// all of them collapsing into one.
449    #[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    /// The blobs are what turn a repo into downloads: only the files carrying
464    /// a quant this runtime loads, each with its own size, digest and URL.
465    #[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    /// **An architecture the generator cannot run is not a candidate, and
495    /// neither is one that declares no architecture.** A t5 build of a
496    /// grammar-repair model LOADS and then aborts the process mid-run
497    /// (`ggml_abort`, measured live), so offering one would be offering a
498    /// crash; silence about the arch is the same risk without a promise.
499    #[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    /// The languages a repo declares come from the card the blobs carried,
519    /// and only fall back to the tags when it says nothing - and the tags the
520    /// fallback reads are the two-letter codes alone, never an ordinary tag
521    /// that happens to be short.
522    #[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}