1use std::collections::HashSet;
5
6use crate::install::provider::InstallProviderId;
7use crate::profiles::{FitAssessment, FitVerdict};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum InstallCategory {
12 Chat,
14 Code,
16 Voice,
18 Image,
20}
21
22impl InstallCategory {
23 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#[derive(Debug, Clone, PartialEq)]
36pub struct InstallCatalogEntry {
37 pub provider: InstallProviderId,
39 pub reference: String,
41 pub name: String,
43 pub blurb: String,
45 pub size_gb: f64,
47 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 pub fn id(&self) -> String {
72 format!("{}|{}", self.provider.as_str(), self.reference)
73 }
74
75 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
82pub 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
194pub 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 let start = fitting.len().saturating_sub(3);
229 fitting.split_off(start)
230}
231
232pub 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 assert!(chat[0].size_gb <= chat[2].size_gb);
285 }
286
287 #[test]
288 fn a_tiny_machine_still_gets_the_smallest_suggestion() {
289 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 let chat = recommended_for_ram(Some(InstallCategory::Chat), 0, None);
299 assert_eq!(chat.len(), 1);
300 }
301}