Skip to main content

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}