Skip to main content

kernel/install/
catalog.rs

1//! A curated catalog of models worth installing, grouped by task and filtered by
2//! how well they fit the machine's memory.
3
4use std::collections::HashSet;
5
6use crate::install::provider::InstallProviderId;
7use crate::profiles::{FitAssessment, FitVerdict};
8
9/// The task a catalog entry is meant for.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum InstallCategory {
12    /// General chat / assistants.
13    Chat,
14    /// Coding help.
15    Code,
16    /// Speech (text-to-speech / transcription).
17    Voice,
18    /// Image generation.
19    Image,
20}
21
22impl InstallCategory {
23    /// The stable string form.
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            InstallCategory::Chat => "chat",
27            InstallCategory::Code => "code",
28            InstallCategory::Voice => "voice",
29            InstallCategory::Image => "image",
30        }
31    }
32}
33
34/// One recommendable model: where it comes from, how big it is, and what it's for.
35#[derive(Debug, Clone, PartialEq)]
36pub struct InstallCatalogEntry {
37    /// The provider that installs it.
38    pub provider: InstallProviderId,
39    /// The reference to install (tag or repo).
40    pub reference: String,
41    /// The name to show.
42    pub name: String,
43    /// A one-line description.
44    pub blurb: String,
45    /// The approximate download/footprint size in gigabytes.
46    pub size_gb: f64,
47    /// The task it's meant for.
48    pub category: InstallCategory,
49}
50
51impl InstallCatalogEntry {
52    fn new(
53        provider: InstallProviderId,
54        reference: &str,
55        name: &str,
56        blurb: &str,
57        size_gb: f64,
58        category: InstallCategory,
59    ) -> Self {
60        Self {
61            provider,
62            reference: reference.to_owned(),
63            name: name.to_owned(),
64            blurb: blurb.to_owned(),
65            size_gb,
66            category,
67        }
68    }
69
70    /// A stable id: `provider|reference`.
71    pub fn id(&self) -> String {
72        format!("{}|{}", self.provider.as_str(), self.reference)
73    }
74
75    /// How well this model fits in `total_memory_bytes`, its footprint being
76    /// `size_gb` decimal gigabytes as the hubs state them, or `None` if the
77    /// size is unusable.
78    pub fn fit(&self, total_memory_bytes: u64) -> Option<FitAssessment> {
79        FitVerdict::assess(Some((self.size_gb * 1e9) as i64), total_memory_bytes)
80    }
81}
82
83/// The full curated catalog.
84pub fn entries() -> Vec<InstallCatalogEntry> {
85    let ollama = InstallProviderId::ollama();
86    let hf = InstallProviderId::huggingface();
87    vec![
88        InstallCatalogEntry::new(
89            ollama.clone(),
90            "gemma3:1b",
91            "gemma3:1b",
92            "Tiny and instant. Always fits.",
93            0.8,
94            InstallCategory::Chat,
95        ),
96        InstallCatalogEntry::new(
97            ollama.clone(),
98            "llama3.2:3b",
99            "llama3.2:3b",
100            "Fast general chat on modest memory.",
101            2.0,
102            InstallCategory::Chat,
103        ),
104        InstallCatalogEntry::new(
105            ollama.clone(),
106            "gemma3:4b",
107            "gemma3:4b",
108            "Fast everyday chat. Runs comfortably on any Mac.",
109            3.3,
110            InstallCategory::Chat,
111        ),
112        InstallCatalogEntry::new(
113            ollama.clone(),
114            "gemma3:12b",
115            "gemma3:12b",
116            "Stronger reasoning, still nimble.",
117            8.1,
118            InstallCategory::Chat,
119        ),
120        InstallCatalogEntry::new(
121            ollama.clone(),
122            "gemma3:27b",
123            "gemma3:27b",
124            "Flagship reasoning with room to spare on a big Mac.",
125            17.0,
126            InstallCategory::Chat,
127        ),
128        InstallCatalogEntry::new(
129            ollama.clone(),
130            "llama3.3:70b",
131            "llama3.3:70b",
132            "The big one, at Q4. Leaves a little headroom, not much.",
133            40.0,
134            InstallCategory::Chat,
135        ),
136        InstallCatalogEntry::new(
137            ollama.clone(),
138            "qwen2.5-coder:7b",
139            "qwen2.5-coder:7b",
140            "Everyday coding help that fits most Macs.",
141            4.7,
142            InstallCategory::Code,
143        ),
144        InstallCatalogEntry::new(
145            ollama.clone(),
146            "qwen2.5-coder:14b",
147            "qwen2.5-coder:14b",
148            "Strong local coding model with a large context.",
149            9.0,
150            InstallCategory::Code,
151        ),
152        InstallCatalogEntry::new(
153            ollama,
154            "deepseek-coder-v2:16b",
155            "deepseek-coder-v2:16b",
156            "Sharp on repository-scale edits and refactors.",
157            9.4,
158            InstallCategory::Code,
159        ),
160        InstallCatalogEntry::new(
161            hf.clone(),
162            "hexgrad/Kokoro-82M",
163            "kokoro-82m",
164            "Tiny, warm text-to-speech. Instant on any Mac.",
165            0.3,
166            InstallCategory::Voice,
167        ),
168        InstallCatalogEntry::new(
169            hf.clone(),
170            "openai/whisper-large-v3",
171            "whisper-large-v3",
172            "Best-in-class speech-to-text for dictation.",
173            1.5,
174            InstallCategory::Voice,
175        ),
176        InstallCatalogEntry::new(
177            hf.clone(),
178            "black-forest-labs/FLUX.1-schnell",
179            "flux.1-schnell",
180            "Quick, striking image generation in a few steps.",
181            24.0,
182            InstallCategory::Image,
183        ),
184        InstallCatalogEntry::new(
185            hf,
186            "stabilityai/stable-diffusion-xl-base-1.0",
187            "sdxl",
188            "Dependable, well-supported image workhorse.",
189            7.0,
190            InstallCategory::Image,
191        ),
192    ]
193}
194
195/// Up to three recommended entries for `total_memory_bytes`, optionally scoped to
196/// a `category` and/or a set of `providers`. Entries that run well are preferred
197/// (the largest three that do); if none fit, the single smallest scoped entry is
198/// returned so there's always a suggestion.
199pub fn recommended(
200    category: Option<InstallCategory>,
201    total_memory_bytes: u64,
202    providers: Option<&HashSet<InstallProviderId>>,
203) -> Vec<InstallCatalogEntry> {
204    let scoped: Vec<InstallCatalogEntry> = entries()
205        .into_iter()
206        .filter(|entry| category.is_none_or(|category| entry.category == category))
207        .filter(|entry| providers.is_none_or(|providers| providers.contains(&entry.provider)))
208        .collect();
209
210    let mut fitting: Vec<InstallCatalogEntry> = scoped
211        .iter()
212        .filter(|entry| {
213            entry
214                .fit(total_memory_bytes)
215                .is_some_and(|assessment| assessment.verdict == FitVerdict::RunsWell)
216        })
217        .cloned()
218        .collect();
219    fitting.sort_by(by_size_then_reference);
220
221    if fitting.is_empty() {
222        return scoped
223            .into_iter()
224            .min_by(by_size_then_reference)
225            .map(|entry| vec![entry])
226            .unwrap_or_default();
227    }
228    // The largest three that still run well.
229    let start = fitting.len().saturating_sub(3);
230    fitting.split_off(start)
231}
232
233/// Recommend for a machine with `ram_gb` gigabytes (at least 1).
234pub fn recommended_for_ram(
235    category: Option<InstallCategory>,
236    ram_gb: u64,
237    providers: Option<&HashSet<InstallProviderId>>,
238) -> Vec<InstallCatalogEntry> {
239    recommended(category, ram_gb.max(1) << 30, providers)
240}
241
242fn by_size_then_reference(a: &InstallCatalogEntry, b: &InstallCatalogEntry) -> std::cmp::Ordering {
243    a.size_gb
244        .total_cmp(&b.size_gb)
245        .then_with(|| a.reference.cmp(&b.reference))
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    const GIB: u64 = 1 << 30;
253
254    #[test]
255    fn ids_are_provider_and_reference() {
256        let entry = &entries()[0];
257        assert_eq!(entry.id(), format!("ollama|{}", entry.reference));
258    }
259
260    #[test]
261    fn a_category_filter_scopes_the_recommendations() {
262        let code = recommended(Some(InstallCategory::Code), 64 * GIB, None);
263        assert!(!code.is_empty());
264        assert!(
265            code.iter()
266                .all(|entry| entry.category == InstallCategory::Code)
267        );
268    }
269
270    #[test]
271    fn a_provider_filter_scopes_the_recommendations() {
272        let only_ollama: HashSet<InstallProviderId> = [InstallProviderId::ollama()].into();
273        let hits = recommended(None, 64 * GIB, Some(&only_ollama));
274        assert!(
275            hits.iter()
276                .all(|entry| entry.provider == InstallProviderId::ollama())
277        );
278    }
279
280    #[test]
281    fn a_big_machine_gets_the_largest_three_that_run_well() {
282        let chat = recommended(Some(InstallCategory::Chat), 128 * GIB, None);
283        assert_eq!(chat.len(), 3);
284        // Sorted ascending by size, so the last is the largest.
285        assert!(chat[0].size_gb <= chat[2].size_gb);
286    }
287
288    #[test]
289    fn a_tiny_machine_still_gets_the_smallest_suggestion() {
290        // 2 GiB can't run anything well, but the smallest chat model is returned.
291        let chat = recommended(Some(InstallCategory::Chat), 2 * GIB, None);
292        assert_eq!(chat.len(), 1);
293        assert_eq!(chat[0].reference, "gemma3:1b");
294    }
295
296    #[test]
297    fn recommended_for_ram_clamps_to_at_least_one_gib() {
298        // ram 0 → clamped to 1 GiB; still returns the fallback smallest.
299        let chat = recommended_for_ram(Some(InstallCategory::Chat), 0, None);
300        assert_eq!(chat.len(), 1);
301    }
302}