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` (footprint = `size_gb`
76    /// gigabytes, passed to the assessor as MiB), or `None` if the size is unusable.
77    pub fn fit(&self, total_memory_bytes: u64) -> Option<FitAssessment> {
78        FitVerdict::assess(Some((self.size_gb * 1024.0) as i64), total_memory_bytes)
79    }
80}
81
82/// The full curated catalog.
83pub fn entries() -> Vec<InstallCatalogEntry> {
84    let ollama = InstallProviderId::ollama();
85    let hf = InstallProviderId::huggingface();
86    vec![
87        InstallCatalogEntry::new(
88            ollama.clone(),
89            "gemma3:1b",
90            "gemma3:1b",
91            "Tiny and instant. Always fits.",
92            0.8,
93            InstallCategory::Chat,
94        ),
95        InstallCatalogEntry::new(
96            ollama.clone(),
97            "llama3.2:3b",
98            "llama3.2:3b",
99            "Fast general chat on modest memory.",
100            2.0,
101            InstallCategory::Chat,
102        ),
103        InstallCatalogEntry::new(
104            ollama.clone(),
105            "gemma3:4b",
106            "gemma3:4b",
107            "Fast everyday chat. Runs comfortably on any Mac.",
108            3.3,
109            InstallCategory::Chat,
110        ),
111        InstallCatalogEntry::new(
112            ollama.clone(),
113            "gemma3:12b",
114            "gemma3:12b",
115            "Stronger reasoning, still nimble.",
116            8.1,
117            InstallCategory::Chat,
118        ),
119        InstallCatalogEntry::new(
120            ollama.clone(),
121            "gemma3:27b",
122            "gemma3:27b",
123            "Flagship reasoning with room to spare on a big Mac.",
124            17.0,
125            InstallCategory::Chat,
126        ),
127        InstallCatalogEntry::new(
128            ollama.clone(),
129            "llama3.3:70b",
130            "llama3.3:70b",
131            "The big one, at Q4. Leaves a little headroom, not much.",
132            40.0,
133            InstallCategory::Chat,
134        ),
135        InstallCatalogEntry::new(
136            ollama.clone(),
137            "qwen2.5-coder:7b",
138            "qwen2.5-coder:7b",
139            "Everyday coding help that fits most Macs.",
140            4.7,
141            InstallCategory::Code,
142        ),
143        InstallCatalogEntry::new(
144            ollama.clone(),
145            "qwen2.5-coder:14b",
146            "qwen2.5-coder:14b",
147            "Strong local coding model with a large context.",
148            9.0,
149            InstallCategory::Code,
150        ),
151        InstallCatalogEntry::new(
152            ollama,
153            "deepseek-coder-v2:16b",
154            "deepseek-coder-v2:16b",
155            "Sharp on repository-scale edits and refactors.",
156            9.4,
157            InstallCategory::Code,
158        ),
159        InstallCatalogEntry::new(
160            hf.clone(),
161            "hexgrad/Kokoro-82M",
162            "kokoro-82m",
163            "Tiny, warm text-to-speech. Instant on any Mac.",
164            0.3,
165            InstallCategory::Voice,
166        ),
167        InstallCatalogEntry::new(
168            hf.clone(),
169            "openai/whisper-large-v3",
170            "whisper-large-v3",
171            "Best-in-class speech-to-text for dictation.",
172            1.5,
173            InstallCategory::Voice,
174        ),
175        InstallCatalogEntry::new(
176            hf.clone(),
177            "black-forest-labs/FLUX.1-schnell",
178            "flux.1-schnell",
179            "Quick, striking image generation in a few steps.",
180            24.0,
181            InstallCategory::Image,
182        ),
183        InstallCatalogEntry::new(
184            hf,
185            "stabilityai/stable-diffusion-xl-base-1.0",
186            "sdxl",
187            "Dependable, well-supported image workhorse.",
188            7.0,
189            InstallCategory::Image,
190        ),
191    ]
192}
193
194/// Up to three recommended entries for `total_memory_bytes`, optionally scoped to
195/// a `category` and/or a set of `providers`. Entries that run well are preferred
196/// (the largest three that do); if none fit, the single smallest scoped entry is
197/// returned so there's always a suggestion.
198pub fn recommended(
199    category: Option<InstallCategory>,
200    total_memory_bytes: u64,
201    providers: Option<&HashSet<InstallProviderId>>,
202) -> Vec<InstallCatalogEntry> {
203    let scoped: Vec<InstallCatalogEntry> = entries()
204        .into_iter()
205        .filter(|entry| category.is_none_or(|category| entry.category == category))
206        .filter(|entry| providers.is_none_or(|providers| providers.contains(&entry.provider)))
207        .collect();
208
209    let mut fitting: Vec<InstallCatalogEntry> = scoped
210        .iter()
211        .filter(|entry| {
212            entry
213                .fit(total_memory_bytes)
214                .is_some_and(|assessment| assessment.verdict == FitVerdict::RunsWell)
215        })
216        .cloned()
217        .collect();
218    fitting.sort_by(by_size_then_reference);
219
220    if fitting.is_empty() {
221        return scoped
222            .into_iter()
223            .min_by(by_size_then_reference)
224            .map(|entry| vec![entry])
225            .unwrap_or_default();
226    }
227    // The largest three that still run well.
228    let start = fitting.len().saturating_sub(3);
229    fitting.split_off(start)
230}
231
232/// Recommend for a machine with `ram_gb` gigabytes (at least 1).
233pub fn recommended_for_ram(
234    category: Option<InstallCategory>,
235    ram_gb: u64,
236    providers: Option<&HashSet<InstallProviderId>>,
237) -> Vec<InstallCatalogEntry> {
238    recommended(category, ram_gb.max(1) << 30, providers)
239}
240
241fn by_size_then_reference(a: &InstallCatalogEntry, b: &InstallCatalogEntry) -> std::cmp::Ordering {
242    a.size_gb
243        .total_cmp(&b.size_gb)
244        .then_with(|| a.reference.cmp(&b.reference))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    const GIB: u64 = 1 << 30;
252
253    #[test]
254    fn ids_are_provider_and_reference() {
255        let entry = &entries()[0];
256        assert_eq!(entry.id(), format!("ollama|{}", entry.reference));
257    }
258
259    #[test]
260    fn a_category_filter_scopes_the_recommendations() {
261        let code = recommended(Some(InstallCategory::Code), 64 * GIB, None);
262        assert!(!code.is_empty());
263        assert!(
264            code.iter()
265                .all(|entry| entry.category == InstallCategory::Code)
266        );
267    }
268
269    #[test]
270    fn a_provider_filter_scopes_the_recommendations() {
271        let only_ollama: HashSet<InstallProviderId> = [InstallProviderId::ollama()].into();
272        let hits = recommended(None, 64 * GIB, Some(&only_ollama));
273        assert!(
274            hits.iter()
275                .all(|entry| entry.provider == InstallProviderId::ollama())
276        );
277    }
278
279    #[test]
280    fn a_big_machine_gets_the_largest_three_that_run_well() {
281        let chat = recommended(Some(InstallCategory::Chat), 128 * GIB, None);
282        assert_eq!(chat.len(), 3);
283        // Sorted ascending by size, so the last is the largest.
284        assert!(chat[0].size_gb <= chat[2].size_gb);
285    }
286
287    #[test]
288    fn a_tiny_machine_still_gets_the_smallest_suggestion() {
289        // 2 GiB can't run anything well, but the smallest chat model is returned.
290        let chat = recommended(Some(InstallCategory::Chat), 2 * GIB, None);
291        assert_eq!(chat.len(), 1);
292        assert_eq!(chat[0].reference, "gemma3:1b");
293    }
294
295    #[test]
296    fn recommended_for_ram_clamps_to_at_least_one_gib() {
297        // ram 0 → clamped to 1 GiB; still returns the fallback smallest.
298        let chat = recommended_for_ram(Some(InstallCategory::Chat), 0, None);
299        assert_eq!(chat.len(), 1);
300    }
301}