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> {
79 FitVerdict::assess(Some((self.size_gb * 1e9) as i64), total_memory_bytes)
80 }
81}
82
83pub 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
195pub 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 let start = fitting.len().saturating_sub(3);
230 fitting.split_off(start)
231}
232
233pub 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 assert!(chat[0].size_gb <= chat[2].size_gb);
286 }
287
288 #[test]
289 fn a_tiny_machine_still_gets_the_smallest_suggestion() {
290 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 let chat = recommended_for_ram(Some(InstallCategory::Chat), 0, None);
300 assert_eq!(chat.len(), 1);
301 }
302}