gglib_core/domain/recommendation.rs
1//! Picking a first model that actually fits the machine it will run on.
2//!
3//! `gglib up` has to answer a question a new user cannot: *which* GGUF should
4//! land on this box. Getting it wrong is worse than not answering — a model
5//! that overflows VRAM does not fail, it swaps to host memory and runs at a
6//! tenth of the speed, which reads as "gglib is slow" rather than "that model
7//! was too big".
8//!
9//! The answer is a small hand-maintained shortlist rather than a live search.
10//! Hugging Face has tens of thousands of GGUF repos and no reliable signal for
11//! "this one tool-calls properly"; a curated table is deterministic, testable
12//! offline, and needs no network round-trip before the confirmation prompt.
13//! Its cost — someone has to revisit it as models age — is paid once per
14//! release rather than once per user.
15//!
16//! Candidates are biased towards models whose tool-call dialect
17//! [`crate::normalize`] already parses. Recommending a model gglib cannot
18//! normalize would sell the user the exact failure the proxy exists to fix.
19//!
20//! This module decides *what to suggest*; it does not download, and it has no
21//! opinion on what to do when nothing fits — [`recommend`] returns [`None`]
22//! and the caller reports the hardware it found.
23
24use crate::cache_config::KvCacheType;
25use crate::domain::kv_estimate::{
26 KvElemsPerToken, estimate_kv_bytes_for_context, kv_bytes_per_token,
27};
28use crate::utils::system::SystemMemoryInfo;
29
30/// Fraction of the memory budget a candidate is allowed to occupy.
31///
32/// The remainder absorbs what this estimate deliberately does not model: the
33/// compute buffer, the framebuffer already in use by the desktop, allocator
34/// fragmentation. Sizing to 100% of nominal VRAM reliably produces a model
35/// that *almost* fits, which is the slowest possible outcome.
36const BUDGET_UTILISATION: f64 = 0.9;
37
38/// One entry in the shortlist.
39///
40/// Weights are recorded as the actual byte size of the quantized file on
41/// Hugging Face — not a rounded "about 18 GB" — because the whole point is to
42/// compare against a real memory figure. The KV shape comes from the model's
43/// own config so the cache cost is computed by [`kv_estimate`], not restated
44/// here as a second magic number.
45///
46/// [`kv_estimate`]: crate::domain::kv_estimate
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct ModelCandidate {
49 /// Hugging Face repository id, passed verbatim to the download queue.
50 pub repo: &'static str,
51 /// Quantization to request from that repository.
52 pub quantization: &'static str,
53 /// Size of the quantized weights, in bytes.
54 pub weights_bytes: u64,
55 /// Per-token KV cache element counts, derived from the model's config.
56 pub kv_elems_per_token: KvElemsPerToken,
57 /// Context this candidate is sized for.
58 pub context: u64,
59 /// Why this model, in the user's terms. Printed verbatim.
60 pub rationale: &'static str,
61}
62
63impl ModelCandidate {
64 /// Total memory this candidate needs: weights plus KV cache at
65 /// [`context`](Self::context), quantized to the gglib default.
66 ///
67 /// Uses [`kv_bytes_per_token`] with [`KvCacheType::Q8_0`] on both sides
68 /// because that is what the runtime actually launches with (see
69 /// `gglib_runtime::llama::args::kv_cache_type`). A recommendation computed
70 /// against `f16` would over-reserve by roughly the KV cache again.
71 #[must_use]
72 pub const fn required_bytes(&self) -> u64 {
73 let per_token = kv_bytes_per_token(
74 self.kv_elems_per_token,
75 KvCacheType::Q8_0,
76 KvCacheType::Q8_0,
77 );
78 self.weights_bytes
79 .saturating_add(estimate_kv_bytes_for_context(per_token, self.context))
80 }
81
82 /// The smallest memory budget this candidate may be recommended for.
83 #[must_use]
84 pub fn min_budget_bytes(&self) -> u64 {
85 #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
86 #[allow(clippy::cast_possible_truncation)]
87 {
88 (self.required_bytes() as f64 / BUDGET_UTILISATION) as u64
89 }
90 }
91}
92
93/// Which pool of memory the recommendation was sized against.
94///
95/// Worth carrying rather than inferring at the print site: "24.0 GB VRAM" and
96/// "24.0 GB system RAM" lead to very different expectations, and the
97/// [`SystemRam`](Self::SystemRam) case is frequently a *fallback* rather than a
98/// CPU-only machine — see [`recommend`].
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum BudgetSource {
101 /// Discrete GPU VRAM.
102 Vram,
103 /// Apple Silicon unified memory.
104 UnifiedMemory,
105 /// Host RAM — either a CPU-only machine, or a GPU whose VRAM gglib cannot
106 /// read.
107 SystemRam,
108}
109
110impl BudgetSource {
111 /// Short label for terminal output.
112 #[must_use]
113 pub const fn label(self) -> &'static str {
114 match self {
115 Self::Vram => "VRAM",
116 Self::UnifiedMemory => "unified memory",
117 Self::SystemRam => "system RAM",
118 }
119 }
120}
121
122/// A candidate plus the reasoning that selected it.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct Recommendation {
125 /// The chosen model.
126 pub candidate: &'static ModelCandidate,
127 /// The memory figure it was sized against.
128 pub budget_bytes: u64,
129 /// Where that figure came from.
130 pub budget_source: BudgetSource,
131 /// Budget left over after the candidate's requirement.
132 pub headroom_bytes: u64,
133}
134
135/// The shortlist, largest first.
136///
137/// Byte sizes are the `Q4_K_M` files on Hugging Face as published; KV shapes
138/// are `num_hidden_layers × num_key_value_heads × head_dim` from each model's
139/// `config.json`. Both are verified by [`tests::shortlist_is_internally_consistent`]
140/// only for self-consistency — the figures themselves have to be re-checked
141/// against the repositories when this table is edited.
142static SHORTLIST: &[ModelCandidate] = &[
143 ModelCandidate {
144 repo: "unsloth/Qwen3-30B-A3B-GGUF",
145 quantization: "Q4_K_M",
146 weights_bytes: 18_556_686_912,
147 // 48 layers x 4 KV heads x 128 head dim.
148 kv_elems_per_token: KvElemsPerToken {
149 k: 24_576,
150 v: 24_576,
151 },
152 context: 32_768,
153 rationale: "mixture-of-experts: 30B of knowledge, ~3B active per token, \
154 so it answers at roughly 3B speed",
155 },
156 ModelCandidate {
157 repo: "bartowski/Qwen2.5-Coder-14B-Instruct-GGUF",
158 quantization: "Q4_K_M",
159 weights_bytes: 8_988_111_072,
160 // 48 layers x 8 KV heads x 128 head dim.
161 kv_elems_per_token: KvElemsPerToken {
162 k: 49_152,
163 v: 49_152,
164 },
165 context: 32_768,
166 rationale: "the strongest dense coding model that still leaves room for \
167 a 32k context",
168 },
169 ModelCandidate {
170 repo: "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF",
171 quantization: "Q4_K_M",
172 weights_bytes: 4_683_074_336,
173 // 28 layers x 4 KV heads x 128 head dim.
174 kv_elems_per_token: KvElemsPerToken {
175 k: 14_336,
176 v: 14_336,
177 },
178 context: 32_768,
179 rationale: "dependable tool calling on a mid-range card, with headroom \
180 to spare",
181 },
182 ModelCandidate {
183 repo: "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
184 quantization: "Q4_K_M",
185 weights_bytes: 1_929_903_360,
186 // 36 layers x 2 KV heads x 128 head dim.
187 kv_elems_per_token: KvElemsPerToken { k: 9_216, v: 9_216 },
188 context: 32_768,
189 rationale: "the smallest model in the list that still calls tools \
190 reliably enough to drive an agent",
191 },
192];
193
194/// Resolve the memory figure to size against, and say where it came from.
195///
196/// VRAM wins when it is known, because that is the memory the weights will
197/// actually occupy. It is `None` on every Vulkan-only machine — gglib reads
198/// VRAM for Metal and NVIDIA only — so an AMD or Intel GPU falls back to host
199/// RAM. That fallback is usually *too generous*, which is exactly why
200/// [`BudgetSource`] is returned alongside the number instead of being thrown
201/// away: the caller is expected to say so.
202const fn resolve_budget(mem: &SystemMemoryInfo) -> (u64, BudgetSource) {
203 match mem.gpu_memory_bytes {
204 Some(vram) if mem.is_apple_silicon => (vram, BudgetSource::UnifiedMemory),
205 Some(vram) => (vram, BudgetSource::Vram),
206 None => (mem.total_ram_bytes, BudgetSource::SystemRam),
207 }
208}
209
210/// Recommend the largest shortlisted model that fits this machine.
211///
212/// Returns `None` when even the smallest candidate would not fit. That is a
213/// real answer, not a failure: suggesting a model that overflows would produce
214/// a working-but-unusably-slow endpoint, and the user is better served by
215/// being told their budget and left to choose.
216#[must_use]
217pub fn recommend(mem: &SystemMemoryInfo) -> Option<Recommendation> {
218 let (budget_bytes, budget_source) = resolve_budget(mem);
219
220 // Largest-first, so the first fit is the best fit.
221 let candidate = SHORTLIST
222 .iter()
223 .find(|c| c.min_budget_bytes() <= budget_bytes)?;
224
225 Some(Recommendation {
226 candidate,
227 budget_bytes,
228 budget_source,
229 headroom_bytes: budget_bytes.saturating_sub(candidate.required_bytes()),
230 })
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 const GB: u64 = 1_073_741_824;
238
239 fn vram(gb: u64) -> SystemMemoryInfo {
240 SystemMemoryInfo {
241 total_ram_bytes: 64 * GB,
242 gpu_memory_bytes: Some(gb * GB),
243 is_apple_silicon: false,
244 has_nvidia_gpu: true,
245 }
246 }
247
248 fn ram_only(gb: u64) -> SystemMemoryInfo {
249 SystemMemoryInfo {
250 total_ram_bytes: gb * GB,
251 gpu_memory_bytes: None,
252 is_apple_silicon: false,
253 has_nvidia_gpu: false,
254 }
255 }
256
257 /// The table is hand-maintained, so guard the invariants a careless edit
258 /// would break. This cannot check the byte counts are *correct* — only
259 /// Hugging Face can — but it does catch a row inserted out of order, which
260 /// would silently make `recommend` return an undersized model.
261 #[test]
262 fn shortlist_is_internally_consistent() {
263 assert!(!SHORTLIST.is_empty());
264 for c in SHORTLIST {
265 assert!(
266 c.required_bytes() > c.weights_bytes,
267 "{}: KV cache must cost something",
268 c.repo
269 );
270 assert!(c.context > 0, "{}: context must be set", c.repo);
271 assert!(!c.rationale.is_empty(), "{}: needs a rationale", c.repo);
272 }
273 for pair in SHORTLIST.windows(2) {
274 assert!(
275 pair[0].required_bytes() > pair[1].required_bytes(),
276 "shortlist must be ordered largest-first: {} is not bigger than {}",
277 pair[0].repo,
278 pair[1].repo,
279 );
280 }
281 }
282
283 /// The tiers this exists to serve. A change that shifts any of these is a
284 /// product decision, not a refactor.
285 #[test]
286 fn each_tier_selects_the_expected_model() {
287 let cases = [
288 (24, "unsloth/Qwen3-30B-A3B-GGUF"),
289 (16, "bartowski/Qwen2.5-Coder-14B-Instruct-GGUF"),
290 (12, "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"),
291 (8, "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"),
292 (4, "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF"),
293 ];
294 for (gb, expected) in cases {
295 let got = recommend(&vram(gb)).unwrap_or_else(|| panic!("{gb} GB found nothing"));
296 assert_eq!(got.candidate.repo, expected, "at {gb} GB");
297 }
298 }
299
300 #[test]
301 fn nothing_fits_below_the_smallest_tier() {
302 assert!(recommend(&vram(2)).is_none());
303 }
304
305 /// VRAM is the memory the weights occupy; a large host RAM figure must not
306 /// talk the recommendation up past what the card can hold.
307 #[test]
308 fn vram_wins_over_system_ram_when_known() {
309 let mem = SystemMemoryInfo {
310 total_ram_bytes: 128 * GB,
311 gpu_memory_bytes: Some(8 * GB),
312 is_apple_silicon: false,
313 has_nvidia_gpu: true,
314 };
315 let got = recommend(&mem).expect("8 GB fits something");
316 assert_eq!(got.budget_source, BudgetSource::Vram);
317 assert_eq!(got.budget_bytes, 8 * GB);
318 assert_eq!(
319 got.candidate.repo,
320 "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"
321 );
322 }
323
324 #[test]
325 fn apple_silicon_reports_unified_memory() {
326 let mem = SystemMemoryInfo {
327 total_ram_bytes: 32 * GB,
328 gpu_memory_bytes: Some(24 * GB),
329 is_apple_silicon: true,
330 has_nvidia_gpu: false,
331 };
332 let got = recommend(&mem).expect("24 GB fits something");
333 assert_eq!(got.budget_source, BudgetSource::UnifiedMemory);
334 }
335
336 /// Vulkan-only machines report no VRAM at all, so this is the AMD/Intel
337 /// path, not just the CPU-only one.
338 #[test]
339 fn missing_vram_falls_back_to_system_ram() {
340 let got = recommend(&ram_only(32)).expect("32 GB fits something");
341 assert_eq!(got.budget_source, BudgetSource::SystemRam);
342 assert_eq!(got.budget_bytes, 32 * GB);
343 }
344
345 /// A budget exactly equal to the requirement must be refused: the reserve
346 /// is what stops "fits on paper" from becoming "swaps to host memory".
347 #[test]
348 fn a_budget_equal_to_the_requirement_is_not_enough() {
349 let smallest = SHORTLIST.last().expect("shortlist is non-empty");
350 let mem = SystemMemoryInfo {
351 total_ram_bytes: smallest.required_bytes(),
352 gpu_memory_bytes: Some(smallest.required_bytes()),
353 is_apple_silicon: false,
354 has_nvidia_gpu: true,
355 };
356 assert!(recommend(&mem).is_none());
357
358 // ...but the same requirement plus the reserve is.
359 let mem = SystemMemoryInfo {
360 gpu_memory_bytes: Some(smallest.min_budget_bytes()),
361 ..mem
362 };
363 assert!(recommend(&mem).is_some());
364 }
365
366 #[test]
367 fn headroom_is_the_unused_remainder() {
368 let got = recommend(&vram(24)).expect("24 GB fits something");
369 assert_eq!(got.headroom_bytes, 24 * GB - got.candidate.required_bytes());
370 }
371
372 /// The KV term must track the context, or the 14B's much larger cache
373 /// would be invisible to the fit check.
374 #[test]
375 fn required_bytes_includes_the_kv_cache_at_the_stated_context() {
376 let c = SHORTLIST[0];
377 let per_token =
378 kv_bytes_per_token(c.kv_elems_per_token, KvCacheType::Q8_0, KvCacheType::Q8_0);
379 assert_eq!(c.required_bytes(), c.weights_bytes + per_token * c.context);
380 }
381}