Skip to main content

forge_workspace/
catalogue.rs

1//! The dictation model catalogue: the feed's check and the models page's
2//! view.
3//!
4//! The feed itself is `forge-dictate`'s; this module folds it into what the
5//! page draws and owns the rule that decides when a catalogue entry counts
6//! as an update to the model in use.
7
8use std::path::PathBuf;
9use std::time::{Duration, SystemTime};
10
11use forge_dictate::catalogue::{Catalogue, CatalogueEntry};
12use forge_dictate::{ModelFacts, ModelSpec};
13
14use crate::dictate::{DictateModelState, DictateRole, DictateSnapshot};
15
16/// A fetched feed older than this refreshes at boot. Upstream's cadence is
17/// weeks, so a day is what keeps a restart from being the only way a new
18/// variant is ever seen, while a boot nobody asked for a download stays
19/// off the network.
20pub(crate) const REFRESH_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
21
22/// The quants a row's download size prefers, most preferred first: the Q4
23/// build is what a dictation machine runs, and the chain after it keeps a
24/// row that ships no Q4 sized rather than sizeless.
25pub(crate) use forge_dictate::catalogue::PREFERRED_DOWNLOADS;
26
27/// Everything the models page draws, in one read.
28#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29pub struct DictateModelsSnapshot {
30    /// Whether `[dictate] enabled` is set. Carried rather than inferred
31    /// from an empty `in_use`: a failed preflight empties it, and a
32    /// switched-off section still carries the speaking row.
33    pub enabled: bool,
34    /// Where the dictation models land.
35    pub models_dir: Option<PathBuf>,
36    /// The speaking role's own read: its model, the variants that can run,
37    /// and the voices the installed model carries.
38    pub speaking: crate::tts::TtsView,
39    /// The models forge is configured to run, pins and live state.
40    pub in_use: Vec<InUseModel>,
41    /// The catalogue's own freshness.
42    pub check: CatalogueCheck,
43    /// Per in-service model, the catalogue entry worth adopting, when one
44    /// is.
45    pub updates: Vec<ModelUpdate>,
46    /// The whole feed, for the page's search list.
47    pub rows: Vec<CatalogueRow>,
48    /// Where the last model install got to.
49    pub install: crate::install::InstallState,
50    /// Where the last model activation got to.
51    pub activate: crate::install::ActivateState,
52    /// Every model downloaded from the feed on this machine, oldest first.
53    pub installed: Vec<crate::install::InstalledModel>,
54    /// Where the last bench got to: idle, running with its tick, or
55    /// failed with its reason.
56    pub bench: crate::bench::BenchState,
57    /// What benches have measured on this machine, newest first.
58    pub results: Vec<crate::bench::BenchResult>,
59    /// Where the speaking loop's bench got to: idle, running with its
60    /// tick, or failed with its reason.
61    pub speaking_bench: crate::bench::SpeakingBenchState,
62    /// What speaking benches have measured on this machine, newest first.
63    pub speaking_results: Vec<crate::bench::SpeakingBenchResult>,
64    /// The read-aloud set: whether this machine has one, and its passage.
65    pub read_aloud: crate::bench::ReadAloudState,
66}
67
68/// What the last catalogue check did.
69#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
70#[serde(tag = "state", rename_all = "snake_case")]
71pub enum CatalogueCheck {
72    /// Nothing has fetched on this machine.
73    Never,
74    /// A fetch is in flight.
75    Checking,
76    /// The last fetch answered. `at` is when it ran, RFC 3339.
77    Fresh {
78        at: String,
79        /// The upstream release the feed stood at, when it answered.
80        release: Option<String>,
81        /// How many listed documents did not become entries.
82        skipped: usize,
83    },
84    /// The last fetch failed. The rows, when there are any, are the
85    /// last-known feed; nothing in use changed.
86    Unreachable { error: String },
87}
88
89/// One model forge runs, as the page's IN USE rows draw it.
90#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
91pub struct InUseModel {
92    pub role: DictateRole,
93    pub file: String,
94    pub size: u64,
95    /// The digest the spec declares, or `None` for an installed model whose
96    /// upstream publishes none: nothing may print a digest for one.
97    pub sha256: Option<String>,
98    /// The preflight snapshot's own state: pending through ready.
99    pub state: DictateModelState,
100    /// The facts the spec declares.
101    pub facts: ModelFacts,
102    /// The feed's entry for this file, when it carries one.
103    pub catalogue: Option<CatalogueJoin>,
104    /// Where this role's model came from: the config key, the last runtime
105    /// pick, or the compiled pin.
106    pub from: crate::install::ActiveFrom,
107    /// RFC 3339, when a runtime pick chose it.
108    pub at: Option<String>,
109}
110
111/// What the feed says about a file the pin names.
112#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
113pub struct CatalogueJoin {
114    pub variant: String,
115    pub display_name: String,
116    /// The feed's own byte length for the file. Differing from the pin's
117    /// is the feed saying the file was rebuilt.
118    pub size_bytes: u64,
119    pub streaming: bool,
120    pub languages: Vec<String>,
121    pub speed: Option<SpeedFact>,
122}
123
124/// One measured speed row, as the page's speed line draws it.
125#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
126pub struct SpeedFact {
127    pub machine: String,
128    pub backend: String,
129    pub quant: String,
130    pub xrt_wall: f64,
131}
132
133/// One catalogue entry, as the page's candidate rows draw it.
134#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
135pub struct CatalogueRow {
136    pub variant: String,
137    pub display_name: String,
138    pub family: String,
139    /// The parameter count the source published, or `None` when it published
140    /// none - a row drawing `0M params` would claim a measurement.
141    pub params: Option<u64>,
142    pub license: Option<String>,
143    pub languages: Vec<String>,
144    pub streaming: bool,
145    /// The preferred quant's size, when the entry ships one of them.
146    pub download: Option<DownloadFact>,
147    pub speed: Option<SpeedFact>,
148    /// The comparison axis: FLEURS English where the entry carries it,
149    /// else its own headline benchmark.
150    pub wer: Option<WerFact>,
151    /// What the entry is for: which role's candidates it belongs in.
152    pub kind: forge_dictate::catalogue::EntryKind,
153    /// Where a reader can read about it, when the feed names its own page.
154    /// The Hub's entries do; the speech feed's leave this `None` and the
155    /// page opens the entry's document instead.
156    pub url: Option<String>,
157    /// How many times the Hub has served it - the only pre-run signal a
158    /// cleanup candidate carries, and what orders a sweep. The speech feed
159    /// carries no count.
160    pub download_count: Option<u64>,
161}
162
163/// One downloadable quantisation's size.
164#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
165pub struct DownloadFact {
166    pub quant: String,
167    pub size_bytes: u64,
168}
169
170/// One accuracy figure and the benchmark it was measured on.
171#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
172pub struct WerFact {
173    pub dataset: String,
174    pub split: String,
175    pub language: String,
176    pub err_pct: f64,
177}
178
179/// The comparison one role's model is read against: the model in use, and
180/// every English entry it can be compared with, ranked the way the rule
181/// ranks them.
182///
183/// **The whole comparison crosses, not just the winner.** The rule that
184/// picks a candidate is the thing a reader wants to check, and a page that
185/// showed only the pick would be asking to be trusted.
186#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
187pub struct ModelUpdate {
188    pub role: DictateRole,
189    /// The in-service file this is about.
190    pub file: String,
191    /// The numbers the in-service model's own catalogue entry carries,
192    /// which is the baseline every candidate row is read against.
193    pub current: CurrentFacts,
194    /// The comparable entries other than the one in use, fastest first
195    /// with the sharper breaking a tie - the order the rule walks.
196    pub candidates: Vec<CandidateRow>,
197}
198
199/// One compared entry, and what the rule makes of it.
200#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
201pub struct CandidateRow {
202    pub row: CatalogueRow,
203    pub verdict: UpdateVerdict,
204}
205
206/// Why a candidate is, or is not, the one the page proposes.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum UpdateVerdict {
210    /// The fastest entry that beats the model in use on both axes: what the
211    /// page proposes adopting.
212    Recommended,
213    /// It beats the model in use on both axes, and something faster does
214    /// too.
215    BeatsBoth,
216    /// It is no faster than the model in use, whatever its error rate.
217    Slower,
218    /// It is faster and no more accurate.
219    Blunter,
220}
221
222/// The in-service side of an update's comparison.
223#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
224pub struct CurrentFacts {
225    pub speed_x: f64,
226    pub fleurs_en_wer: f64,
227}
228
229/// Fold one entry into the row a candidate list draws.
230pub(crate) fn row_for(entry: &CatalogueEntry) -> CatalogueRow {
231    CatalogueRow {
232        variant: entry.variant.clone(),
233        display_name: entry.display_name.clone(),
234        family: entry.family.clone(),
235        params: entry.params,
236        license: entry.license.as_ref().map(|license| license.display.clone()),
237        languages: entry.languages.clone(),
238        streaming: entry.streaming(),
239        download: download_fact(entry),
240        speed: speed_fact(entry),
241        wer: wer_fact(entry),
242        kind: entry.kind,
243        // Only the Hub's entries name a page of their own; a speech entry's
244        // row keeps the document the page already opens for it.
245        url: match entry.kind {
246            forge_dictate::catalogue::EntryKind::Normalizer => {
247                entry.published_repo.as_ref().map(|repo| format!("https://huggingface.co/{repo}"))
248            }
249            forge_dictate::catalogue::EntryKind::Asr => None,
250        },
251        download_count: entry.download_count,
252    }
253}
254
255/// The speed row the entry's best wall-clock number came from.
256fn speed_fact(entry: &CatalogueEntry) -> Option<SpeedFact> {
257    let row = entry.m4_metal_speed_row()?;
258    Some(SpeedFact {
259        machine: row.machine.clone(),
260        backend: row.backend.clone(),
261        quant: row.quant.clone(),
262        xrt_wall: row.xrt_wall?,
263    })
264}
265
266/// The entry's preferred download, by the quantization chain the list
267/// draws.
268fn download_fact(entry: &CatalogueEntry) -> Option<DownloadFact> {
269    PREFERRED_DOWNLOADS.iter().find_map(|quant| {
270        entry.downloads.iter().find(|download| &download.quant == quant).map(|download| {
271            DownloadFact { quant: download.quant.clone(), size_bytes: download.size_bytes }
272        })
273    })
274}
275
276/// The entry's comparison axis: FLEURS English where it exists, else the
277/// entry's own headline row.
278fn wer_fact(entry: &CatalogueEntry) -> Option<WerFact> {
279    if let Some(err_pct) = entry.fleurs_en_wer() {
280        return Some(WerFact {
281            dataset: "fleurs".to_owned(),
282            split: "test".to_owned(),
283            language: "en".to_owned(),
284            err_pct,
285        });
286    }
287    let headline = entry.headline_benchmark.as_ref()?;
288    Some(WerFact {
289        dataset: headline.dataset.clone(),
290        split: headline.split.clone(),
291        language: headline.language.clone(),
292        err_pct: entry.headline_wer()?,
293    })
294}
295
296/// The feed's entry for a pinned file, joined by file name - the one
297/// thing the pin and the feed share.
298pub(crate) fn join_for(entries: &[CatalogueEntry], spec: &ModelSpec) -> Option<CatalogueJoin> {
299    let entry = entry_for(entries, spec)?;
300    let download = entry.download_for(&spec.file)?;
301    Some(CatalogueJoin {
302        variant: entry.variant.clone(),
303        display_name: entry.display_name.clone(),
304        size_bytes: download.size_bytes,
305        streaming: entry.streaming(),
306        languages: entry.languages.clone(),
307        speed: speed_fact(entry),
308    })
309}
310
311fn entry_for<'e>(entries: &'e [CatalogueEntry], spec: &ModelSpec) -> Option<&'e CatalogueEntry> {
312    entries.iter().find(|entry| entry.download_for(&spec.file).is_some())
313}
314
315/// The IN USE rows: the model's declaration, the preflight state, the
316/// feed's entry when there is one, and where the choice came from.
317pub(crate) fn in_use(
318    entries: &[CatalogueEntry],
319    active: &[(DictateRole, crate::install::ActiveModel)],
320    states: &DictateSnapshot,
321) -> Vec<InUseModel> {
322    active
323        .iter()
324        .map(|(role, model)| {
325            let spec = &model.spec;
326            let state = states
327                .models
328                .iter()
329                .find(|row| row.file == spec.file)
330                .map_or(DictateModelState::Pending, |row| row.state.clone());
331            InUseModel {
332                role: *role,
333                file: spec.file.clone(),
334                size: spec.size,
335                sha256: spec.sha256.clone(),
336                state,
337                facts: spec.facts.clone(),
338                catalogue: join_for(entries, spec),
339                from: model.from.clone(),
340                at: model.at.clone(),
341            }
342        })
343        .collect()
344}
345
346/// The comparison one role's model is read against: the model in use and
347/// every comparable entry, ranked the way the rule ranks them.
348///
349/// **The rule, in full, because the page tables it.** A candidate is
350/// comparable when it carries English and the feed measured it on the same
351/// two axes the model in use carries - m4-max Metal wall-clock realtime
352/// factor, and FLEURS English word error rate. Comparable entries rank
353/// fastest first, with the sharper breaking a tie. The first one that
354/// beats the model in use on BOTH axes is the pick; the ones behind it
355/// that also beat both are listed as such; the rest carry the one axis
356/// they lose on.
357///
358/// **A non-commercial licence is a fact on the row, not a filter.**
359/// Recommending a model used to mean pinning it into forge's shipped
360/// defaults, where `-nc` bars it; what the page does now is download and
361/// run one on this machine, which is the licence's own personal use. The
362/// row states the licence so the constraint is read where the decision is.
363pub(crate) fn updates_for(
364    entries: &[CatalogueEntry],
365    specs: &[(DictateRole, ModelSpec)],
366) -> Vec<ModelUpdate> {
367    specs
368        .iter()
369        .filter_map(|(role, spec)| {
370            let joined = entry_for(entries, spec)?;
371            // The pin's own byte length witnesses that the entry is
372            // about the file in use: a rebuild under the same name
373            // carries other bytes, and its measured rows are not this
374            // file's.
375            if joined.download_for(&spec.file)?.size_bytes != spec.size {
376                return None;
377            }
378            let current_speed = joined.m4_metal_xrt_wall()?;
379            let current_wer = joined.fleurs_en_wer()?;
380
381            let mut ranked: Vec<(&CatalogueEntry, f64, f64)> = Vec::new();
382            for entry in entries {
383                if entry.variant == joined.variant {
384                    continue;
385                }
386                if !entry.languages.iter().any(|language| language == "en") {
387                    continue;
388                }
389                let (Some(speed), Some(wer)) = (entry.m4_metal_xrt_wall(), entry.fleurs_en_wer())
390                else {
391                    continue;
392                };
393                ranked.push((entry, speed, wer));
394            }
395            ranked.sort_by(|(_, a_speed, a_wer), (_, b_speed, b_wer)| {
396                b_speed.total_cmp(a_speed).then(a_wer.total_cmp(b_wer))
397            });
398
399            let mut recommended = false;
400            let candidates: Vec<CandidateRow> = ranked
401                .into_iter()
402                .map(|(entry, speed, wer)| {
403                    let beats = speed > current_speed && wer < current_wer;
404                    // The first beater in the ranked order is the fastest
405                    // one, which is the pick.
406                    let verdict = if beats && !recommended {
407                        recommended = true;
408                        UpdateVerdict::Recommended
409                    } else if beats {
410                        UpdateVerdict::BeatsBoth
411                    } else if speed <= current_speed {
412                        UpdateVerdict::Slower
413                    } else {
414                        UpdateVerdict::Blunter
415                    };
416                    CandidateRow { row: row_for(entry), verdict }
417                })
418                .collect();
419
420            Some(ModelUpdate {
421                role: *role,
422                file: spec.file.clone(),
423                current: CurrentFacts { speed_x: current_speed, fleurs_en_wer: current_wer },
424                candidates,
425            })
426        })
427        .collect()
428}
429
430/// Whether a feed is old enough to fetch again: no feed at all, one
431/// stamped older than [`REFRESH_AFTER`], or one whose stamp this build
432/// cannot read - acting on an unreadable stamp beats trusting it.
433pub(crate) fn refresh_is_due(catalogue: Option<&Catalogue>, now: SystemTime) -> bool {
434    let Some(catalogue) = catalogue else {
435        return true;
436    };
437    let Ok(at) = time::OffsetDateTime::parse(
438        &catalogue.fetched_at,
439        &time::format_description::well_known::Rfc3339,
440    ) else {
441        return true;
442    };
443    now.duration_since(SystemTime::from(at)).map_or(true, |age| age >= REFRESH_AFTER)
444}
445
446/// The live catalogue state: the last fetched feed and what the last
447/// check did.
448pub(crate) struct CatalogueState {
449    pub(crate) check: CatalogueCheck,
450    /// The last-known feed: from the cache at boot, replaced by each
451    /// successful fetch, and left standing by a failed one.
452    pub(crate) catalogue: Option<Catalogue>,
453}
454
455impl Default for CatalogueState {
456    fn default() -> Self {
457        Self { check: CatalogueCheck::Never, catalogue: None }
458    }
459}
460
461impl crate::Workspace {
462    /// Everything the models page draws.
463    pub fn dictate_models(&self) -> DictateModelsSnapshot {
464        let config = self.config_arc();
465        let settings = &config.dictate;
466        let active = self.active_models();
467        let specs: Vec<(DictateRole, ModelSpec)> =
468            active.iter().map(|(role, model)| (*role, model.spec.clone())).collect();
469        let preflight = self.dictate.snapshot.lock().clone();
470        let state = self.dictate_catalogue.lock();
471        let entries: &[CatalogueEntry] =
472            state.catalogue.as_ref().map_or(&[], |catalogue| catalogue.entries.as_slice());
473        let mut in_use =
474            if settings.enabled { in_use(entries, &active, &preflight) } else { Vec::new() };
475        if self.voice_enabled() {
476            let view = self.voices();
477            let variant = crate::tts::SpeakingVariant::named(&view.active.variant)
478                .unwrap_or_else(crate::tts::SpeakingVariant::pinned);
479            let install = self.speaking_install(&view.active.variant);
480            let (size, sha256) = install.as_ref().map_or_else(
481                || (variant.size, Some(variant.sha256.to_owned())),
482                |row| (row.model_size, Some(row.model_sha256.clone())),
483            );
484            let from = match view.from {
485                crate::tts::SpeakingFrom::Config => crate::install::ActiveFrom::Config {
486                    key: "speaking_model".to_owned(),
487                    variant: variant.variant.to_owned(),
488                },
489                crate::tts::SpeakingFrom::Installed => {
490                    crate::install::ActiveFrom::Installed { variant: variant.variant.to_owned() }
491                }
492                crate::tts::SpeakingFrom::Pin => crate::install::ActiveFrom::Pin,
493            };
494            for row in &view.snapshot.models {
495                in_use.push(InUseModel {
496                    role: row.role,
497                    file: row.file.clone(),
498                    size,
499                    sha256: sha256.clone(),
500                    state: row.state.clone(),
501                    facts: variant.spec().facts,
502                    catalogue: None,
503                    from: from.clone(),
504                    at: None,
505                });
506            }
507        }
508
509        DictateModelsSnapshot {
510            enabled: settings.enabled,
511            models_dir: settings.models_dir(),
512            speaking: self.voices(),
513            in_use,
514            check: state.check.clone(),
515            updates: if settings.enabled { updates_for(entries, &specs) } else { Vec::new() },
516            rows: entries.iter().map(row_for).collect(),
517            install: self.dictate_install(),
518            activate: self.dictate_activate(),
519            installed: self.installed_models(),
520            bench: self.dictate_bench(),
521            results: self.bench_results(),
522            speaking_bench: self.speaking_bench(),
523            speaking_results: self.speaking_bench_results(),
524            read_aloud: self.read_aloud_state(),
525        }
526    }
527
528    /// Load the cached feed, then fetch a fresh one when it is due. One
529    /// task per forge run, started beside the dictation preflight; a
530    /// no-op unless `[dictate] enabled` is set.
531    ///
532    /// The cache read is synchronous - one small file on the boot path -
533    /// and the fetch never is: it is what the state's [`CatalogueCheck`]
534    /// reports on.
535    pub fn start_dictate_catalogue(self: &std::sync::Arc<Self>) {
536        if !self.config_arc().dictate.enabled {
537            return;
538        }
539        match self.catalogue_dir() {
540            None => {
541                self.spawn_catalogue_refresh();
542            }
543            Some(dir) => match forge_dictate::catalogue::read_catalogue_cache(&dir) {
544                Ok(Some(catalogue)) => {
545                    let due = refresh_is_due(Some(&catalogue), SystemTime::now());
546                    // The check state comes off the cache too: the last
547                    // check a page can read is the one that fetched it,
548                    // even across a restart that has not fetched yet.
549                    {
550                        let mut state = self.dictate_catalogue.lock();
551                        state.check = CatalogueCheck::Fresh {
552                            at: catalogue.fetched_at.clone(),
553                            release: catalogue.release.clone(),
554                            skipped: catalogue.skipped,
555                        };
556                        state.catalogue = Some(catalogue);
557                    }
558                    if due {
559                        self.spawn_catalogue_refresh();
560                    }
561                }
562                Ok(None) => {
563                    self.spawn_catalogue_refresh();
564                }
565                // A cache this build cannot read is a cache it does not
566                // have; the record says why, so a corrupt file is not
567                // indistinguishable from none and does not repeat
568                // silently every boot.
569                Err(error) => {
570                    tracing::warn!(
571                        event_name = "dictate_catalogue_cache_unreadable",
572                        %error,
573                        "the cached catalogue could not be read; fetching a fresh one"
574                    );
575                    self.spawn_catalogue_refresh();
576                }
577            },
578        }
579    }
580
581    /// The models page's Check now: a fresh fetch, refused when there is
582    /// nothing to check against or one is already running.
583    pub(crate) fn check_dictate_catalogue(
584        self: &std::sync::Arc<Self>,
585    ) -> Result<(), crate::DispatchError> {
586        if !self.config_arc().dictate.enabled {
587            return Err(crate::DispatchError::DictateOff);
588        }
589        if !self.spawn_catalogue_refresh() {
590            return Err(crate::DispatchError::CatalogueChecking);
591        }
592        Ok(())
593    }
594
595    /// Fetch the feed off the runtime, land it, and push the re-read
596    /// view. Answers `false` when a check is already in flight, deciding
597    /// that under the same lock take that moves the state to
598    /// [`CatalogueCheck::Checking`]: two near-simultaneous dispatches
599    /// cannot both fetch.
600    fn spawn_catalogue_refresh(self: &std::sync::Arc<Self>) -> bool {
601        {
602            let mut state = self.dictate_catalogue.lock();
603            if matches!(state.check, CatalogueCheck::Checking) {
604                return false;
605            }
606            state.check = CatalogueCheck::Checking;
607        }
608        let this = std::sync::Arc::clone(self);
609        tokio::spawn(async move {
610            let _ = this.fetch_catalogue_once().await;
611            let models = this.dictate_models();
612            let _ =
613                this.update_sender().send(crate::SessionUpdate::DictateModelsChanged { models });
614        });
615        true
616    }
617
618    /// Fetch the feed once and land it: the freshness line, the rows and
619    /// the cache. Answers the catalogue, or the error the fetch failed
620    /// with; the last-known feed stands either way.
621    ///
622    /// Shared by the background check and the active-model resolution,
623    /// which needs the feed's entry for a config key's variant.
624    pub(crate) async fn fetch_catalogue_once(
625        &self,
626    ) -> Result<forge_dictate::catalogue::Catalogue, forge_dictate::Error> {
627        let source = self.catalogue_source();
628        let cleanup = self.cleanup_source();
629        let fetched = tokio::task::spawn_blocking(move || {
630            let mut catalogue = forge_dictate::catalogue::fetch_catalogue(&source)?;
631            // **The cleanup feed is a second source, and its failure is its
632            // own.** The speech feed landing is the check; a cleanup fetch
633            // that could not be read costs its rows, not the check, and the
634            // candidates the last fetch left stand.
635            match forge_dictate::cleanup::fetch_cleanup(&cleanup) {
636                Ok(mut entries) => {
637                    catalogue.entries.append(&mut entries);
638                    Ok::<_, forge_dictate::Error>((catalogue, true))
639                }
640                Err(error) => {
641                    tracing::warn!(
642                        event_name = "dictate_cleanup_feed_failed",
643                        %error,
644                        "the cleanup feed could not be read; the candidates the last fetch left stand"
645                    );
646                    Ok((catalogue, false))
647                }
648            }
649        })
650        .await;
651        let outcome = fetched
652            .unwrap_or_else(|join| {
653                Err(forge_dictate::Error::Catalogue { message: join.to_string() })
654            })
655            .map(|(mut catalogue, cleanup_ok)| {
656                if !cleanup_ok {
657                    let previous: Vec<forge_dictate::catalogue::CatalogueEntry> = self
658                        .dictate_catalogue
659                        .lock()
660                        .catalogue
661                        .as_ref()
662                        .map(|held| {
663                            held.entries
664                                .iter()
665                                .filter(|entry| {
666                                    entry.kind == forge_dictate::catalogue::EntryKind::Normalizer
667                                })
668                                .cloned()
669                                .collect()
670                        })
671                        .unwrap_or_default();
672                    catalogue.entries.splice(0..0, previous);
673                }
674                catalogue
675            });
676
677        match outcome {
678            Ok(catalogue) => {
679                if let Some(dir) = self.catalogue_dir()
680                    && let Err(error) =
681                        forge_dictate::catalogue::write_catalogue_cache(&dir, &catalogue)
682                {
683                    tracing::warn!(
684                        event_name = "dictate_catalogue_cache_write_failed",
685                        %error,
686                        "the fetched catalogue was not cached; the next boot fetches it again"
687                    );
688                }
689                let mut state = self.dictate_catalogue.lock();
690                state.check = CatalogueCheck::Fresh {
691                    at: catalogue.fetched_at.clone(),
692                    release: catalogue.release.clone(),
693                    skipped: catalogue.skipped,
694                };
695                state.catalogue = Some(catalogue.clone());
696                Ok(catalogue)
697            }
698            // The rows the last fetch left stand, and nothing in use
699            // changed: a failed check costs the freshness line, not
700            // the feed.
701            Err(error) => {
702                tracing::warn!(
703                    event_name = "dictate_catalogue_check_failed",
704                    %error,
705                    "the catalogue check failed; the last-known feed stands"
706                );
707                self.dictate_catalogue.lock().check =
708                    CatalogueCheck::Unreachable { error: error.to_string() };
709                Err(error)
710            }
711        }
712    }
713
714    /// Where the feed is fetched from. Test builds can point this at a
715    /// loopback server; production always takes the real one.
716    pub(crate) fn catalogue_source(&self) -> forge_dictate::catalogue::CatalogueSource {
717        #[cfg(any(test, feature = "testing"))]
718        if let Some(source) = self.test_catalogue_source.lock().clone() {
719            return source;
720        }
721        forge_dictate::catalogue::CatalogueSource::default()
722    }
723
724    /// Where the cleanup feed is fetched from, on the same terms - and a test
725    /// serving the speech feed serves this one from the same loopback, so a
726    /// check in a test reads one server rather than reaching the Hub.
727    pub(crate) fn cleanup_source(&self) -> forge_dictate::cleanup::CleanupSource {
728        #[cfg(any(test, feature = "testing"))]
729        if let Some(source) = self.test_cleanup_source.lock().clone() {
730            return source;
731        }
732        #[cfg(any(test, feature = "testing"))]
733        if let Some(base) = self.test_catalogue_source.lock().clone() {
734            return forge_dictate::cleanup::CleanupSource {
735                listing: format!("{}cleanup?filter=", base.entry_base),
736                listing_tail: "&filter=gguf".to_owned(),
737                tags: vec!["text-normalization".to_owned()],
738                blobs_base: format!("{}blobs/", base.entry_base),
739                files_base: format!("{}files/", base.entry_base),
740            };
741        }
742        forge_dictate::cleanup::CleanupSource::default()
743    }
744
745    /// Where the fetched feed is cached: the workspace's own app-support
746    /// base (re-resolving the machine dir here would let a test's fetch
747    /// write into the real tree), or a test's own override.
748    fn catalogue_dir(&self) -> Option<PathBuf> {
749        #[cfg(any(test, feature = "testing"))]
750        if let Some(dir) = self.test_catalogue_dir.lock().clone() {
751            return Some(dir);
752        }
753        if let Some(base) = &self.app_support {
754            Some(base.join("dictate-catalogue"))
755        } else {
756            tracing::warn!(
757                event_name = "dictate_catalogue_dir_unresolved",
758                "no app-support base was resolved at boot: the fetched catalogue is not cached"
759            );
760            None
761        }
762    }
763}
764
765#[cfg(test)]
766pub(crate) mod tests_catalogue_view {
767    use super::*;
768    use crate::dictate::{DictateModel, DictateModelState, DictateRole, DictateSnapshot};
769    use forge_dictate::catalogue::parse_entry;
770
771    /// A synthetic feed entry carrying every row the view reads. The
772    /// real entries' values are pinned in `forge-dictate`'s own tests;
773    /// these are the shapes this module's rules are about.
774    fn entry(
775        variant: &str,
776        languages: &str,
777        speed: f64,
778        wer: f64,
779    ) -> forge_dictate::catalogue::CatalogueEntry {
780        entry_under(variant, languages, speed, wer, 100, "mit", "MIT")
781    }
782
783    /// [`entry`] with the download length and licence a test needs.
784    ///
785    /// `pub(crate)` so the install tests build their entries with the same
786    /// spelling rather than a second one.
787    pub(crate) fn entry_under(
788        variant: &str,
789        languages: &str,
790        speed: f64,
791        wer: f64,
792        size_bytes: u64,
793        spdx: &str,
794        display: &str,
795    ) -> forge_dictate::catalogue::CatalogueEntry {
796        parse_entry(&format!(
797            r#"{{
798              "schema": "transcribe-catalog-v1",
799              "variant": "{variant}",
800              "display_name": "{variant}",
801              "params": 1000000,
802              "license": {{"spdx": "{spdx}", "display": "{display}"}},
803              "languages": {languages},
804              "downloads": [
805                {{"quant": "F16", "filename": "{variant}-F16.gguf", "size_bytes": 200}},
806                {{"quant": "Q4_K_M", "filename": "{variant}-Q4_K_M.gguf", "size_bytes": {size_bytes}}}
807              ],
808              "speed_benchmarks": [
809                {{"machine": "m4-max", "backend": "metal", "quant": "Q8_0", "xrt_wall": {speed}}}
810              ],
811              "accuracy_benchmarks": [
812                {{"dataset": "fleurs", "split": "test", "language": "en", "quant": "Q8_0", "err_pct": {wer}}}
813              ]
814            }}"#
815        ))
816        .expect("the synthetic entry parses")
817    }
818
819    /// The spec that joins `entry("in-use", ..)`: the file name is the
820    /// join and the length is its witness, so both are the synthetic
821    /// entry's own.
822    fn in_use_spec() -> forge_dictate::ModelSpec {
823        let mut spec = forge_dictate::ModelSpec::cohere_transcribe_q4_k_m();
824        spec.file = "in-use-Q4_K_M.gguf".to_owned();
825        spec.size = 100;
826        spec
827    }
828
829    /// One role's compiled pin, as the active list spells it: what a role
830    /// with no config key and no runtime pick runs.
831    fn active_pin(
832        role: DictateRole,
833        spec: forge_dictate::ModelSpec,
834    ) -> (DictateRole, crate::install::ActiveModel) {
835        (
836            role,
837            crate::install::ActiveModel {
838                role,
839                spec,
840                from: crate::install::ActiveFrom::Pin,
841                at: None,
842            },
843        )
844    }
845
846    /// Every fact the candidate list draws comes off the row itself.
847    #[test]
848    fn a_row_carries_the_facts_the_candidate_list_draws() {
849        let row = row_for(&entry("candidate", r#"["en", "fr"]"#, 388.8, 4.61));
850
851        assert_eq!(row.variant, "candidate");
852        assert_eq!(row.params, Some(1_000_000));
853        assert_eq!(row.license.as_deref(), Some("MIT"));
854        assert_eq!(row.languages, ["en", "fr"]);
855        assert_eq!(
856            row.download.as_ref().map(|download| (download.quant.as_str(), download.size_bytes)),
857            Some(("Q4_K_M", 100)),
858            "the candidate line's size is the Q4 build"
859        );
860        assert_eq!(row.speed.as_ref().map(|speed| speed.xrt_wall), Some(388.8));
861        assert_eq!(row.speed.as_ref().map(|speed| speed.backend.as_str()), Some("metal"));
862        assert_eq!(row.wer.as_ref().map(|wer| wer.err_pct), Some(4.61));
863        assert_eq!(row.wer.as_ref().map(|wer| wer.language.as_str()), Some("en"));
864    }
865
866    /// A build with no Q4 falls back along the reference quants, and one
867    /// with none of them carries no size rather than a wrong one.
868    #[test]
869    fn the_rows_download_falls_back_when_no_q4_build_exists() {
870        let q8_only = parse_entry(
871            r#"{"schema":"transcribe-catalog-v1","variant":"q8",
872                "downloads":[{"quant":"F32","size_bytes":9},{"quant":"Q8_0","size_bytes":5}]}"#,
873        )
874        .expect("parse");
875        assert_eq!(
876            row_for(&q8_only).download.as_ref().map(|download| download.quant.as_str()),
877            Some("Q8_0"),
878            "without a Q4 build the reference quant chain is next"
879        );
880
881        let f32_only = parse_entry(
882            r#"{"schema":"transcribe-catalog-v1","variant":"f32",
883                "downloads":[{"quant":"F32","size_bytes":9}]}"#,
884        )
885        .expect("parse");
886        assert!(
887            row_for(&f32_only).download.is_none(),
888            "no reference quant means no size, not the F32 one"
889        );
890    }
891
892    /// The update is the candidate the page's line shows: the fastest
893    /// entry that beats the model in use on both compared axes.
894    #[test]
895    fn the_update_is_the_fastest_candidate_that_beats_the_model_in_use() {
896        // The winner is deliberately not the first beater in the list, so
897        // a walk that keeps the first candidate it meets fails here; and
898        // the list carries one entry per way to be admitted wrongly -
899        // faster but blunter, and faster with an equal error rate.
900        let entries = vec![
901            entry("in-use", r#"["en"]"#, 72.9, 5.08),
902            entry("parakeet-like", r#"["en"]"#, 218.8, 3.99),
903            entry("granite-like", r#"["en"]"#, 388.8, 4.61),
904            entry("fast-but-blunt", r#"["en"]"#, 900.0, 5.50),
905            entry("faster-but-equal-wer", r#"["en"]"#, 500.0, 5.08),
906            entry("slower-but-sharper", r#"["en"]"#, 50.0, 1.20),
907            entry("russian", r#"["ru"]"#, 900.0, 0.90),
908        ];
909
910        let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
911
912        assert_eq!(updates.len(), 1, "one comparison per model in use");
913        let update = &updates[0];
914        assert_eq!(update.file, "in-use-Q4_K_M.gguf");
915        assert_eq!(update.current.speed_x, 72.9, "the in-use side of the comparison");
916        assert_eq!(update.current.fleurs_en_wer, 5.08);
917
918        // The table: fastest first, the pick at its head, and every row
919        // carrying the axis it lost or won on.
920        let table: Vec<(&str, UpdateVerdict)> = update
921            .candidates
922            .iter()
923            .map(|candidate| (candidate.row.variant.as_str(), candidate.verdict))
924            .collect();
925        assert_eq!(
926            table,
927            vec![
928                ("fast-but-blunt", UpdateVerdict::Blunter),
929                ("faster-but-equal-wer", UpdateVerdict::Blunter),
930                ("granite-like", UpdateVerdict::Recommended),
931                ("parakeet-like", UpdateVerdict::BeatsBoth),
932                ("slower-but-sharper", UpdateVerdict::Slower),
933            ],
934            "the ranked comparison the page tables: fastest first, the pick flagged where it \
935             lands, and every other row carrying the axis it lost on"
936        );
937    }
938
939    /// Two candidates equally fast: the sharper takes the line.
940    #[test]
941    fn of_two_equally_fast_candidates_the_sharper_wins() {
942        let entries = vec![
943            entry("in-use", r#"["en"]"#, 72.9, 5.08),
944            entry("twin-blunter", r#"["en"]"#, 300.0, 4.00),
945            entry("twin-sharper", r#"["en"]"#, 300.0, 3.50),
946        ];
947
948        let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
949
950        assert_eq!(updates.len(), 1);
951        assert_eq!(
952            updates[0].candidates[0].row.variant, "twin-sharper",
953            "equal speed is broken by the lower word error rate, not by list order"
954        );
955        assert_eq!(updates[0].candidates[0].verdict, UpdateVerdict::Recommended);
956    }
957
958    /// FLEURS English is the comparison axis: a row without it cannot be
959    /// compared, however fast it is.
960    #[test]
961    fn a_candidate_with_no_comparable_wer_is_not_a_candidate() {
962        let headline_only = parse_entry(
963            r#"{"schema":"transcribe-catalog-v1","variant":"headline-only","languages":["en"],
964                "headline_benchmark":{"dataset":"librispeech","split":"test-clean","language":"en"},
965                "downloads":[{"quant":"Q4_K_M","filename":"x.gguf","size_bytes":1}],
966                "speed_benchmarks":[{"machine":"m4-max","backend":"metal","quant":"Q8_0","xrt_wall":900.0}],
967                "accuracy_benchmarks":[{"dataset":"librispeech","split":"test-clean","language":"en","quant":"Q8_0","err_pct":1.2}]}"#,
968        )
969        .expect("parse");
970        let entries = vec![entry("in-use", r#"["en"]"#, 72.9, 5.08), headline_only];
971
972        let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
973
974        assert_eq!(updates.len(), 1, "the baseline still stands");
975        assert!(
976            updates[0].candidates.is_empty(),
977            "no common axis, no comparison - and no row for it"
978        );
979    }
980
981    /// **A non-commercial licence is a fact on the row, not a filter.**
982    /// The fastest beater wins and the row carries the licence it would
983    /// run under - the page downloads and runs it on this machine, which
984    /// is the licence's own personal use.
985    #[test]
986    fn a_non_commercial_candidate_is_recommended_with_its_licence_on_the_row() {
987        let entries = vec![
988            entry("in-use", r#"["en"]"#, 72.9, 5.08),
989            entry_under(
990                "faster-but-nc",
991                r#"["en"]"#,
992                401.64,
993                4.30,
994                100,
995                "cc-by-nc-sa-4.0",
996                "CC-BY-NC-SA-4.0",
997            ),
998            entry_under("permissive", r#"["en"]"#, 388.76, 4.61, 100, "apache-2.0", "Apache-2.0"),
999        ];
1000
1001        let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
1002
1003        assert_eq!(updates.len(), 1);
1004        assert_eq!(
1005            updates[0].candidates[0].row.variant, "faster-but-nc",
1006            "the fastest entry that beats the model in use, licence and all"
1007        );
1008        assert_eq!(updates[0].candidates[0].verdict, UpdateVerdict::Recommended);
1009        assert_eq!(
1010            updates[0].candidates[0].row.license.as_deref(),
1011            Some("CC-BY-NC-SA-4.0"),
1012            "and the row states the licence the decision is read under"
1013        );
1014        assert_eq!(updates[0].candidates[1].row.variant, "permissive");
1015        assert_eq!(updates[0].candidates[1].verdict, UpdateVerdict::BeatsBoth);
1016    }
1017
1018    /// The pin's own byte length is the witness an entry is about the
1019    /// file in use: a feed carrying the same name at another length has
1020    /// been rebuilt, and its measured rows are not this file's.
1021    #[test]
1022    fn an_update_is_not_proposed_from_a_rebuilt_file() {
1023        let entries = vec![
1024            // Our file's name at another length: upstream rebuilt the
1025            // quant, and these rows are the new build's.
1026            entry_under("in-use", r#"["en"]"#, 300.0, 4.00, 101, "mit", "MIT"),
1027            // A candidate that WOULD beat those rows - which is exactly
1028            // what must not happen, because they are not our file's.
1029            entry("candidate", r#"["en"]"#, 500.0, 2.00),
1030        ];
1031
1032        assert!(
1033            updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]).is_empty(),
1034            "a same-name different-length download is a rebuild, and no comparison is built on it"
1035        );
1036        let join = join_for(&entries, &in_use_spec())
1037            .expect("the join still crosses, so a reader can see the length moved");
1038        assert_eq!(join.size_bytes, 101, "the feed's own length, against the pin's 100");
1039    }
1040
1041    /// The model in use is not news about itself, whatever a duplicate
1042    /// entry in the feed claims.
1043    #[test]
1044    fn the_model_in_use_is_never_its_own_candidate() {
1045        let entries = vec![
1046            entry("in-use", r#"["en"]"#, 72.9, 5.08),
1047            entry("in-use", r#"["en"]"#, 900.0, 1.00),
1048        ];
1049
1050        let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
1051
1052        assert_eq!(updates.len(), 1, "the comparison still stands");
1053        assert!(
1054            updates[0].candidates.is_empty(),
1055            "and the variant in use is no row of it: {:?}",
1056            updates[0].candidates
1057        );
1058    }
1059
1060    /// The IN USE row: declared facts off the pin, the state off the
1061    /// preflight snapshot, the feed's facts off the join.
1062    #[test]
1063    fn in_use_facts_come_from_the_pin_the_state_and_the_join() {
1064        // The feed's own length differs from the pin's on purpose: which
1065        // side each number came from is the assertion.
1066        let entries = vec![entry_under("in-use", r#"["en", "fr"]"#, 72.9, 5.08, 200, "mit", "MIT")];
1067        let states = DictateSnapshot {
1068            models: vec![DictateModel {
1069                role: DictateRole::Transcribing,
1070                file: "in-use-Q4_K_M.gguf".to_owned(),
1071                state: DictateModelState::Ready,
1072            }],
1073            failure: None,
1074        };
1075
1076        let rows =
1077            in_use(&entries, &[active_pin(DictateRole::Transcribing, in_use_spec())], &states);
1078
1079        assert_eq!(rows.len(), 1);
1080        let row = &rows[0];
1081        assert_eq!(
1082            row.state,
1083            DictateModelState::Ready,
1084            "the page's loaded chip is the preflight snapshot's own state"
1085        );
1086        assert_eq!(row.facts.quant.as_deref(), Some("Q4_K_M"), "declared facts come off the pin");
1087        assert_eq!(row.size, 100, "the pin's own byte length");
1088        let join = row.catalogue.as_ref().expect("the file joins its catalogue entry");
1089        assert_eq!(join.variant, "in-use");
1090        assert_eq!(join.size_bytes, 200, "the feed's own size for the file, as a witness");
1091        assert_eq!(join.languages, ["en", "fr"]);
1092        assert_eq!(join.speed.as_ref().map(|speed| speed.xrt_wall), Some(72.9));
1093    }
1094
1095    /// A model the feed does not carry still crosses whole.
1096    #[test]
1097    fn a_model_in_no_catalogue_entry_still_crosses_whole() {
1098        let rows = in_use(
1099            &[],
1100            &[active_pin(DictateRole::Normalization, forge_dictate::ModelSpec::s1_mini_f16())],
1101            &DictateSnapshot::default(),
1102        );
1103
1104        assert_eq!(rows.len(), 1);
1105        assert!(rows[0].catalogue.is_none(), "the normalizer is in no feed; the pin carries it");
1106        assert_eq!(
1107            rows[0].state,
1108            DictateModelState::Pending,
1109            "no preflight row yet is pending, not missing"
1110        );
1111    }
1112
1113    /// A feed older than a day refreshes at boot; a stamp this build
1114    /// cannot read is due too, because acting on it beats trusting it.
1115    #[test]
1116    fn a_feed_older_than_a_day_is_due_for_a_refresh() {
1117        let now = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_791_244_800);
1118        let stamp = |age: std::time::Duration| {
1119            let at = now - age;
1120            time::OffsetDateTime::from(at)
1121                .format(&time::format_description::well_known::Rfc3339)
1122                .expect("a stamp formats")
1123        };
1124        let feed = |fetched_at: String| forge_dictate::catalogue::Catalogue {
1125            fetched_at,
1126            release: None,
1127            entries: Vec::new(),
1128            skipped: 0,
1129        };
1130
1131        assert!(refresh_is_due(None, now), "a machine that never fetched is due");
1132        assert!(
1133            !refresh_is_due(Some(&feed(stamp(std::time::Duration::from_secs(3600)))), now),
1134            "an hour old is not due"
1135        );
1136        assert!(
1137            refresh_is_due(Some(&feed(stamp(std::time::Duration::from_secs(25 * 3600)))), now),
1138            "past a day is due"
1139        );
1140        assert!(
1141            refresh_is_due(Some(&feed("whenever".to_owned())), now),
1142            "a stamp this build cannot read is due, not trusted"
1143        );
1144    }
1145
1146    /// The check crosses under the names the client narrows on.
1147    #[test]
1148    fn the_check_crosses_under_the_names_the_client_narrows() {
1149        for (check, name) in [
1150            (CatalogueCheck::Never, "never"),
1151            (CatalogueCheck::Checking, "checking"),
1152            (CatalogueCheck::Unreachable { error: "502".to_owned() }, "unreachable"),
1153        ] {
1154            let value = serde_json::to_value(&check).expect("serialise");
1155            assert_eq!(value["state"], name, "the client narrows on this tag: {value}");
1156        }
1157
1158        let fresh = CatalogueCheck::Fresh {
1159            at: "2026-10-06T06:12:00Z".to_owned(),
1160            release: Some("v0.3.1".to_owned()),
1161            skipped: 2,
1162        };
1163        let value = serde_json::to_value(&fresh).expect("serialise");
1164        assert_eq!(value["state"], "fresh");
1165        assert_eq!(value["release"], "v0.3.1");
1166        assert_eq!(value["skipped"], 2);
1167    }
1168
1169    // ---- the workspace's own check path ----
1170
1171    use crate::{Command, DispatchError, SessionUpdate, Workspace};
1172    use forge_dictate::catalogue::{
1173        Catalogue, CatalogueSource, read_catalogue_cache, write_catalogue_cache,
1174    };
1175    use std::sync::Arc;
1176
1177    /// A stub whose `[dictate]` section is on, with the models directory
1178    /// a tempdir so nothing touches the real cache.
1179    ///
1180    /// `pub(crate)` for the install tests, which drive the same stub and
1181    /// the same loopback server rather than carrying a second copy.
1182    pub(crate) fn enabled_stub()
1183    -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>, tempfile::TempDir)
1184    {
1185        let config_dir = tempfile::tempdir().expect("a config dir");
1186        let models = tempfile::tempdir().expect("a models dir");
1187        let mut config = crate::config::LoadedConfig::empty_for_test();
1188        config.dictate.enabled = true;
1189        config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1190        let (ws, updates) =
1191            Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1192                .expect("the stub builds");
1193        (ws, updates, models)
1194    }
1195
1196    /// Loopback HTTP/1.1 server answering fixed paths, one request per
1197    /// connection. Anything unrouted answers 404.
1198    pub(crate) fn serve(routes: Vec<(&'static str, u16, Vec<u8>)>) -> String {
1199        serve_with(|_| {
1200            routes
1201                .into_iter()
1202                .map(|(route, status, body)| (route.to_owned(), status, body))
1203                .collect()
1204        })
1205    }
1206
1207    /// [`serve`] with the routes built from the bound address, for a body
1208    /// that must carry the URL it is served from: an install's doc table
1209    /// points its download links back at this same loopback.
1210    pub(crate) fn serve_with(
1211        routes_for: impl FnOnce(&str) -> Vec<(String, u16, Vec<u8>)>,
1212    ) -> String {
1213        use std::io::{BufRead, BufReader, Write};
1214        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port");
1215        let base = format!("http://{}", listener.local_addr().expect("the bound address"));
1216        let routes = routes_for(&base);
1217        std::thread::spawn(move || {
1218            for stream in listener.incoming() {
1219                let Ok(mut stream) = stream else { break };
1220                let mut reader = BufReader::new(stream.try_clone().expect("a clone"));
1221                let mut request = String::new();
1222                if reader.read_line(&mut request).is_err() {
1223                    continue;
1224                }
1225                let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
1226                loop {
1227                    let mut line = String::new();
1228                    if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
1229                        break;
1230                    }
1231                }
1232                let (status, body) = match routes.iter().find(|(route, _, _)| route == &path) {
1233                    Some((_, status, body)) => (*status, body.clone()),
1234                    None => (404, Vec::new()),
1235                };
1236                let head = format!(
1237                    "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1238                    body.len()
1239                );
1240                let _ = stream.write_all(head.as_bytes());
1241                let _ = stream.write_all(&body);
1242                let _ = stream.flush();
1243            }
1244        });
1245        base
1246    }
1247
1248    pub(crate) fn source(base: &str) -> CatalogueSource {
1249        CatalogueSource {
1250            listing: format!("{base}/catalog"),
1251            entry_base: format!("{base}/catalog/"),
1252            release: format!("{base}/release"),
1253            doc_base: format!("{base}/docs/"),
1254            repo_base: format!("{base}/repos/"),
1255        }
1256    }
1257
1258    /// One parseable entry, shaped like the feed's own documents.
1259    pub(crate) fn feed_entry() -> Vec<u8> {
1260        br#"{"schema": "transcribe-catalog-v1", "variant": "one", "languages": ["en"],
1261             "downloads": [{"quant": "Q4_K_M", "filename": "one-Q4_K_M.gguf", "size_bytes": 100}],
1262             "speed_benchmarks": [{"machine": "m4-max", "backend": "metal", "quant": "Q8_0", "xrt_wall": 100.0}],
1263             "accuracy_benchmarks": [{"dataset": "fleurs", "split": "test", "language": "en", "quant": "Q8_0", "err_pct": 4.0}]}"#
1264            .to_vec()
1265    }
1266
1267    pub(crate) fn listing() -> Vec<u8> {
1268        br#"[{"name": "one.json", "type": "file"}]"#.to_vec()
1269    }
1270
1271    /// Before any feed, the page still reads the pins and the preflight
1272    /// state - and nothing invented for the parts a feed would answer.
1273    #[test]
1274    fn the_read_answers_the_pins_before_any_catalogue() {
1275        let (ws, _updates, _models) = enabled_stub();
1276
1277        let view = ws.dictate_models();
1278
1279        assert!(view.enabled);
1280        assert_eq!(view.in_use.len(), 2, "both pinned models cross");
1281        assert_eq!(
1282            view.in_use[0].state,
1283            crate::dictate::DictateModelState::Pending,
1284            "preflight has not got there yet"
1285        );
1286        assert!(view.in_use[0].catalogue.is_none(), "no feed, no join");
1287        assert!(matches!(view.check, CatalogueCheck::Never));
1288        assert!(view.rows.is_empty(), "no fetched feed is no rows");
1289        assert!(view.updates.is_empty(), "and no update to propose");
1290        assert_eq!(
1291            view.bench,
1292            crate::bench::BenchState::Idle,
1293            "no bench is running on a fresh read"
1294        );
1295        assert!(view.results.is_empty(), "and nothing has been measured yet");
1296    }
1297
1298    /// With `[dictate]` off the page reads no models in use and no
1299    /// proposals, while the feed's rows still cross: the catalogue is
1300    /// not per-configuration, and inventing a configuration from it
1301    /// would draw a section nobody switched on.
1302    #[test]
1303    fn a_disabled_dictation_reads_no_models_and_no_proposals() {
1304        let (ws, _updates) = Workspace::testing_stub();
1305        // The in-use entry IS the production pin - same variant name,
1306        // same quant file, same length - so without the `enabled` gate
1307        // the candidate below would be proposed and this test would see
1308        // it.
1309        ws.dictate_catalogue.lock().catalogue = Some(Catalogue {
1310            fetched_at: "2026-10-06T00:00:00Z".to_owned(),
1311            release: None,
1312            entries: vec![
1313                entry_under(
1314                    "cohere-transcribe-03-2026",
1315                    r#"["en"]"#,
1316                    72.9,
1317                    5.08,
1318                    1_558_162_944,
1319                    "apache-2.0",
1320                    "Apache-2.0",
1321                ),
1322                entry("better", r#"["en"]"#, 500.0, 4.00),
1323            ],
1324            skipped: 0,
1325        });
1326
1327        let view = ws.dictate_models();
1328
1329        assert!(!view.enabled);
1330        assert!(view.in_use.is_empty(), "no pins are in use when dictation is off");
1331        assert!(view.updates.is_empty(), "and nothing is proposed against them");
1332        assert_eq!(view.rows.len(), 2, "the feed's rows still cross");
1333    }
1334
1335    /// **The cache dir threads the workspace's own app-support base, never
1336    /// the machine's**: a test's fetch once cached into the real
1337    /// app-support tree.
1338    #[test]
1339    fn the_cache_dir_threads_the_workspaces_app_support_base() {
1340        let config_dir = tempfile::tempdir().expect("a config dir");
1341        let models = tempfile::tempdir().expect("a models dir");
1342        let mut config = crate::config::LoadedConfig::empty_for_test();
1343        config.dictate.enabled = true;
1344        config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1345        let (ws, _updates) =
1346            Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1347                .expect("the stub builds");
1348
1349        assert_eq!(
1350            ws.catalogue_dir(),
1351            Some(config_dir.path().join("app-support").join("dictate-catalogue")),
1352            "the cache dir is derived from the workspace's injected app-support base"
1353        );
1354        assert_ne!(
1355            ws.catalogue_dir(),
1356            forge_sdk::app_support_dir().ok().map(|dir| dir.join("dictate-catalogue")),
1357            "and never from the machine's real app-support dir"
1358        );
1359    }
1360
1361    /// A boot with a fresh cache reads the feed and does not fetch; a
1362    /// stale one fetches in the background.
1363    #[test]
1364    fn a_boot_reads_a_fresh_cache_and_does_not_fetch() {
1365        let (ws, _updates, _models) = enabled_stub();
1366        let dir = tempfile::tempdir().expect("a cache dir");
1367        *ws.test_catalogue_dir.lock() = Some(dir.path().to_path_buf());
1368        let now = std::time::SystemTime::now();
1369        let cached = Catalogue {
1370            fetched_at: time::OffsetDateTime::from(now)
1371                .format(&time::format_description::well_known::Rfc3339)
1372                .expect("a stamp"),
1373            release: Some("v0.3.1".to_owned()),
1374            entries: vec![
1375                parse_entry(&String::from_utf8(feed_entry()).expect("utf-8")).expect("parse"),
1376            ],
1377            skipped: 0,
1378        };
1379        write_catalogue_cache(dir.path(), &cached).expect("the cache writes");
1380
1381        ws.start_dictate_catalogue();
1382
1383        let view = ws.dictate_models();
1384        assert_eq!(view.rows.len(), 1, "the cached rows read before any fetch lands");
1385        assert!(
1386            matches!(&view.check, CatalogueCheck::Fresh { release, .. } if release.as_deref() == Some("v0.3.1")),
1387            "the freshness line is the last check that fetched this cache, got {:?}",
1388            view.check
1389        );
1390    }
1391
1392    /// The Check now path: fetch, land the feed in the state, cache it,
1393    /// and push the re-read view to a subscriber.
1394    #[tokio::test]
1395    async fn a_check_lands_the_feed_and_pushes_the_view() {
1396        let (ws, mut updates, _models) = enabled_stub();
1397        let dir = tempfile::tempdir().expect("a cache dir");
1398        *ws.test_catalogue_dir.lock() = Some(dir.path().to_path_buf());
1399        let base = serve(vec![
1400            ("/catalog", 200, listing()),
1401            ("/catalog/one.json", 200, feed_entry()),
1402            ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1403        ]);
1404        *ws.test_catalogue_source.lock() = Some(source(&base));
1405
1406        ws.dispatch(Command::DictateCatalogueCheck).expect("the check dispatches");
1407
1408        let landed = tokio::time::timeout(std::time::Duration::from_secs(15), async {
1409            loop {
1410                match updates
1411                    .recv()
1412                    .await
1413                    .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
1414                {
1415                    Some(SessionUpdate::DictateModelsChanged { models }) => break models,
1416                    Some(_) => {}
1417                    None => panic!("the subscription must stay attached"),
1418                }
1419            }
1420        })
1421        .await
1422        .expect("the check must land inside fifteen seconds");
1423
1424        let CatalogueCheck::Fresh { release, skipped, .. } = &landed.check else {
1425            panic!("a landed check is fresh, got {:?}", landed.check)
1426        };
1427        assert_eq!(release.as_deref(), Some("v0.3.1"));
1428        assert_eq!(*skipped, 0);
1429        assert_eq!(landed.rows.len(), 1, "the pushed view carries the feed's rows");
1430        assert_eq!(landed.in_use.len(), 2, "and the pins beside them");
1431        assert_eq!(
1432            read_catalogue_cache(dir.path()).expect("the cache reads").map(|c| c.entries.len()),
1433            Some(1),
1434            "the fetched feed is cached for the next boot"
1435        );
1436
1437        let view = ws.dictate_models();
1438        assert!(
1439            matches!(&view.check, CatalogueCheck::Fresh { .. }),
1440            "the state a later reader gets agrees with the pushed one"
1441        );
1442    }
1443
1444    /// **The cleanup feed's own path runs in CI.** Its listing is fetched per
1445    /// tag and merged, its blobs answer each file's size and digest, and the
1446    /// rows carry both - a path until now only the ignored live reporter
1447    /// exercised, so a broken merge or a dropped digest was invisible here.
1448    #[tokio::test]
1449    async fn a_check_lands_the_cleanup_feed_too() {
1450        let (ws, _updates, _models) = enabled_stub();
1451        let cleanup = br#"[{"id": "owner/norm-a", "downloads": 5000,
1452            "pipeline_tag": "text-generation", "library_name": "gguf",
1453            "tags": ["gguf", "text-generation", "text-normalization", "en",
1454                     "base_model:owner/norm-base"]}]"#;
1455        let blobs = br#"{"siblings": [{"rfilename": "norm-a-Q4_K_M.gguf", "size": 300000000,
1456            "lfs": {"sha256": "abababab"}}],
1457            "gguf": {"architecture": "qwen2"}}"#;
1458        let base = serve(vec![
1459            ("/catalog", 200, listing()),
1460            ("/catalog/one.json", 200, feed_entry()),
1461            ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1462            ("/catalog/cleanup?filter=text-normalization&filter=gguf", 200, cleanup.to_vec()),
1463            ("/catalog/blobs/owner/norm-a?blobs=true", 200, blobs.to_vec()),
1464        ]);
1465        *ws.test_catalogue_source.lock() = Some(source(&base));
1466
1467        let landed = ws.fetch_catalogue_once().await.expect("both feeds land");
1468        let cleanup_rows: Vec<_> = landed
1469            .entries
1470            .iter()
1471            .filter(|entry| entry.kind == forge_dictate::catalogue::EntryKind::Normalizer)
1472            .collect();
1473        assert_eq!(cleanup_rows.len(), 1, "the cleanup listing lands its row");
1474        let row = cleanup_rows[0];
1475        assert_eq!(row.variant, "owner/norm-a");
1476        assert_eq!(row.downloads.len(), 1, "the blobs answer the file");
1477        assert_eq!(row.downloads[0].quant, "Q4_K_M");
1478        assert_eq!(row.downloads[0].size_bytes, 300_000_000);
1479        assert_eq!(
1480            row.downloads[0].sha256.as_deref(),
1481            Some("abababab"),
1482            "the blobs' digest rides the download, which is what an install verifies"
1483        );
1484        assert!(
1485            row.params.is_none(),
1486            "a Hub entry counts no parameters; the row must not invent one"
1487        );
1488
1489        // And the speech feed's own row stands beside it: one catalogue, two
1490        // kinds, one read.
1491        assert!(
1492            landed
1493                .entries
1494                .iter()
1495                .any(|entry| entry.kind == forge_dictate::catalogue::EntryKind::Asr),
1496            "the speech feed's rows land in the same read"
1497        );
1498    }
1499
1500    /// A second check while one is running is refused: the one in flight
1501    /// is the answer.
1502    #[test]
1503    fn a_check_while_one_is_running_is_refused() {
1504        let (ws, _updates, _models) = enabled_stub();
1505        ws.dictate_catalogue.lock().check = CatalogueCheck::Checking;
1506
1507        let err = ws
1508            .dispatch(Command::DictateCatalogueCheck)
1509            .expect_err("a second check must be refused");
1510
1511        assert!(matches!(err, DispatchError::CatalogueChecking), "got: {err:?}");
1512    }
1513
1514    /// With dictation off there is nothing to check against.
1515    #[test]
1516    fn a_check_with_dictation_off_is_refused() {
1517        let (ws, _updates) = Workspace::testing_stub();
1518
1519        let err =
1520            ws.dispatch(Command::DictateCatalogueCheck).expect_err("no pinned models, no check");
1521
1522        assert!(matches!(err, DispatchError::DictateOff), "got: {err:?}");
1523    }
1524}