Skip to main content

car_inference/
recommend.rs

1//! Model recommender — hardware + intent → ranked, explained model picks.
2//!
3//! This is the framing layer that turns the typed registry
4//! (`ModelSchema`), hardware facts (`HardwareInfo` / `SupportedAcceleration`),
5//! and the acquisition-intent vocabulary (`UseCase` / `QualityTier` /
6//! `Privacy`) into something a non-expert can act on: "given this machine
7//! and what I want to do, which model should I install, and why?"
8//!
9//! The full contract lives in docs/solutions/first-class-model-ux.md. The
10//! selection pipeline is deterministic — same `(models, hardware, intent)`
11//! always yields the same ranking — and pure (no disk, no network), so the
12//! hardware × use-case matrix is unit-testable.
13//!
14//! This supersedes `hardware::recommend_model` for the user-facing flows
15//! (`car setup`, `models.recommend`). That standalone heuristic survives only
16//! as a registry-less bootstrap inside `HardwareInfo::detect`; everything that
17//! has a registry in hand should call [`recommend`].
18
19use serde::{Deserialize, Serialize};
20
21use crate::hardware::{HardwareInfo, SupportedAcceleration};
22use crate::intent::{Privacy, QualityTier, UseCase, UseCaseRole};
23use crate::resource_policy::{
24    estimate_model_memory_for_checkpoint, model_parameter_billions_active,
25    model_parameter_billions_total, CheckpointGeometry, ResourcePolicy, ResourceProfile,
26    RECOMMENDATION_CONTEXT_TOKENS,
27};
28use crate::schema::{ModelSchema, TrustTier};
29
30/// Whether a model fits in the machine's memory for local execution.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum FitStatus {
34    /// Comfortably fits with headroom for KV cache + overhead.
35    Fits,
36    /// Too large for this machine's memory budget.
37    TooBig,
38    /// Runs on an external server (vLLM-MLX) or remote API — local memory
39    /// does not apply. Never claimed as a local "fits".
40    ServerProvided,
41    /// The model declares no usable memory figure, so fit can't be asserted.
42    Unknown,
43}
44
45/// One ranked recommendation, explained in plain language.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Recommendation {
48    /// Registry id (e.g. "qwen/qwen3-4b:q4_k_m"). The caller pulls by this.
49    pub model_id: String,
50    /// Human-readable name shown to the user.
51    pub display_name: String,
52    /// The role lane this pick serves.
53    pub role: UseCaseRole,
54    /// Plain-language reason, generated from the winning factors. Never
55    /// exposes quantization / repo / file jargon.
56    pub rationale: String,
57    /// Download size in MB.
58    pub download_mb: u64,
59    /// True if the model is already installed.
60    pub already_installed: bool,
61    /// Local memory fit.
62    pub fit: FitStatus,
63    /// The acceleration tier this machine would run it on.
64    pub acceleration: SupportedAcceleration,
65    /// True for on-device models, false for remote/cloud.
66    pub is_local: bool,
67    /// True when running this pick sends prompts off the machine — the
68    /// caller must obtain one-time consent before the first cloud inference.
69    pub requires_cloud_consent: bool,
70    /// How much the project vouches for this model.
71    pub trust_tier: TrustTier,
72    /// Internal blended score (higher is better). Exposed for tests/debug.
73    pub score: f32,
74    /// Whether CAR may choose this candidate automatically for the current
75    /// policy lane. False candidates remain visible as explicit heavier or
76    /// unknown-memory alternatives that a person can choose deliberately.
77    ///
78    /// The default preserves deserialization compatibility with responses
79    /// produced before this additive field existed.
80    #[serde(default = "default_true")]
81    pub within_recommendation_target: bool,
82    /// Estimated peak resident memory at the recommender's typical context
83    /// ([`RECOMMENDATION_CONTEXT_TOKENS`]): weights, KV cache and runtime
84    /// margin. Preflight at the request's own context can differ. `0` for a
85    /// model that does not run in this machine's memory (car#3536).
86    #[serde(default)]
87    pub estimated_peak_mb: u64,
88}
89
90const fn default_true() -> bool {
91    true
92}
93
94// --- tuning constants (documented; consistent with hardware.rs scale) ------
95
96/// RAM the OS + other apps need; subtracted from total before the budget.
97const OS_RESERVE_MB: u64 = 3072;
98
99/// The result of a recommendation query — never a bare `Vec`, so the caller
100/// can tell "here are your picks" from "nothing fits, here's why".
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RecommendationSet {
103    /// Ranked, actionable picks (best first). Empty when nothing is eligible
104    /// or everything is too big — read `note` in that case.
105    pub picks: Vec<Recommendation>,
106    /// Eligible models that don't fit this machine's memory, for an honest
107    /// "needs more RAM" listing. Ranked by score so the closest miss is first.
108    pub not_enough_memory: Vec<Recommendation>,
109    /// Plain-language explanation when `picks` is empty, or a heads-up worth
110    /// surfacing (e.g. the top pick runs in the cloud). `None` otherwise.
111    pub note: Option<String>,
112    /// The resource policy's local-model ceiling the fit verdicts were judged
113    /// against, when a policy is in force. A model listed in
114    /// `not_enough_memory` is over this ceiling, which is a share of the
115    /// machine's memory, not all of it (car#3532).
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub memory_ceiling_mb: Option<u64>,
118}
119
120/// Memory fit of one catalog row on one machine, as `models.list_unified`
121/// publishes it. Three-valued on purpose: a catalog consumer wants to know
122/// whether to offer the row, and "the server owns its memory" is, for that
123/// question, a fit. [`FitStatus`] keeps the four-way distinction for the
124/// recommender, which ranks rather than filters.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
126#[serde(rename_all = "snake_case")]
127pub enum ModelFitStatus {
128    /// Fits this machine's active local-model budget, or runs somewhere that
129    /// is not this machine's memory at all (remote API, delegated runner,
130    /// operator-managed external endpoint).
131    Fits,
132    /// Too large for this machine's active local-model budget.
133    TooBig,
134    /// The row declares no usable memory figure, or the accelerator's memory
135    /// is unknown, so fit cannot be asserted either way. Also what a client
136    /// sees from a daemon that predates the annotation, which is why it is
137    /// the `Default`: an absent verdict must never read as too-big.
138    #[default]
139    Unknown,
140}
141
142/// A row's fit annotation: the verdict, the estimate it was judged on, and
143/// whether the accelerator can run the row at all.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct ModelFit {
146    pub fit: ModelFitStatus,
147    /// The cold-load peak the verdict was judged against, in MB. `None` when
148    /// the row's memory is not this machine's (remote, delegated, external)
149    /// or when it declares neither a size nor a RAM figure — the same rows
150    /// the recommender refuses to judge by memory.
151    pub estimated_peak_mb: Option<u64>,
152    /// See [`platform_compatible`].
153    pub platform_compatible: bool,
154}
155
156/// The `models.list_unified` fit rule. It is `build_recommendation`'s own
157/// verdict — the same [`estimate_model_memory_for_checkpoint`] at
158/// [`RECOMMENDATION_CONTEXT_TOKENS`], the same `fit_status`, the same policy
159/// ceiling — collapsed to the three values a catalog consumer acts on, plus
160/// `passes_base_filter`'s platform check. Pure over its inputs: nothing here
161/// touches disk or the network, and nothing is persisted.
162///
163/// `geometry` carries installed checkpoints' own KV geometry, read by the
164/// caller from the models directory admission reads (car#2213). An installed
165/// row's KV cache is therefore sized as admission sizes it; a row `geometry`
166/// does not hold falls back to the parameter count, as admission does. What
167/// still separates the two is live memory, which only admission consults.
168/// Every reader of this verdict shares the input — `models.list_unified`'s
169/// `fit`, `models.recommend`, the portfolio's `never_runs_here`, and
170/// `car models fit`.
171pub fn model_fit(
172    m: &ModelSchema,
173    hw: &HardwareInfo,
174    policy: Option<&ResourcePolicy>,
175    geometry: &CheckpointGeometry,
176) -> ModelFit {
177    let memory_limits = RecommendationMemoryLimits::for_policy(hw, policy);
178    let estimate =
179        estimate_model_memory_for_checkpoint(m, hw, RECOMMENDATION_CONTEXT_TOKENS, geometry);
180    let status = fit_status(m, hw, &estimate, &memory_limits);
181    let fit = match status {
182        FitStatus::Fits | FitStatus::ServerProvided => ModelFitStatus::Fits,
183        FitStatus::TooBig => ModelFitStatus::TooBig,
184        FitStatus::Unknown => ModelFitStatus::Unknown,
185    };
186    let estimated_peak_mb = match status {
187        FitStatus::ServerProvided => None,
188        // Mirrors `fit_status`'s own "nothing declared" gate: an estimate
189        // derived from no declared figure is not evidence worth publishing.
190        _ if m.size_mb() == 0 && m.ram_mb() == 0 => None,
191        _ => Some(estimate.estimated_peak_mb),
192    };
193    ModelFit {
194        fit,
195        estimated_peak_mb,
196        platform_compatible: platform_compatible(m, hw),
197    }
198}
199
200/// Rank model picks for a machine + intent, best first. Pure over its inputs.
201///
202/// `models` is typically `UnifiedRegistry::list()`. Returns at most one
203/// role's lane today (every `UseCase` is single-role). Too-big-but-eligible
204/// models are surfaced separately in `not_enough_memory` rather than dropped
205/// silently, and an empty `picks` always comes with an explanatory `note`.
206/// `geometry` is installed checkpoints' KV geometry, as for [`model_fit`].
207pub fn recommend(
208    models: &[&ModelSchema],
209    hw: &HardwareInfo,
210    use_case: UseCase,
211    tier: QualityTier,
212    privacy: Privacy,
213    geometry: &CheckpointGeometry,
214) -> RecommendationSet {
215    recommend_inner(models, hw, use_case, tier, privacy, None, geometry)
216}
217
218/// Policy-aware recommendation entry point. Every profile supplies the fit
219/// ceiling and automatic-selection target. Only Everyday Assistant/Balanced
220/// changes the legacy ranking and capability floor.
221pub fn recommend_with_policy(
222    models: &[&ModelSchema],
223    hw: &HardwareInfo,
224    policy: &ResourcePolicy,
225    use_case: UseCase,
226    tier: QualityTier,
227    privacy: Privacy,
228    geometry: &CheckpointGeometry,
229) -> RecommendationSet {
230    recommend_inner(models, hw, use_case, tier, privacy, Some(policy), geometry)
231}
232
233fn recommend_inner(
234    models: &[&ModelSchema],
235    hw: &HardwareInfo,
236    use_case: UseCase,
237    tier: QualityTier,
238    privacy: Privacy,
239    policy: Option<&ResourcePolicy>,
240    geometry: &CheckpointGeometry,
241) -> RecommendationSet {
242    let accel = hw.supported_acceleration();
243    // A model without `ToolUse` cannot run the assistant: `car do` refuses it
244    // and substitutes a tool-capable one. That is a property of the USE CASE,
245    // not of the resource policy or the quality tier.
246    //
247    // It used to ride on `everyday_assistant_balanced` below, which also
248    // selects a sort order, so the requirement silently applied only on the
249    // default profile at the default tier. `--tier fastest` and
250    // `--tier most-capable` therefore ranked models the assistant will not use
251    // — and told the reader to install the top one with `car setup --for
252    // assistant`. Measured before this split: `--tier fastest` returned
253    // Qwen3-0.6B first, which has no `ToolUse` (car#1822).
254    let assistant_requires_tools = use_case == UseCase::Assistant;
255    let everyday_assistant_balanced = policy.is_some_and(|policy| {
256        policy.profile == ResourceProfile::Everyday
257            && use_case == UseCase::Assistant
258            && tier == QualityTier::Balanced
259    });
260    let memory_limits = RecommendationMemoryLimits::for_policy(hw, policy);
261    let sort = |v: &mut Vec<RankedRecommendation>| {
262        // Everyday Assistant/Balanced is intentionally memory-first for local
263        // candidates. The policy applies only in that lane, so every legacy
264        // caller keeps the established score ordering.
265        v.sort_by(|a, b| {
266            if everyday_assistant_balanced {
267                // This is an item-local key, not a pair-dependent branch.
268                // That matters when local and cloud candidates are mixed:
269                // every comparison observes the same total ordering, so sort
270                // transitivity cannot depend on the input permutation.
271                let policy_class = |recommendation: &Recommendation| {
272                    if recommendation.fit == FitStatus::Unknown {
273                        3
274                    } else if recommendation.is_local && recommendation.within_recommendation_target
275                    {
276                        0
277                    } else if !recommendation.is_local
278                        && recommendation.within_recommendation_target
279                    {
280                        1
281                    } else {
282                        2
283                    }
284                };
285                let a_recommendation = &a.recommendation;
286                let b_recommendation = &b.recommendation;
287                return policy_class(a_recommendation)
288                    .cmp(&policy_class(b_recommendation))
289                    .then(
290                        b_recommendation
291                            .already_installed
292                            .cmp(&a_recommendation.already_installed),
293                    )
294                    .then(a.estimated_peak_mb.cmp(&b.estimated_peak_mb))
295                    .then(a.latency_p50_ms.cmp(&b.latency_p50_ms))
296                    .then_with(|| b_recommendation.score.total_cmp(&a_recommendation.score))
297                    .then(a_recommendation.model_id.cmp(&b_recommendation.model_id));
298            }
299            b.recommendation
300                .score
301                .total_cmp(&a.recommendation.score)
302                .then(
303                    b.recommendation
304                        .already_installed
305                        .cmp(&a.recommendation.already_installed),
306                )
307                .then(
308                    a.recommendation
309                        .download_mb
310                        .cmp(&b.recommendation.download_mb),
311                )
312                .then(a.recommendation.model_id.cmp(&b.recommendation.model_id))
313        });
314    };
315
316    let size_prior_ceiling = size_prior_ceiling(models);
317    let (mut picks, mut not_enough_memory): (Vec<_>, Vec<_>) = models
318        .iter()
319        .filter(|m| {
320            passes_base_filter(m, hw, use_case, privacy)
321                && (!assistant_requires_tools
322                    || m.has_capability(crate::schema::ModelCapability::ToolUse))
323        })
324        .map(|model| {
325            build_recommendation(
326                model,
327                hw,
328                &accel,
329                use_case,
330                tier,
331                &memory_limits,
332                geometry,
333                size_prior_ceiling,
334            )
335        })
336        .partition(|ranked| ranked.recommendation.fit != FitStatus::TooBig);
337    sort(&mut picks);
338    sort(&mut not_enough_memory);
339    let picks: Vec<_> = picks
340        .into_iter()
341        .map(|ranked| ranked.recommendation)
342        .collect();
343    let not_enough_memory: Vec<_> = not_enough_memory
344        .into_iter()
345        .map(|ranked| ranked.recommendation)
346        .collect();
347
348    let note = explain_if_needed(&picks, &not_enough_memory, hw, use_case, tier, privacy)
349        .or_else(|| unmeasured_larger_candidates(models, &picks, tier));
350    RecommendationSet {
351        picks,
352        not_enough_memory,
353        note,
354        memory_ceiling_mb: memory_limits.policy_host_budget_mb,
355    }
356}
357
358struct RankedRecommendation {
359    recommendation: Recommendation,
360    estimated_peak_mb: u64,
361    latency_p50_ms: u64,
362}
363
364#[derive(Clone, Copy)]
365struct RecommendationMemoryLimits {
366    legacy_budget_mb: u64,
367    policy_host_budget_mb: Option<u64>,
368    recommendation_target_mb: Option<u64>,
369}
370
371impl RecommendationMemoryLimits {
372    /// The budgets every fit verdict is judged against: the legacy
373    /// acceleration-tier budget, plus the policy's host ceiling and automatic
374    /// recommendation target when a policy is in force. One constructor, so
375    /// [`recommend_with_policy`] and [`model_fit`] cannot drift apart.
376    fn for_policy(hw: &HardwareInfo, policy: Option<&ResourcePolicy>) -> Self {
377        Self {
378            legacy_budget_mb: memory_budget_mb(hw),
379            policy_host_budget_mb: policy.map(|policy| {
380                policy
381                    .effective_budget(hw.total_ram_mb)
382                    .configured_model_ceiling_mb
383            }),
384            recommendation_target_mb: policy
385                .map(|policy| policy.recommendation_target_mb(hw.total_ram_mb)),
386        }
387    }
388}
389
390/// Hard eligibility, minus the fit check (fit is handled by partitioning so
391/// too-big models can still be surfaced). A model failing any of these is
392/// never shown at all.
393fn passes_base_filter(
394    m: &ModelSchema,
395    hw: &HardwareInfo,
396    use_case: UseCase,
397    privacy: Privacy,
398) -> bool {
399    if m.deprecated {
400        return false;
401    }
402    // Capability requirement encodes the role lane (Search⇒Embed, etc.).
403    if !use_case
404        .required_capabilities()
405        .iter()
406        .all(|c| m.has_capability(*c))
407    {
408        return false;
409    }
410    // Privacy: on-device excludes anything that leaves the machine.
411    if privacy == Privacy::OnDevice && !m.is_local() {
412        return false;
413    }
414    // A Metal-only model on a non-Apple machine can't run at all.
415    platform_compatible(m, hw)
416}
417
418/// Whether this machine can run the model at all. Metal-only sources (MLX,
419/// CAR-managed vLLM-MLX, Apple FoundationModels) need Apple Silicon, and
420/// GGUF/Candle rows never run there;
421/// WindowsSpeech and rows tagged `windows-only` need Windows; rows tagged
422/// `linux-only` need Linux. Everything else runs anywhere CAR does. Shared by
423/// the recommender's eligibility filter and the `models.list_unified` fit
424/// annotation so the two cannot disagree about which rows a machine can host.
425pub fn platform_compatible(m: &ModelSchema, hw: &HardwareInfo) -> bool {
426    let windows_only = matches!(m.source, crate::schema::ModelSource::WindowsSpeech { .. })
427        || m.tags.iter().any(|tag| tag == "windows-only");
428    let linux_only = m.tags.iter().any(|tag| tag == "linux-only");
429    let apple = matches!(
430        hw.supported_acceleration(),
431        SupportedAcceleration::Apple { .. }
432    );
433    let apple_compatible = !m.requires_apple_silicon() || apple;
434    // On Apple Silicon CAR runs local text only through MLX: a GGUF request
435    // executes as its MLX twin, an exact GGUF pin is refused, and a GGUF row
436    // with no twin cannot run at all. Offering the GGUF row there recommended
437    // a download that would never execute — and listed every Qwen3 twice.
438    let candle_runs_here = !(apple && matches!(m.source, crate::schema::ModelSource::Local { .. }));
439    let windows_compatible = !windows_only || hw.os.eq_ignore_ascii_case("windows");
440    let linux_compatible = !linux_only || hw.os.eq_ignore_ascii_case("linux");
441    apple_compatible && candle_runs_here && windows_compatible && linux_compatible
442}
443
444/// Build the honest `note` for the result set. Empty picks always get one.
445fn explain_if_needed(
446    picks: &[Recommendation],
447    too_big: &[Recommendation],
448    hw: &HardwareInfo,
449    use_case: UseCase,
450    tier: QualityTier,
451    privacy: Privacy,
452) -> Option<String> {
453    let purpose = use_case_purpose(use_case);
454    if picks.is_empty() {
455        let ram_gb = hw.total_ram_mb / 1024;
456        return Some(if !too_big.is_empty() {
457            match privacy {
458                Privacy::OnDevice => format!(
459                    "No on-device model for {purpose} fits your {ram_gb} GB machine. \
460                     Free up memory, pick a smaller tier, or allow cloud models."
461                ),
462                Privacy::CloudOk => format!(
463                    "No local model for {purpose} fits your {ram_gb} GB machine, and no \
464                     cloud model is configured. Add an API key or free up memory."
465                ),
466            }
467        } else {
468            format!("No model available for {purpose} on this machine.")
469        });
470    }
471    // Heads-up when the best pick we can offer is the most-capable tier but
472    // still ran out of bigger options, or when the top pick is cloud.
473    if picks[0].requires_cloud_consent {
474        return Some(format!(
475            "The best {purpose} pick runs in the cloud and needs your OK before first use. \
476             {} fits locally if you prefer on-device.",
477            picks
478                .iter()
479                .find(|p| p.is_local)
480                .map(|p| p.display_name.as_str())
481                .unwrap_or("No local model")
482        ));
483    }
484    let _ = tier;
485    None
486}
487
488/// A heads-up when "most capable" had larger candidates it could not judge.
489///
490/// The quality prior is a published benchmark when one exists and a saturating
491/// size curve otherwise, and those are not the same scale — the curve tops out
492/// below what a real score reaches, so a measured mid-size model outranks an
493/// unmeasured larger one by construction. That is defensible ranking (an
494/// unmeasured model is genuinely unproven) but it is not something to state as
495/// "the most capable model that fits" while saying nothing about the rest.
496///
497/// So: name them. The user can see that the answer is bounded by what has been
498/// measured, and knows there is something to do about it.
499fn unmeasured_larger_candidates(
500    models: &[&ModelSchema],
501    picks: &[Recommendation],
502    tier: QualityTier,
503) -> Option<String> {
504    if tier != QualityTier::MostCapable {
505        return None;
506    }
507    let top = picks.first()?;
508    let top_params = models
509        .iter()
510        .find(|m| m.id == top.model_id)
511        .map(|m| param_billions_total(m))?;
512
513    let mut larger: Vec<&str> = models
514        .iter()
515        .filter(|m| {
516            m.is_local()
517                && m.public_benchmarks.is_empty()
518                && param_billions_total(m) > top_params * 1.5
519        })
520        .map(|m| m.name.as_str())
521        .collect();
522    if larger.is_empty() {
523        return None;
524    }
525    larger.sort_unstable();
526    larger.dedup();
527    let shown = larger
528        .iter()
529        .take(3)
530        .copied()
531        .collect::<Vec<_>>()
532        .join(", ");
533    let rest = larger.len().saturating_sub(3);
534    let and_more = if rest > 0 {
535        format!(" and {rest} more")
536    } else {
537        String::new()
538    };
539    Some(format!(
540        "Ranked among models with a measured quality score. Larger ones this \
541         machine can run are unscored, so they cannot be ranked here yet: \
542         {shown}{and_more}. Run `scripts/bench-contribute.sh` to score them."
543    ))
544}
545
546fn use_case_purpose(use_case: UseCase) -> &'static str {
547    match use_case {
548        UseCase::Assistant => "chat & general help",
549        UseCase::Coding => "coding",
550        UseCase::Summarize => "summarizing",
551        UseCase::Vision => "understanding images",
552        UseCase::Transcription => "transcription",
553        UseCase::Search => "semantic search",
554    }
555}
556
557fn build_recommendation(
558    m: &ModelSchema,
559    hw: &HardwareInfo,
560    accel: &SupportedAcceleration,
561    use_case: UseCase,
562    tier: QualityTier,
563    memory_limits: &RecommendationMemoryLimits,
564    geometry: &CheckpointGeometry,
565    size_prior_ceiling: f32,
566) -> RankedRecommendation {
567    let estimate =
568        estimate_model_memory_for_checkpoint(m, hw, RECOMMENDATION_CONTEXT_TOKENS, geometry);
569    let fit = fit_status(m, hw, &estimate, memory_limits);
570    let quality = quality_score(m, size_prior_ceiling);
571    let latency = latency_score(m, accel);
572    let pressure = memory_pressure(&estimate, memory_limits.legacy_budget_mb);
573    let w = tier.weights();
574    // Higher score is better, so memory *pressure* is inverted.
575    let mut score =
576        w.quality * quality + w.latency * latency + w.memory_pressure * (1.0 - pressure);
577    // Preferred capabilities are a soft bonus, never an eligibility gate.
578    let pref_hits = use_case
579        .preferred_capabilities()
580        .iter()
581        .filter(|c| m.has_capability(**c))
582        .count();
583    score += 0.05 * pref_hits as f32;
584
585    let is_local = m.is_local();
586    let within_recommendation_target = match memory_limits.recommendation_target_mb {
587        None => true,
588        Some(_) if fit == FitStatus::Unknown => false,
589        Some(target_mb) if is_local => match hw.supported_acceleration() {
590            SupportedAcceleration::Cuda {
591                device_memory_mb: Some(device_memory_mb),
592            } => {
593                let host_required_mb = estimate
594                    .estimated_peak_mb
595                    .saturating_sub(estimate.weights_mb);
596                fit == FitStatus::Fits
597                    && estimate.weights_mb <= device_memory_mb
598                    && host_required_mb <= target_mb
599            }
600            SupportedAcceleration::Cuda {
601                device_memory_mb: None,
602            } => false,
603            _ => fit == FitStatus::Fits && estimate.estimated_peak_mb <= target_mb,
604        },
605        Some(_) => true,
606    };
607    RankedRecommendation {
608        estimated_peak_mb: if is_local {
609            estimate.estimated_peak_mb
610        } else {
611            0
612        },
613        latency_p50_ms: m.performance.latency_p50_ms.unwrap_or(u64::MAX),
614        recommendation: Recommendation {
615            model_id: m.id.clone(),
616            display_name: m.name.clone(),
617            role: use_case.role(),
618            rationale: rationale(m, hw, use_case, tier, fit, quality),
619            download_mb: if m.downloads_weights() {
620                m.size_mb()
621            } else {
622                0
623            },
624            already_installed: m.has_installed_weights(),
625            fit,
626            acceleration: accel.clone(),
627            is_local,
628            requires_cloud_consent: !is_local,
629            trust_tier: m.trust_tier,
630            score,
631            within_recommendation_target,
632            estimated_peak_mb: if is_local {
633                estimate.estimated_peak_mb
634            } else {
635                0
636            },
637        },
638    }
639}
640
641/// The mean published benchmark score, when the model has one.
642fn measured_quality(m: &ModelSchema) -> Option<f32> {
643    if m.public_benchmarks.is_empty() {
644        return None;
645    }
646    let sum: f64 = m.public_benchmarks.iter().map(|b| b.score).sum();
647    Some((sum / m.public_benchmarks.len() as f64).clamp(0.0, 1.0) as f32)
648}
649
650/// What the size curve is scaled to: the best measured local score among the
651/// candidates, or 1.0 when none is measured.
652///
653/// The curve has to stay below what a real score reaches, so that a measured
654/// model outranks an unmeasured larger one (see
655/// [`unmeasured_larger_candidates`]). A fixed curve only did that while scores
656/// sat on one scale: when the judged suite got harder the best local score
657/// fell to about 0.66, and an unmeasured 31B's 0.82 from the curve outranked
658/// every measurement.
659fn size_prior_ceiling(models: &[&ModelSchema]) -> f32 {
660    models
661        .iter()
662        .filter(|m| m.is_local())
663        .filter_map(|m| measured_quality(m))
664        .reduce(f32::max)
665        .unwrap_or(1.0)
666}
667
668/// 0.0–1.0 quality prior: published benchmarks when present, else a
669/// param-count heuristic (bigger ⇒ generally more capable, with diminishing
670/// returns) scaled to `ceiling`.
671fn quality_score(m: &ModelSchema, ceiling: f32) -> f32 {
672    if let Some(measured) = measured_quality(m) {
673        return measured;
674    }
675    // Quality tracks *total* params (a 30B MoE has 30B-class knowledge even
676    // with 3B active). Saturating: 0.6B≈0.08, 4B≈0.36, 8B≈0.53, 30B≈0.81,
677    // before scaling.
678    let b = param_billions_total(m).max(0.1);
679    ((b / (b + 7.0)) * ceiling).clamp(0.0, 1.0)
680}
681
682/// 0.0–1.0 latency prior: smaller + better-accelerated ⇒ faster ⇒ higher.
683/// Uses *active* params — a 30B MoE with 3B active runs at 3B-ish speed.
684fn latency_score(m: &ModelSchema, accel: &SupportedAcceleration) -> f32 {
685    let b = param_billions_active(m).max(0.1);
686    // Smaller models score higher; 0.6B≈0.93, 4B≈0.6, 8B≈0.43, 30B≈0.14.
687    let size_term = 8.0 / (b + 8.0);
688    let accel_bonus = match accel {
689        SupportedAcceleration::Apple { .. } | SupportedAcceleration::Cuda { .. } => 0.1,
690        _ => 0.0,
691    };
692    (size_term + accel_bonus).clamp(0.0, 1.0)
693}
694
695/// Fraction of the memory budget this model consumes (0.0–1.0+). Used
696/// inverted in scoring so leaner picks win under memory-pressure weight.
697fn memory_pressure(estimate: &crate::resource_policy::ModelMemoryEstimate, budget: u64) -> f32 {
698    if budget == 0 {
699        return 1.0;
700    }
701    (estimate.estimated_peak_mb as f32 / budget as f32).clamp(0.0, 1.5)
702}
703
704/// Local memory fit verdict for this model on this machine.
705fn fit_status(
706    m: &ModelSchema,
707    hw: &HardwareInfo,
708    estimate: &crate::resource_policy::ModelMemoryEstimate,
709    memory_limits: &RecommendationMemoryLimits,
710) -> FitStatus {
711    // Explicitly external servers and remote/delegated models own their own
712    // memory. ManagedVllmMlx is CAR-owned and must pass the same local budget
713    // check as an in-process model; endpoint spelling never changes ownership.
714    if m.is_remote() || m.is_delegated() {
715        return FitStatus::ServerProvided;
716    }
717    // OS-provided implementations own their weights and memory, so the lack of
718    // a catalog size is affirmative ownership evidence rather than an unknown
719    // local-model estimate.
720    if m.is_os_provided() {
721        return FitStatus::Fits;
722    }
723    if m.size_mb() == 0 && m.ram_mb() == 0 {
724        return FitStatus::Unknown;
725    }
726    let required_mb = estimate.estimated_peak_mb;
727    let fits = if let Some(host_budget) = memory_limits.policy_host_budget_mb {
728        match hw.supported_acceleration() {
729            SupportedAcceleration::Cuda {
730                device_memory_mb: Some(device_memory_mb),
731            } => {
732                let host_required_mb = estimate
733                    .estimated_peak_mb
734                    .saturating_sub(estimate.weights_mb);
735                estimate.weights_mb <= device_memory_mb && host_required_mb <= host_budget
736            }
737            SupportedAcceleration::Cuda {
738                device_memory_mb: None,
739            } => return FitStatus::Unknown,
740            _ => required_mb <= host_budget,
741        }
742    } else {
743        required_mb <= memory_limits.legacy_budget_mb
744    };
745    if fits {
746        FitStatus::Fits
747    } else {
748        FitStatus::TooBig
749    }
750}
751
752/// Resident memory this model needs: weights + KV cache at a typical context
753/// + backend overhead.
754/// Memory available for a model after OS reserve, by acceleration tier.
755pub(crate) fn memory_budget_mb(hw: &HardwareInfo) -> u64 {
756    match hw.supported_acceleration() {
757        SupportedAcceleration::Apple { unified_memory_mb } => {
758            unified_memory_mb.saturating_sub(OS_RESERVE_MB)
759        }
760        SupportedAcceleration::Cuda { device_memory_mb } => {
761            device_memory_mb.unwrap_or(hw.total_ram_mb)
762        }
763        // CPU and unsupported-discrete both run from system RAM.
764        _ => hw.total_ram_mb.saturating_sub(OS_RESERVE_MB),
765    }
766}
767
768/// Total parameter count in billions from `param_count` ("4B", "30B (3B
769/// active)" → 30). Drives the *quality* prior. When `param_count` is blank
770/// (common for under-curated entries), estimate from on-disk size rather
771/// than treating the model as 0B — a 4-bit GGUF is ~0.6 GB per B params.
772fn param_billions_total(m: &ModelSchema) -> f32 {
773    model_parameter_billions_total(m)
774}
775
776/// Active parameter count in billions — the "(N active)" hint for MoE models,
777/// falling back to total for dense models. Drives *latency* and KV sizing.
778fn param_billions_active(m: &ModelSchema) -> f32 {
779    model_parameter_billions_active(m)
780}
781
782/// Plain-language rationale from the winning factors. Fixed templates, no
783/// free-form prose, so copy stays consistent and translatable.
784fn rationale(
785    m: &ModelSchema,
786    hw: &HardwareInfo,
787    use_case: UseCase,
788    tier: QualityTier,
789    fit: FitStatus,
790    quality: f32,
791) -> String {
792    let purpose = use_case_purpose(use_case);
793    let machine = match hw.supported_acceleration() {
794        SupportedAcceleration::Apple { unified_memory_mb } => {
795            format!(
796                "your {} GB Apple Silicon Mac (Metal)",
797                unified_memory_mb / 1024
798            )
799        }
800        SupportedAcceleration::Cuda { device_memory_mb } => match device_memory_mb {
801            Some(mb) => format!("your {} GB NVIDIA GPU (CUDA)", mb / 1024),
802            None => "your NVIDIA GPU (CUDA)".to_string(),
803        },
804        SupportedAcceleration::UnsupportedDiscreteGpu { .. } | SupportedAcceleration::Cpu => {
805            format!("your {} GB machine (CPU)", hw.total_ram_mb / 1024)
806        }
807    };
808
809    match fit {
810        FitStatus::ServerProvided
811            if matches!(&m.source, crate::schema::ModelSource::VllmMlx { .. }) =>
812        {
813            format!(
814                "{}: external server for {} — its operator runs the model, nothing to download",
815                m.name, purpose
816            )
817        }
818        FitStatus::ServerProvided if m.is_remote() => format!(
819            "{}: cloud model for {} — runs on Parslee's servers, nothing to download",
820            m.name, purpose
821        ),
822        FitStatus::ServerProvided => format!(
823            "{}: served externally for {} — no local memory needed",
824            m.name, purpose
825        ),
826        _ => {
827            let tier_word = match tier {
828                QualityTier::Fastest => "fastest",
829                QualityTier::Balanced => "best-balanced",
830                QualityTier::MostCapable => "most capable",
831            };
832            let quality_note = if quality >= 0.7 { "high-quality " } else { "" };
833            let size = if m.size_mb() >= 1024 {
834                format!("{:.1} GB download", m.size_mb() as f64 / 1024.0)
835            } else {
836                format!("{} MB download", m.size_mb())
837            };
838            format!(
839                "{}: the {} {}{} model that fits {} ({})",
840                m.name, tier_word, quality_note, purpose, machine, size
841            )
842        }
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::hardware::{GpuBackend, GpuDevice, GpuVendor};
850    use crate::resource_policy::estimate_model_memory;
851    use crate::schema::{CostModel, ModelCapability, ModelSource, PerformanceEnvelope};
852
853    pub(super) fn hw(accel_backend: GpuBackend, ram_mb: u64, gpu_mb: Option<u64>) -> HardwareInfo {
854        HardwareInfo {
855            os: "test".into(),
856            arch: "test".into(),
857            cpu_cores: 8,
858            total_ram_mb: ram_mb,
859            gpu_backend: accel_backend,
860            gpu_memory_mb: gpu_mb,
861            gpu_devices: vec![],
862            recommended_model: String::new(),
863            recommended_context: 4096,
864            max_model_mb: 0,
865        }
866    }
867
868    pub(super) fn mac(ram_gb: u64) -> HardwareInfo {
869        // Metal backend ⇒ SupportedAcceleration::Apple with unified memory.
870        hw(GpuBackend::Metal, ram_gb * 1024, None)
871    }
872
873    pub(super) fn local_model(id: &str, name: &str, params: &str, size_mb: u64) -> ModelSchema {
874        ModelSchema {
875            id: id.into(),
876            name: name.into(),
877            provider: "qwen".into(),
878            family: "qwen3".into(),
879            version: String::new(),
880            capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
881            context_length: 32768,
882            max_output_tokens: None,
883            param_count: params.into(),
884            quantization: Some(crate::schema::Quantization::parse("Q4_K_M")),
885            performance: PerformanceEnvelope::default(),
886            cost: CostModel {
887                size_mb: Some(size_mb),
888                ram_mb: Some(size_mb),
889                ..Default::default()
890            },
891            source: ModelSource::Local {
892                hf_repo: "x/y".into(),
893                hf_filename: "m.gguf".into(),
894                tokenizer_repo: "x/y".into(),
895            },
896            tags: vec![],
897            supported_params: vec![],
898            public_benchmarks: vec![],
899            trust_tier: TrustTier::Curated,
900            deprecated: false,
901            available: false,
902            weights_ready: false,
903        }
904    }
905
906    fn catalog() -> Vec<ModelSchema> {
907        vec![
908            local_model("qwen/qwen3-0.6b", "Qwen3-0.6B", "0.6B", 650),
909            local_model("qwen/qwen3-4b", "Qwen3-4B", "4B", 2500),
910            local_model("qwen/qwen3-8b", "Qwen3-8B", "8B", 4900),
911            local_model("qwen/qwen3-30b", "Qwen3-30B-A3B", "30B (3B active)", 17000),
912        ]
913    }
914
915    /// The same catalog as a Mac sees it: MLX rows. GGUF rows never run on
916    /// Apple Silicon — see [`platform_compatible`].
917    fn mac_catalog() -> Vec<ModelSchema> {
918        catalog().into_iter().map(as_mlx).collect()
919    }
920
921    fn as_mlx(mut m: ModelSchema) -> ModelSchema {
922        let name =
923            m.id.rsplit('/')
924                .next()
925                .expect("rsplit yields one")
926                .to_string();
927        m.id = format!("mlx/{name}");
928        m.source = ModelSource::Mlx {
929            hf_repo: format!("mlx-community/{}", m.name),
930            hf_weight_file: None,
931        };
932        m
933    }
934
935    fn qwen_mlx_policy_catalog() -> Vec<ModelSchema> {
936        let mut four = local_model("mlx/qwen3-4b:4bit", "Qwen3-4B-MLX", "4B", 2400);
937        four.source = ModelSource::Mlx {
938            hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
939            hf_weight_file: None,
940        };
941        four.capabilities = vec![
942            ModelCapability::Generate,
943            ModelCapability::Code,
944            ModelCapability::ToolUse,
945        ];
946        four.performance.latency_p50_ms = Some(294);
947
948        let mut eight = local_model("mlx/qwen3-8b:4bit", "Qwen3-8B-MLX", "8B", 4800);
949        eight.source = ModelSource::Mlx {
950            hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
951            hf_weight_file: None,
952        };
953        eight.capabilities = vec![
954            ModelCapability::Generate,
955            ModelCapability::Code,
956            ModelCapability::ToolUse,
957        ];
958        eight.performance.latency_p50_ms = Some(451);
959        vec![four, eight]
960    }
961
962    fn refs(v: &[ModelSchema]) -> Vec<&ModelSchema> {
963        v.iter().collect()
964    }
965
966    #[test]
967    fn fastest_prefers_the_small_model() {
968        let cat = mac_catalog();
969        let recs = recommend(
970            &refs(&cat),
971            &mac(36),
972            UseCase::Coding,
973            QualityTier::Fastest,
974            Privacy::OnDevice,
975            &CheckpointGeometry::default(),
976        )
977        .picks;
978        assert_eq!(recs[0].display_name, "Qwen3-0.6B");
979    }
980
981    #[test]
982    fn downloadable_catalog_entry_is_not_installed_until_weights_are_ready() {
983        let mut model = qwen_mlx_policy_catalog().remove(0);
984        model.available = true;
985        model.weights_ready = false;
986
987        let set = recommend(
988            &[&model],
989            &mac(32),
990            UseCase::Assistant,
991            QualityTier::Balanced,
992            Privacy::OnDevice,
993            &CheckpointGeometry::default(),
994        );
995
996        assert_eq!(set.picks.len(), 1);
997        assert!(!set.picks[0].already_installed);
998    }
999
1000    /// On a 64 GB Mac under `everyday`, the 35B-A3B vLLM-MLX row fits: its
1001    /// weights are its measured 19.5 GB, not 24 GB, and three car-judged
1002    /// suites ran in about 20.3 GB (car#3532, car#3536). The set names the
1003    /// policy ceiling each verdict was judged against, and each row its peak.
1004    #[test]
1005    fn the_35b_a3b_row_fits_a_64gb_mac_under_everyday() {
1006        let row = crate::registry::builtin_catalog()
1007            .into_iter()
1008            .find(|m| m.id == "vllm-mlx/qwen3.6-35b-a3b:4bit")
1009            .expect("catalog row");
1010        let policy = crate::resource_policy::ResourcePolicy::everyday();
1011        let ceiling = policy
1012            .effective_budget(64 * 1024)
1013            .configured_model_ceiling_mb;
1014        let peak =
1015            estimate_model_memory(&row, &mac(64), RECOMMENDATION_CONTEXT_TOKENS).estimated_peak_mb;
1016        assert!(
1017            peak <= ceiling,
1018            "peak {peak} MB over the {ceiling} MB ceiling"
1019        );
1020        assert!(
1021            peak >= 20_300,
1022            "peak {peak} MB under the measured residency"
1023        );
1024        let set = recommend_with_policy(
1025            &refs(&[row]),
1026            &mac(64),
1027            &policy,
1028            UseCase::Coding,
1029            QualityTier::MostCapable,
1030            Privacy::OnDevice,
1031            &CheckpointGeometry::default(),
1032        );
1033        assert!(
1034            set.not_enough_memory.is_empty(),
1035            "{:?}",
1036            set.not_enough_memory
1037        );
1038        assert_eq!(set.memory_ceiling_mb, Some(ceiling));
1039        assert!(
1040            set.picks
1041                .iter()
1042                .any(|pick| pick.model_id == "vllm-mlx/qwen3.6-35b-a3b:4bit"
1043                    && pick.estimated_peak_mb == peak),
1044            "{:?}",
1045            set.picks
1046        );
1047    }
1048
1049    #[test]
1050    fn everyday_32gb_apple_assistant_balanced_prefers_four_b_and_keeps_eight_b() {
1051        let mut catalog = qwen_mlx_policy_catalog();
1052        let mut no_tools = local_model("mlx/qwen3-1.7b:3bit", "Qwen3-1.7B-MLX", "1.7B", 900);
1053        no_tools.source = ModelSource::Mlx {
1054            hf_repo: "mlx-community/Qwen3-1.7B-3bit".into(),
1055            hf_weight_file: None,
1056        };
1057        no_tools.capabilities = vec![ModelCapability::Generate];
1058        catalog.push(no_tools);
1059        let set = recommend_with_policy(
1060            &refs(&catalog),
1061            &mac(32),
1062            &crate::resource_policy::ResourcePolicy::everyday(),
1063            UseCase::Assistant,
1064            QualityTier::Balanced,
1065            Privacy::OnDevice,
1066            &CheckpointGeometry::default(),
1067        );
1068
1069        let ids: Vec<&str> = set
1070            .picks
1071            .iter()
1072            .map(|pick| pick.model_id.as_str())
1073            .collect();
1074        assert_eq!(ids, ["mlx/qwen3-4b:4bit", "mlx/qwen3-8b:4bit"]);
1075        let budget = crate::resource_policy::ResourcePolicy::everyday().effective_budget(32 * 1024);
1076        for model in &catalog[..2] {
1077            assert!(
1078                estimate_model_memory(model, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
1079                    .estimated_peak_mb
1080                    < budget.configured_model_ceiling_mb
1081            );
1082        }
1083    }
1084
1085    #[test]
1086    fn everyday_recommends_up_to_its_full_ceiling() {
1087        let mut four = qwen_mlx_policy_catalog().remove(0);
1088        four.cost.ram_mb = Some(3_500);
1089        four.cost.size_mb = Some(3_500);
1090        let mut nine = four.clone();
1091        nine.id = "mlx/qwen3-9b:4bit".into();
1092        nine.name = "Qwen3-9B-MLX".into();
1093        nine.param_count = "9B".into();
1094        nine.cost.ram_mb = Some(9_000);
1095        nine.cost.size_mb = Some(9_000);
1096        nine.weights_ready = true;
1097        nine.public_benchmarks = vec![crate::schema::BenchmarkScore {
1098            name: "quality".into(),
1099            score: 0.99,
1100            harness: None,
1101            source_url: None,
1102            measured_at: None,
1103            runs: None,
1104            spread: None,
1105        }];
1106        let catalog = vec![nine, four];
1107
1108        let set = recommend_with_policy(
1109            &refs(&catalog),
1110            &mac(32),
1111            &ResourcePolicy::everyday(),
1112            UseCase::Assistant,
1113            QualityTier::Balanced,
1114            Privacy::OnDevice,
1115            &CheckpointGeometry::default(),
1116        );
1117
1118        // A 9 GB model on a 32 GB Mac fits Everyday's 40% ceiling (13.1 GB)
1119        // and is offered automatically; under the old half-ceiling target
1120        // (6.5 GB) it was visible but never selected. Inside the target, its
1121        // higher quality ranks it first.
1122        assert_eq!(set.picks[0].model_id, "mlx/qwen3-9b:4bit");
1123        assert_eq!(set.picks[1].model_id, "mlx/qwen3-4b:4bit");
1124        assert!(set.picks[0].within_recommendation_target);
1125        assert!(set.picks[1].within_recommendation_target);
1126
1127        let only_heavy = recommend_with_policy(
1128            &[&catalog[0]],
1129            &mac(32),
1130            &ResourcePolicy::everyday(),
1131            UseCase::Assistant,
1132            QualityTier::Balanced,
1133            Privacy::OnDevice,
1134            &CheckpointGeometry::default(),
1135        );
1136        assert_eq!(only_heavy.picks[0].fit, FitStatus::Fits);
1137        assert!(only_heavy.picks[0].within_recommendation_target);
1138    }
1139
1140    #[test]
1141    fn everyday_ranking_is_permutation_stable_across_local_and_cloud_candidates() {
1142        let mut four = qwen_mlx_policy_catalog().remove(0);
1143        four.cost.ram_mb = Some(3_500);
1144        four.cost.size_mb = Some(3_500);
1145
1146        let mut nine = four.clone();
1147        nine.id = "mlx/qwen3-9b:4bit".into();
1148        nine.name = "Qwen3-9B-MLX".into();
1149        nine.param_count = "9B".into();
1150        nine.cost.ram_mb = Some(9_000);
1151        nine.cost.size_mb = Some(9_000);
1152        nine.public_benchmarks = vec![crate::schema::BenchmarkScore {
1153            name: "quality".into(),
1154            score: 0.99,
1155            harness: None,
1156            source_url: None,
1157            measured_at: None,
1158            runs: None,
1159            spread: None,
1160        }];
1161
1162        let mut cloud = four.clone();
1163        cloud.id = "remote/tool-use".into();
1164        cloud.name = "ToolUse Cloud".into();
1165        cloud.source = ModelSource::RemoteApi {
1166            endpoint: "https://example.invalid".into(),
1167            api_key_env: "TEST_KEY".into(),
1168            api_key_envs: vec![],
1169            api_version: None,
1170            protocol: crate::schema::ApiProtocol::OpenAiCompat,
1171        };
1172        cloud.cost.ram_mb = None;
1173        cloud.cost.size_mb = None;
1174        cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1175            name: "quality".into(),
1176            score: 1.0,
1177            harness: None,
1178            source_url: None,
1179            measured_at: None,
1180            runs: None,
1181            spread: None,
1182        }];
1183
1184        let candidates = [four, nine, cloud];
1185        let permutations = [
1186            [0, 1, 2],
1187            [0, 2, 1],
1188            [1, 0, 2],
1189            [1, 2, 0],
1190            [2, 0, 1],
1191            [2, 1, 0],
1192        ];
1193        let expected = ["mlx/qwen3-4b:4bit", "mlx/qwen3-9b:4bit", "remote/tool-use"];
1194        for permutation in permutations {
1195            let catalog: Vec<ModelSchema> = permutation
1196                .into_iter()
1197                .map(|index| candidates[index].clone())
1198                .collect();
1199            let set = recommend_with_policy(
1200                &refs(&catalog),
1201                &mac(32),
1202                &ResourcePolicy::everyday(),
1203                UseCase::Assistant,
1204                QualityTier::Balanced,
1205                Privacy::CloudOk,
1206                &CheckpointGeometry::default(),
1207            );
1208            let actual: Vec<&str> = set
1209                .picks
1210                .iter()
1211                .map(|pick| pick.model_id.as_str())
1212                .collect();
1213            assert_eq!(actual, expected, "permutation {permutation:?}");
1214        }
1215    }
1216
1217    #[test]
1218    fn cuda_policy_checks_gpu_weights_and_host_overhead_as_separate_pools() {
1219        let mut model = qwen_mlx_policy_catalog().remove(0);
1220        model.source = ModelSource::Local {
1221            hf_repo: "x/y".into(),
1222            hf_filename: "m.gguf".into(),
1223            tokenizer_repo: "x/y".into(),
1224        };
1225
1226        model.cost.ram_mb = Some(1_000);
1227        model.cost.size_mb = Some(1_000);
1228        let vram_too_small = recommend_with_policy(
1229            &[&model],
1230            &hw(GpuBackend::Cuda, 64 * 1024, Some(900)),
1231            &ResourcePolicy::everyday(),
1232            UseCase::Assistant,
1233            QualityTier::Balanced,
1234            Privacy::OnDevice,
1235            &CheckpointGeometry::default(),
1236        );
1237        assert_eq!(vram_too_small.not_enough_memory[0].fit, FitStatus::TooBig);
1238
1239        model.cost.ram_mb = Some(5_000);
1240        model.cost.size_mb = Some(5_000);
1241        // The CUDA weights use the 16 GB VRAM pool. Unknown geometry keeps
1242        // 2,688 MB of host overhead (512 runtime + 1,152 context + 1,024
1243        // transient), below a 16 GB Everyday target of 3,276 MB.
1244        let separate_pools_fit = recommend_with_policy(
1245            &[&model],
1246            &hw(GpuBackend::Cuda, 16 * 1024, Some(16 * 1024)),
1247            &ResourcePolicy::everyday(),
1248            UseCase::Assistant,
1249            QualityTier::Balanced,
1250            Privacy::OnDevice,
1251            &CheckpointGeometry::default(),
1252        );
1253        assert_eq!(separate_pools_fit.picks[0].fit, FitStatus::Fits);
1254        assert!(separate_pools_fit.picks[0].within_recommendation_target);
1255    }
1256
1257    #[test]
1258    fn custom_zero_blocks_automatic_and_explicit_local_fit() {
1259        let model = qwen_mlx_policy_catalog().remove(0);
1260        let set = recommend_with_policy(
1261            &[&model],
1262            &mac(32),
1263            &ResourcePolicy::custom_gb(0.0).unwrap(),
1264            UseCase::Assistant,
1265            QualityTier::Balanced,
1266            Privacy::OnDevice,
1267            &CheckpointGeometry::default(),
1268        );
1269
1270        assert!(set.picks.is_empty());
1271        assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1272        assert!(!set.not_enough_memory[0].within_recommendation_target);
1273    }
1274
1275    #[test]
1276    fn local_focused_uses_its_full_configured_ceiling() {
1277        let mut model = qwen_mlx_policy_catalog().remove(0);
1278        // 10,400 weights + 512 runtime + 1,152 context + 1,024 transient =
1279        // 13,088 MB: below Local Focused's full 13,107 MB ceiling, but well
1280        // above Everyday's 6,553 MB ceiling on this 16 GB fixture.
1281        model.cost.ram_mb = Some(10_400);
1282        model.cost.size_mb = Some(10_400);
1283        let set = recommend_with_policy(
1284            &[&model],
1285            &mac(16),
1286            &ResourcePolicy::local_focused(),
1287            UseCase::Assistant,
1288            QualityTier::Balanced,
1289            Privacy::OnDevice,
1290            &CheckpointGeometry::default(),
1291        );
1292
1293        assert_eq!(set.picks[0].fit, FitStatus::Fits);
1294        assert!(set.picks[0].within_recommendation_target);
1295    }
1296
1297    #[test]
1298    fn unknown_memory_never_outranks_a_known_fit() {
1299        let mut known = qwen_mlx_policy_catalog().remove(0);
1300        known.public_benchmarks.clear();
1301        let mut unknown = known.clone();
1302        unknown.id = "local/unknown-memory".into();
1303        unknown.name = "Unknown Memory".into();
1304        unknown.param_count.clear();
1305        unknown.cost.ram_mb = None;
1306        unknown.cost.size_mb = None;
1307        unknown.public_benchmarks = vec![crate::schema::BenchmarkScore {
1308            name: "quality".into(),
1309            score: 1.0,
1310            harness: None,
1311            source_url: None,
1312            measured_at: None,
1313            runs: None,
1314            spread: None,
1315        }];
1316
1317        let set = recommend_with_policy(
1318            &refs(&[unknown, known]),
1319            &mac(32),
1320            &ResourcePolicy::everyday(),
1321            UseCase::Assistant,
1322            QualityTier::Balanced,
1323            Privacy::OnDevice,
1324            &CheckpointGeometry::default(),
1325        );
1326        assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1327        assert_eq!(set.picks[1].fit, FitStatus::Unknown);
1328        assert!(!set.picks[1].within_recommendation_target);
1329    }
1330
1331    #[test]
1332    fn everyday_assistant_tool_floor_excludes_generate_only_cloud_models() {
1333        let mut local = qwen_mlx_policy_catalog().remove(0);
1334        local.public_benchmarks.clear();
1335        let mut cloud = local.clone();
1336        cloud.id = "remote/high-score-generate-only".into();
1337        cloud.name = "Remote Generate Only".into();
1338        cloud.source = ModelSource::RemoteApi {
1339            endpoint: "https://api".into(),
1340            api_key_env: "K".into(),
1341            api_key_envs: vec![],
1342            api_version: None,
1343            protocol: crate::schema::ApiProtocol::OpenAiCompat,
1344        };
1345        cloud.capabilities = vec![ModelCapability::Generate];
1346        cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1347            name: "quality".into(),
1348            score: 1.0,
1349            harness: None,
1350            source_url: None,
1351            measured_at: None,
1352            runs: None,
1353            spread: None,
1354        }];
1355
1356        let set = recommend_with_policy(
1357            &refs(&[cloud, local]),
1358            &mac(32),
1359            &ResourcePolicy::everyday(),
1360            UseCase::Assistant,
1361            QualityTier::Balanced,
1362            Privacy::CloudOk,
1363            &CheckpointGeometry::default(),
1364        );
1365        assert_eq!(set.picks.len(), 1);
1366        assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1367    }
1368
1369    /// The assistant tool floor is a property of the use case, not of the tier
1370    /// or the entry point.
1371    ///
1372    /// Positive control: with the requirement welded to
1373    /// `everyday_assistant_balanced`, every assertion below except the
1374    /// `Balanced` one fails — `Fastest` and `MostCapable` returned the
1375    /// generate-only model, and `recommend` (no policy) returned it at every
1376    /// tier. That is car#1822: `--tier fastest` ranked Qwen3-0.6B first and
1377    /// told the reader to install it with `car setup --for assistant`, while
1378    /// `car do` refuses a model without `ToolUse` and substitutes one.
1379    #[test]
1380    fn the_assistant_tool_floor_holds_at_every_tier_and_entry_point() {
1381        let mut tool_capable = as_mlx(local_model("mlx/tools-4b", "Tools-4B", "4B", 2500));
1382        tool_capable.capabilities.push(ModelCapability::ToolUse);
1383        // `local_model` is Generate+Code, so this one is the excluded shape.
1384        let generate_only = as_mlx(local_model("mlx/chat-0.6b", "Chat-0.6B", "0.6B", 650));
1385        let models = [tool_capable, generate_only];
1386
1387        for tier in [
1388            QualityTier::Fastest,
1389            QualityTier::Balanced,
1390            QualityTier::MostCapable,
1391        ] {
1392            for with_policy in [false, true] {
1393                let set = if with_policy {
1394                    recommend_with_policy(
1395                        &refs(&models),
1396                        &mac(32),
1397                        &ResourcePolicy::everyday(),
1398                        UseCase::Assistant,
1399                        tier,
1400                        Privacy::OnDevice,
1401                        &CheckpointGeometry::default(),
1402                    )
1403                } else {
1404                    recommend(
1405                        &refs(&models),
1406                        &mac(32),
1407                        UseCase::Assistant,
1408                        tier,
1409                        Privacy::OnDevice,
1410                        &CheckpointGeometry::default(),
1411                    )
1412                };
1413                let ids: Vec<&str> = set.picks.iter().map(|p| p.model_id.as_str()).collect();
1414                assert_eq!(
1415                    ids,
1416                    vec!["mlx/tools-4b"],
1417                    "tier {tier:?}, with_policy {with_policy}: a model without ToolUse \
1418                     cannot serve the assistant"
1419                );
1420            }
1421        }
1422
1423        // Control: the floor is scoped to Assistant. Coding ranks both, so the
1424        // assertion above is about the use case and not about the catalog.
1425        let coding = recommend(
1426            &refs(&models),
1427            &mac(32),
1428            UseCase::Coding,
1429            QualityTier::Fastest,
1430            Privacy::OnDevice,
1431            &CheckpointGeometry::default(),
1432        );
1433        assert_eq!(
1434            coding.picks.len(),
1435            2,
1436            "coding has no tool floor; both candidates should rank"
1437        );
1438    }
1439
1440    #[test]
1441    fn policy_entry_point_preserves_legacy_order_among_policy_eligible_candidates() {
1442        let catalog = mac_catalog();
1443        for (policy, use_case, tier) in [
1444            (
1445                ResourcePolicy::everyday(),
1446                UseCase::Assistant,
1447                QualityTier::Fastest,
1448            ),
1449            (
1450                ResourcePolicy::everyday(),
1451                UseCase::Assistant,
1452                QualityTier::MostCapable,
1453            ),
1454            (
1455                ResourcePolicy::everyday(),
1456                UseCase::Coding,
1457                QualityTier::Balanced,
1458            ),
1459            (
1460                ResourcePolicy::local_focused(),
1461                UseCase::Assistant,
1462                QualityTier::Balanced,
1463            ),
1464        ] {
1465            let legacy = recommend(
1466                &refs(&catalog),
1467                &mac(36),
1468                use_case,
1469                tier,
1470                Privacy::OnDevice,
1471                &CheckpointGeometry::default(),
1472            );
1473            let policy_aware = recommend_with_policy(
1474                &refs(&catalog),
1475                &mac(36),
1476                &policy,
1477                use_case,
1478                tier,
1479                Privacy::OnDevice,
1480                &CheckpointGeometry::default(),
1481            );
1482            let legacy_common: Vec<&str> = legacy
1483                .picks
1484                .iter()
1485                .filter(|pick| {
1486                    policy_aware
1487                        .picks
1488                        .iter()
1489                        .any(|candidate| candidate.model_id == pick.model_id)
1490                })
1491                .map(|pick| pick.model_id.as_str())
1492                .collect();
1493            let policy_common: Vec<&str> = policy_aware
1494                .picks
1495                .iter()
1496                .filter(|pick| {
1497                    legacy
1498                        .picks
1499                        .iter()
1500                        .any(|candidate| candidate.model_id == pick.model_id)
1501                })
1502                .map(|pick| pick.model_id.as_str())
1503                .collect();
1504            assert_eq!(
1505                policy_common, legacy_common,
1506                "{policy:?} {use_case:?} {tier:?}"
1507            );
1508        }
1509    }
1510
1511    #[test]
1512    fn most_capable_prefers_the_big_model_when_it_fits() {
1513        let cat = mac_catalog();
1514        let recs = recommend(
1515            &refs(&cat),
1516            &mac(36), // 36 GB Mac fits the 17 GB model
1517            UseCase::Coding,
1518            QualityTier::MostCapable,
1519            Privacy::OnDevice,
1520            &CheckpointGeometry::default(),
1521        )
1522        .picks;
1523        assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1524        assert_eq!(recs[0].fit, FitStatus::Fits);
1525    }
1526
1527    #[test]
1528    fn too_big_models_are_excluded_on_small_machines() {
1529        let cat = mac_catalog();
1530        let recs = recommend(
1531            &refs(&cat),
1532            &mac(8), // 8 GB: 17 GB and 8 GB models shouldn't be offered
1533            UseCase::Coding,
1534            QualityTier::MostCapable,
1535            Privacy::OnDevice,
1536            &CheckpointGeometry::default(),
1537        )
1538        .picks;
1539        let names: Vec<&str> = recs.iter().map(|r| r.display_name.as_str()).collect();
1540        assert!(!names.contains(&"Qwen3-30B-A3B"), "30B must not fit 8GB");
1541        assert!(recs.iter().all(|r| r.fit == FitStatus::Fits));
1542        assert!(!recs.is_empty(), "the 0.6B model should still be offered");
1543    }
1544
1545    #[test]
1546    fn balanced_picks_a_capable_model_that_fits() {
1547        let cat = mac_catalog();
1548        let recs = recommend(
1549            &refs(&cat),
1550            &mac(16),
1551            UseCase::Coding,
1552            QualityTier::Balanced,
1553            Privacy::OnDevice,
1554            &CheckpointGeometry::default(),
1555        )
1556        .picks;
1557        // On a 16 GB Mac, Balanced should land on a mid model, not the 0.6B
1558        // and not necessarily the largest.
1559        assert!(matches!(
1560            recs[0].display_name.as_str(),
1561            "Qwen3-4B" | "Qwen3-8B"
1562        ));
1563    }
1564
1565    /// On Apple Silicon a GGUF row never runs — a request for it executes as
1566    /// its MLX twin — so it is not offered there, and each Qwen3 appears once.
1567    /// Everywhere else the GGUF row is the local model.
1568    #[test]
1569    fn gguf_rows_are_offered_everywhere_but_apple_silicon() {
1570        let gguf = catalog();
1571        let both: Vec<ModelSchema> = gguf.iter().cloned().chain(mac_catalog()).collect();
1572        let on_mac = recommend(
1573            &refs(&both),
1574            &mac(64),
1575            UseCase::Coding,
1576            QualityTier::Balanced,
1577            Privacy::OnDevice,
1578            &CheckpointGeometry::default(),
1579        );
1580        assert!(!on_mac.picks.is_empty());
1581        assert!(on_mac
1582            .picks
1583            .iter()
1584            .chain(&on_mac.not_enough_memory)
1585            .all(|p| !gguf.iter().any(|g| g.id == p.model_id)));
1586        let cuda = hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024));
1587        let on_cuda = recommend(
1588            &refs(&both),
1589            &cuda,
1590            UseCase::Coding,
1591            QualityTier::Balanced,
1592            Privacy::OnDevice,
1593            &CheckpointGeometry::default(),
1594        );
1595        assert!(!on_cuda.picks.is_empty());
1596        assert!(on_cuda
1597            .picks
1598            .iter()
1599            .all(|p| gguf.iter().any(|g| g.id == p.model_id)));
1600        assert!(!platform_compatible(&gguf[0], &mac(64)));
1601        assert!(platform_compatible(&gguf[0], &cuda));
1602    }
1603
1604    #[test]
1605    fn search_only_returns_embedding_models() {
1606        let mut cat = mac_catalog();
1607        let mut embed = as_mlx(local_model("qwen/embed", "Qwen3-Embedding", "0.6B", 640));
1608        embed.capabilities = vec![ModelCapability::Embed];
1609        cat.push(embed);
1610        let recs = recommend(
1611            &refs(&cat),
1612            &mac(16),
1613            UseCase::Search,
1614            QualityTier::Balanced,
1615            Privacy::OnDevice,
1616            &CheckpointGeometry::default(),
1617        )
1618        .picks;
1619        assert_eq!(recs.len(), 1, "only the embed model is in the Search lane");
1620        assert_eq!(recs[0].display_name, "Qwen3-Embedding");
1621        assert_eq!(recs[0].role, UseCaseRole::Retrieval);
1622    }
1623
1624    #[test]
1625    fn deprecated_models_are_never_recommended() {
1626        let mut cat = mac_catalog();
1627        cat[1].deprecated = true; // deprecate the 4B
1628        let recs = recommend(
1629            &refs(&cat),
1630            &mac(16),
1631            UseCase::Coding,
1632            QualityTier::Balanced,
1633            Privacy::OnDevice,
1634            &CheckpointGeometry::default(),
1635        )
1636        .picks;
1637        assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B"));
1638    }
1639
1640    #[test]
1641    fn on_device_excludes_cloud_but_cloud_ok_includes_it_with_consent() {
1642        let mut cat = mac_catalog();
1643        let mut cloud = local_model("anthropic/sonnet", "Claude Sonnet", "", 0);
1644        cloud.capabilities = vec![ModelCapability::Generate, ModelCapability::Code];
1645        cloud.source = ModelSource::RemoteApi {
1646            endpoint: "https://api".into(),
1647            api_key_env: "K".into(),
1648            api_key_envs: vec![],
1649            api_version: None,
1650            protocol: crate::schema::ApiProtocol::Anthropic,
1651        };
1652        cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1653            name: "SWE-bench".into(),
1654            score: 0.7,
1655            harness: None,
1656            source_url: None,
1657            measured_at: None,
1658            runs: None,
1659            spread: None,
1660        }];
1661        cat.push(cloud);
1662
1663        let on_device = recommend(
1664            &refs(&cat),
1665            &mac(16),
1666            UseCase::Coding,
1667            QualityTier::MostCapable,
1668            Privacy::OnDevice,
1669            &CheckpointGeometry::default(),
1670        )
1671        .picks;
1672        assert!(on_device.iter().all(|r| r.is_local));
1673
1674        let cloud_ok = recommend(
1675            &refs(&cat),
1676            &mac(16),
1677            UseCase::Coding,
1678            QualityTier::MostCapable,
1679            Privacy::CloudOk,
1680            &CheckpointGeometry::default(),
1681        )
1682        .picks;
1683        let claude = cloud_ok
1684            .iter()
1685            .find(|r| r.display_name == "Claude Sonnet")
1686            .expect("cloud model eligible under CloudOk");
1687        assert!(claude.requires_cloud_consent);
1688        assert_eq!(claude.fit, FitStatus::ServerProvided);
1689    }
1690
1691    #[test]
1692    fn metal_only_model_excluded_on_cpu_host() {
1693        let mut cat = catalog();
1694        let mut mlx = local_model("mlx/qwen3-4b", "Qwen3-4B-MLX", "4B", 2400);
1695        mlx.source = ModelSource::Mlx {
1696            hf_repo: "mlx-community/x".into(),
1697            hf_weight_file: None,
1698        };
1699        cat.push(mlx);
1700        // CPU-only Linux box.
1701        let recs = recommend(
1702            &refs(&cat),
1703            &hw(GpuBackend::Cpu, 32 * 1024, None),
1704            UseCase::Coding,
1705            QualityTier::Balanced,
1706            Privacy::OnDevice,
1707            &CheckpointGeometry::default(),
1708        )
1709        .picks;
1710        assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B-MLX"));
1711    }
1712
1713    #[test]
1714    fn ranking_is_deterministic() {
1715        let cat = mac_catalog();
1716        let a = recommend(
1717            &refs(&cat),
1718            &mac(16),
1719            UseCase::Assistant,
1720            QualityTier::Balanced,
1721            Privacy::OnDevice,
1722            &CheckpointGeometry::default(),
1723        );
1724        let b = recommend(
1725            &refs(&cat),
1726            &mac(16),
1727            UseCase::Assistant,
1728            QualityTier::Balanced,
1729            Privacy::OnDevice,
1730            &CheckpointGeometry::default(),
1731        );
1732        let ids_a: Vec<&str> = a.picks.iter().map(|r| r.model_id.as_str()).collect();
1733        let ids_b: Vec<&str> = b.picks.iter().map(|r| r.model_id.as_str()).collect();
1734        assert_eq!(ids_a, ids_b);
1735    }
1736
1737    #[test]
1738    fn rationale_is_plain_language_no_jargon() {
1739        let cat = mac_catalog();
1740        let recs = recommend(
1741            &refs(&cat),
1742            &mac(36),
1743            UseCase::Coding,
1744            QualityTier::Balanced,
1745            Privacy::OnDevice,
1746            &CheckpointGeometry::default(),
1747        )
1748        .picks;
1749        let r = &recs[0].rationale;
1750        assert!(!r.contains("Q4_K_M"), "no quantization jargon");
1751        assert!(!r.contains("gguf") && !r.contains("hf_repo"));
1752        assert!(r.contains("coding"), "states the purpose");
1753    }
1754
1755    #[test]
1756    fn all_too_big_surfaces_needs_more_ram_with_a_note() {
1757        // A 2 GB machine fits nothing in the catalog.
1758        let cat = catalog();
1759        let set = recommend(
1760            &refs(&cat),
1761            &hw(GpuBackend::Cpu, 2 * 1024, None),
1762            UseCase::Coding,
1763            QualityTier::Balanced,
1764            Privacy::OnDevice,
1765            &CheckpointGeometry::default(),
1766        );
1767        assert!(set.picks.is_empty(), "nothing should fit 2 GB");
1768        assert!(
1769            !set.not_enough_memory.is_empty(),
1770            "too-big models surfaced, not dropped"
1771        );
1772        let note = set.note.expect("empty picks must carry a note");
1773        assert!(note.contains("fits"), "note explains the no-fit: {note}");
1774        // Closest miss ranked first.
1775        assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1776    }
1777
1778    #[test]
1779    fn all_deprecated_gives_generic_note_not_a_memory_note() {
1780        // Deprecated models are filtered before the fit partition, so they
1781        // land in neither picks nor not_enough_memory — the note must be the
1782        // generic "no model available", not a misleading "needs more RAM".
1783        let mut cat = mac_catalog();
1784        for m in &mut cat {
1785            m.deprecated = true;
1786        }
1787        let set = recommend(
1788            &refs(&cat),
1789            &mac(36), // plenty of RAM — so a memory note would be wrong
1790            UseCase::Coding,
1791            QualityTier::Balanced,
1792            Privacy::OnDevice,
1793            &CheckpointGeometry::default(),
1794        );
1795        assert!(set.picks.is_empty());
1796        assert!(set.not_enough_memory.is_empty());
1797        let note = set.note.expect("must explain");
1798        assert!(
1799            !note.contains("fits") && !note.contains("memory"),
1800            "deprecated-only must not claim a memory problem: {note}"
1801        );
1802    }
1803
1804    #[test]
1805    fn not_enough_memory_is_ordered_deterministically() {
1806        let cat = catalog();
1807        let mk = || {
1808            recommend(
1809                &refs(&cat),
1810                &hw(GpuBackend::Cpu, 3 * 1024, None), // only the 0.6B fits
1811                UseCase::Coding,
1812                QualityTier::Balanced,
1813                Privacy::OnDevice,
1814                &CheckpointGeometry::default(),
1815            )
1816            .not_enough_memory
1817            .into_iter()
1818            .map(|r| r.model_id)
1819            .collect::<Vec<_>>()
1820        };
1821        assert!(mk().len() >= 2, "several models should be too big for 3 GB");
1822        assert_eq!(mk(), mk(), "too-big ordering must be deterministic");
1823    }
1824
1825    #[test]
1826    fn empty_registry_returns_empty_with_a_note() {
1827        let set = recommend(
1828            &[],
1829            &mac(16),
1830            UseCase::Assistant,
1831            QualityTier::Balanced,
1832            Privacy::OnDevice,
1833            &CheckpointGeometry::default(),
1834        );
1835        assert!(set.picks.is_empty());
1836        assert!(set.not_enough_memory.is_empty());
1837        assert!(set.note.is_some(), "no-model case must explain itself");
1838    }
1839
1840    #[test]
1841    fn cuda_box_sizes_against_vram() {
1842        // A 24 GB CUDA GPU fits the 17 GB model under MostCapable.
1843        let cat = catalog();
1844        let h = hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024));
1845        let recs = recommend(
1846            &refs(&cat),
1847            &h,
1848            UseCase::Coding,
1849            QualityTier::MostCapable,
1850            Privacy::OnDevice,
1851            &CheckpointGeometry::default(),
1852        )
1853        .picks;
1854        assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1855    }
1856
1857    #[test]
1858    fn unsupported_discrete_gpu_uses_system_ram_not_vram() {
1859        // A 24 GB discrete GPU CAR can't drive must NOT be used as the budget;
1860        // a 16 GB-RAM CPU host can't fit the 17 GB model despite the big card.
1861        let cat = catalog();
1862        let mut h = hw(GpuBackend::Cpu, 16 * 1024, None);
1863        h.gpu_devices = vec![GpuDevice {
1864            vendor: GpuVendor::Nvidia,
1865            name: "GeForce RTX 4090".into(),
1866            memory_mb: Some(24_000),
1867        }];
1868        // Sanity: this is the UnsupportedDiscreteGpu tier.
1869        assert!(matches!(
1870            h.supported_acceleration(),
1871            crate::hardware::SupportedAcceleration::UnsupportedDiscreteGpu { .. }
1872        ));
1873        let recs = recommend(
1874            &refs(&cat),
1875            &h,
1876            UseCase::Coding,
1877            QualityTier::MostCapable,
1878            Privacy::OnDevice,
1879            &CheckpointGeometry::default(),
1880        )
1881        .picks;
1882        assert!(
1883            recs.iter().all(|r| r.display_name != "Qwen3-30B-A3B"),
1884            "17 GB model must not fit a 16 GB-RAM CPU host"
1885        );
1886        assert!(!recs.is_empty(), "smaller models still fit");
1887    }
1888
1889    #[test]
1890    fn recommendation_set_wire_shape_is_snake_case_and_stable() {
1891        // Guards the JSON contract that FFI / JSON-RPC clients decode.
1892        let cat = mac_catalog();
1893        let set = recommend(
1894            &refs(&cat),
1895            &mac(36),
1896            UseCase::Coding,
1897            QualityTier::Balanced,
1898            Privacy::OnDevice,
1899            &CheckpointGeometry::default(),
1900        );
1901        let json = serde_json::to_string(&set).unwrap();
1902        assert!(json.contains("\"picks\""));
1903        assert!(json.contains("\"not_enough_memory\""));
1904        assert!(json.contains("\"model_id\""));
1905        assert!(json.contains("\"already_installed\""));
1906        assert!(json.contains("\"requires_cloud_consent\""));
1907        assert!(json.contains("\"within_recommendation_target\""));
1908        assert!(json.contains("\"fit\""));
1909
1910        let mut legacy = serde_json::to_value(&set.picks[0]).unwrap();
1911        legacy
1912            .as_object_mut()
1913            .unwrap()
1914            .remove("within_recommendation_target");
1915        let decoded: Recommendation = serde_json::from_value(legacy).unwrap();
1916        assert!(decoded.within_recommendation_target);
1917    }
1918
1919    #[test]
1920    fn blank_param_count_estimates_from_size_not_zero() {
1921        // An under-curated entry with no param_count must not be treated as a
1922        // 0B model (which would falsely look tiny + low quality).
1923        let mut m = local_model("x/unknown", "Unknown-Model", "", 4900);
1924        m.param_count = String::new();
1925        assert!(
1926            param_billions_total(&m) > 5.0,
1927            "4.9 GB ⇒ roughly an 8B model, not 0B"
1928        );
1929    }
1930
1931    // --- models.list_unified fit annotation (car#1399) --------------------
1932
1933    fn cloud_row(id: &str) -> ModelSchema {
1934        let mut cloud = local_model(id, "Cloud", "", 0);
1935        cloud.source = ModelSource::RemoteApi {
1936            endpoint: "https://example.invalid".into(),
1937            api_key_env: "TEST_KEY".into(),
1938            api_key_envs: vec![],
1939            api_version: None,
1940            protocol: crate::schema::ApiProtocol::OpenAiCompat,
1941        };
1942        cloud.cost.ram_mb = None;
1943        cloud.cost.size_mb = None;
1944        cloud
1945    }
1946
1947    /// Everyday keeps 40% of unified memory for local models: 3,276 MB on an
1948    /// 8 GB Mac, 6,553 MB on 16 GB, 13,107 MB on 32 GB. Unknown fixture
1949    /// geometry conservatively reserves 1,152 MB of context at 8,192 tokens,
1950    /// yielding Metal peaks of 3,188 MB (0.6B), 5,088 MB (4B), 7,488 MB (8B),
1951    /// and 19,188 MB (30B-A3B).
1952    #[test]
1953    fn unified_fit_is_the_recommenders_verdict_on_every_machine_size() {
1954        let policy = ResourcePolicy::everyday();
1955        let cat = qwen_mlx_policy_catalog();
1956        let four = &cat[0];
1957        let eight = &cat[1];
1958        let small = local_model("mlx/qwen3-0.6b:6bit", "Qwen3-0.6B", "0.6B", 500);
1959        let thirty = local_model(
1960            "mlx/qwen3-30b-a3b:4bit",
1961            "Qwen3-30B-A3B",
1962            "30B (3B active)",
1963            16_500,
1964        );
1965        let at = |m: &ModelSchema, gb: u64| {
1966            model_fit(m, &mac(gb), Some(&policy), &CheckpointGeometry::default())
1967        };
1968
1969        assert_eq!(at(eight, 8).fit, ModelFitStatus::TooBig);
1970        assert_eq!(at(eight, 16).fit, ModelFitStatus::TooBig);
1971        assert_eq!(at(eight, 32).fit, ModelFitStatus::Fits);
1972        assert_eq!(at(four, 8).fit, ModelFitStatus::TooBig);
1973        assert_eq!(at(four, 16).fit, ModelFitStatus::Fits);
1974        assert_eq!(at(&small, 8).fit, ModelFitStatus::Fits);
1975        assert_eq!(at(&thirty, 16).fit, ModelFitStatus::TooBig);
1976        assert_eq!(at(&thirty, 32).fit, ModelFitStatus::TooBig);
1977
1978        // The published estimate is the one the verdict was judged on, and
1979        // the accelerator can run every one of these on a Mac.
1980        let eight_at_32 = at(eight, 32);
1981        assert_eq!(
1982            eight_at_32.estimated_peak_mb,
1983            Some(
1984                estimate_model_memory(eight, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
1985                    .estimated_peak_mb
1986            )
1987        );
1988        assert!(eight_at_32.platform_compatible);
1989
1990        // Same rule as the recommender: whatever it partitions as a pick
1991        // reads `fits`, whatever it lists under not_enough_memory reads
1992        // `too_big`, on each machine.
1993        let all = vec![four.clone(), eight.clone(), small, thirty];
1994        let by_id = |id: &str| all.iter().find(|m| m.id == id).unwrap();
1995        for gb in [8u64, 16, 32] {
1996            let set = recommend_with_policy(
1997                &refs(&all),
1998                &mac(gb),
1999                &policy,
2000                UseCase::Assistant,
2001                QualityTier::Balanced,
2002                Privacy::OnDevice,
2003                &CheckpointGeometry::default(),
2004            );
2005            for pick in &set.picks {
2006                assert_eq!(
2007                    model_fit(
2008                        by_id(&pick.model_id),
2009                        &mac(gb),
2010                        Some(&policy),
2011                        &CheckpointGeometry::default()
2012                    )
2013                    .fit,
2014                    ModelFitStatus::Fits,
2015                    "{gb} GB pick {}",
2016                    pick.model_id
2017                );
2018            }
2019            for miss in &set.not_enough_memory {
2020                assert_eq!(
2021                    model_fit(
2022                        by_id(&miss.model_id),
2023                        &mac(gb),
2024                        Some(&policy),
2025                        &CheckpointGeometry::default()
2026                    )
2027                    .fit,
2028                    ModelFitStatus::TooBig,
2029                    "{gb} GB miss {}",
2030                    miss.model_id
2031                );
2032            }
2033        }
2034    }
2035
2036    /// An installed checkpoint's own KV geometry is what the verdict sizes the
2037    /// KV cache from — the input load-time admission reads — so the list fit
2038    /// and the recommender's partition move together with it (car#2213). The
2039    /// fixture declares four times the KV heads the parameter-count fallback
2040    /// assumes for an 8B row, which takes it from fits to too-big on a 24 GB
2041    /// Mac under Everyday (9,830 MB ceiling).
2042    #[test]
2043    fn an_installed_checkpoints_kv_geometry_decides_the_verdict() {
2044        let policy = ResourcePolicy::everyday();
2045        let cat = qwen_mlx_policy_catalog();
2046        let eight = &cat[1];
2047        let root = tempfile::tempdir().unwrap();
2048        let dir = root.path().join(&eight.name);
2049        std::fs::create_dir_all(&dir).unwrap();
2050        std::fs::write(
2051            dir.join("config.json"),
2052            r#"{"num_hidden_layers":36,"num_key_value_heads":32,"head_dim":128,"hidden_size":4096,"num_attention_heads":32,"torch_dtype":"bfloat16"}"#,
2053        )
2054        .unwrap();
2055        let installed = CheckpointGeometry::read([eight], root.path());
2056        let absent = CheckpointGeometry::default();
2057
2058        let fallback = model_fit(eight, &mac(24), Some(&policy), &absent);
2059        let real = model_fit(eight, &mac(24), Some(&policy), &installed);
2060        assert_eq!(fallback.fit, ModelFitStatus::Fits, "{fallback:?}");
2061        assert_eq!(real.fit, ModelFitStatus::TooBig, "{real:?}");
2062        assert!(real.estimated_peak_mb > fallback.estimated_peak_mb);
2063
2064        let partition = |geometry: &CheckpointGeometry| {
2065            let set = recommend_with_policy(
2066                &[eight],
2067                &mac(24),
2068                &policy,
2069                UseCase::Assistant,
2070                QualityTier::Balanced,
2071                Privacy::OnDevice,
2072                geometry,
2073            );
2074            (
2075                set.picks.iter().any(|r| r.model_id == eight.id),
2076                set.not_enough_memory.iter().any(|r| r.model_id == eight.id),
2077            )
2078        };
2079        assert_eq!(partition(&absent), (true, false));
2080        assert_eq!(partition(&installed), (false, true));
2081
2082        // A multimodal checkpoint (gemma-4's shape) nests the same geometry
2083        // under `text_config`; the verdict reads it there.
2084        let nested_root = tempfile::tempdir().unwrap();
2085        let nested_dir = nested_root.path().join(&eight.name);
2086        std::fs::create_dir_all(&nested_dir).unwrap();
2087        std::fs::write(
2088            nested_dir.join("config.json"),
2089            r#"{"model_type":"gemma4_unified","torch_dtype":"bfloat16","vision_config":{},"text_config":{"num_hidden_layers":36,"num_key_value_heads":32,"head_dim":128,"hidden_size":4096,"num_attention_heads":32}}"#,
2090        )
2091        .unwrap();
2092        let nested = CheckpointGeometry::read([eight], nested_root.path());
2093        assert_eq!(
2094            model_fit(eight, &mac(24), Some(&policy), &nested),
2095            real,
2096            "nested text_config is sized as the flat config is"
2097        );
2098        assert_eq!(partition(&nested), (false, true));
2099    }
2100
2101    /// A model no machine could ever hold reads `too_big` on every machine
2102    /// CAR runs on — including the CUDA-compiled Linux/Windows build with no
2103    /// NVIDIA card, which used to answer `unknown` and so showed the row.
2104    ///
2105    /// This is the fit half of `car-cli`'s
2106    /// `models_list_hides_too_big_and_deprecated_rows_unless_all`. That test
2107    /// spawns the real binary, so its verdict came from whatever hardware the
2108    /// runner reported: it passed under `ci.yml` (`--cfg=car_skip_cuda`, CPU
2109    /// tier) and failed under `build.yml` (CUDA toolkit installed, no GPU) on
2110    /// the very same commit. Judging the fixture here pins the rule to the
2111    /// machine profile instead of to the host.
2112    #[test]
2113    fn a_model_larger_than_any_machine_is_too_big_on_every_machine() {
2114        let policy = ResourcePolicy::everyday();
2115        // The `car models list` fixture: 900 TB of weights.
2116        let enormous = local_model("test/enormous-model:q4", "Enormous", "9000B", 900_000_000);
2117        let machines = [
2118            ("apple 8 GB", mac(8)),
2119            ("apple 128 GB", mac(128)),
2120            ("cpu 32 GB", hw(GpuBackend::Cpu, 32 * 1024, None)),
2121            // The shipped x86_64 Linux/Windows build on a GPU-less box:
2122            // `detect_gpu_backend` says Cuda from a `#[cfg]`, nvidia-smi
2123            // reports nothing.
2124            ("cuda build, no card", hw(GpuBackend::Cuda, 64 * 1024, None)),
2125            (
2126                "cuda 24 GB card",
2127                hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024)),
2128            ),
2129        ];
2130        for (label, machine) in machines {
2131            assert_eq!(
2132                model_fit(
2133                    &enormous,
2134                    &machine,
2135                    Some(&policy),
2136                    &CheckpointGeometry::default()
2137                )
2138                .fit,
2139                ModelFitStatus::TooBig,
2140                "{label} must not claim to hold a 900 TB model"
2141            );
2142            assert_eq!(
2143                model_fit(&enormous, &machine, None, &CheckpointGeometry::default()).fit,
2144                ModelFitStatus::TooBig,
2145                "{label} without a policy must not claim to hold a 900 TB model"
2146            );
2147        }
2148    }
2149
2150    /// The same machine still judges a model it CAN hold as fitting, so the
2151    /// rule above is a memory verdict and not a blanket refusal.
2152    #[test]
2153    fn cuda_build_without_a_card_still_fits_models_that_fit_system_ram() {
2154        let policy = ResourcePolicy::everyday();
2155        let small = local_model("qwen/qwen3-0.6b:q4_k_m", "Qwen3-0.6B", "0.6B", 500);
2156        let no_card = hw(GpuBackend::Cuda, 64 * 1024, None);
2157        assert_eq!(
2158            model_fit(
2159                &small,
2160                &no_card,
2161                Some(&policy),
2162                &CheckpointGeometry::default()
2163            )
2164            .fit,
2165            ModelFitStatus::Fits
2166        );
2167    }
2168
2169    #[test]
2170    fn unified_fit_platform_check_is_the_base_filters() {
2171        let cat = qwen_mlx_policy_catalog();
2172        let mlx = &cat[0];
2173        let cpu_box = hw(GpuBackend::Cpu, 32 * 1024, None);
2174        let fit = model_fit(
2175            mlx,
2176            &cpu_box,
2177            Some(&ResourcePolicy::everyday()),
2178            &CheckpointGeometry::default(),
2179        );
2180        assert!(!fit.platform_compatible, "MLX needs Apple Silicon");
2181        assert!(!passes_base_filter(
2182            mlx,
2183            &cpu_box,
2184            UseCase::Coding,
2185            Privacy::OnDevice
2186        ));
2187        assert!(platform_compatible(mlx, &mac(32)));
2188
2189        let gguf = local_model("qwen/qwen3-4b:q4_k_m", "Qwen3-4B", "4B", 2_500);
2190        assert!(
2191            model_fit(&gguf, &cpu_box, None, &CheckpointGeometry::default()).platform_compatible
2192        );
2193        assert!(passes_base_filter(
2194            &gguf,
2195            &cpu_box,
2196            UseCase::Coding,
2197            Privacy::OnDevice
2198        ));
2199    }
2200
2201    #[test]
2202    fn unified_fit_for_rows_whose_memory_is_not_this_machines() {
2203        // Remote: the server owns its memory → fits, no estimate, any platform.
2204        let cloud = cloud_row("remote/cloud");
2205        let fit = model_fit(
2206            &cloud,
2207            &mac(8),
2208            Some(&ResourcePolicy::everyday()),
2209            &CheckpointGeometry::default(),
2210        );
2211        assert_eq!(fit.fit, ModelFitStatus::Fits);
2212        assert_eq!(fit.estimated_peak_mb, None);
2213        assert!(fit.platform_compatible);
2214        assert!(
2215            model_fit(
2216                &cloud,
2217                &hw(GpuBackend::Cpu, 8 * 1024, None),
2218                None,
2219                &CheckpointGeometry::default()
2220            )
2221            .platform_compatible
2222        );
2223
2224        // A local row that declares neither size nor RAM: nothing to judge.
2225        let mut undeclared = local_model("local/undeclared", "Undeclared", "4B", 0);
2226        undeclared.cost.ram_mb = None;
2227        undeclared.cost.size_mb = None;
2228        let fit = model_fit(
2229            &undeclared,
2230            &mac(32),
2231            Some(&ResourcePolicy::everyday()),
2232            &CheckpointGeometry::default(),
2233        );
2234        assert_eq!(fit.fit, ModelFitStatus::Unknown);
2235        assert_eq!(fit.estimated_peak_mb, None);
2236
2237        // OS-owned rows fit by definition because their memory is outside the
2238        // CAR model budget. Their own platform requirement remains separate.
2239        let mut foundation = local_model("apple/foundation:default", "Apple", "", 0);
2240        foundation.source = ModelSource::AppleFoundationModels { use_case: None };
2241        foundation.cost.ram_mb = None;
2242        foundation.cost.size_mb = None;
2243        let on_mac = model_fit(
2244            &foundation,
2245            &mac(8),
2246            Some(&ResourcePolicy::everyday()),
2247            &CheckpointGeometry::default(),
2248        );
2249        assert_eq!(on_mac.fit, ModelFitStatus::Fits);
2250        assert_eq!(on_mac.estimated_peak_mb, None);
2251        assert!(on_mac.platform_compatible);
2252        assert!(
2253            !model_fit(
2254                &foundation,
2255                &hw(GpuBackend::Cpu, 64 * 1024, None),
2256                None,
2257                &CheckpointGeometry::default()
2258            )
2259            .platform_compatible
2260        );
2261
2262        let mut windows = local_model("windows/speech-synthesis:os", "Windows", "", 0);
2263        windows.source = ModelSource::WindowsSpeech {};
2264        windows.cost.ram_mb = None;
2265        windows.cost.size_mb = None;
2266        let on_mac = model_fit(
2267            &windows,
2268            &mac(8),
2269            Some(&ResourcePolicy::everyday()),
2270            &CheckpointGeometry::default(),
2271        );
2272        assert_eq!(on_mac.fit, ModelFitStatus::Fits);
2273        assert_eq!(on_mac.estimated_peak_mb, None);
2274        assert!(!on_mac.platform_compatible);
2275        let mut windows_host = hw(GpuBackend::Cpu, 8 * 1024, None);
2276        windows_host.os = "windows".into();
2277        assert!(
2278            model_fit(
2279                &windows,
2280                &windows_host,
2281                None,
2282                &CheckpointGeometry::default()
2283            )
2284            .platform_compatible
2285        );
2286
2287        // The explicit platform tags cover future sources without teaching
2288        // this rule another backend variant.
2289        let mut linux = undeclared.clone();
2290        linux.tags.push("linux-only".into());
2291        let mut linux_host = hw(GpuBackend::Cpu, 8 * 1024, None);
2292        linux_host.os = "linux".into();
2293        assert!(platform_compatible(&linux, &linux_host));
2294        assert!(!platform_compatible(&linux, &windows_host));
2295        let mut tagged_windows = undeclared.clone();
2296        tagged_windows.tags.push("windows-only".into());
2297        assert!(platform_compatible(&tagged_windows, &windows_host));
2298        assert!(!platform_compatible(&tagged_windows, &linux_host));
2299
2300        // Without a policy the legacy tier budget applies — unified memory
2301        // minus the 3 GB OS reserve, 5,120 MB on an 8 GB Mac — exactly as
2302        // `recommend` does: the 4B (≈3,940 MB) fits, the 8B (≈6,344 MB) does
2303        // not, and that is the partition the recommender publishes.
2304        let cat = qwen_mlx_policy_catalog();
2305        assert_eq!(
2306            model_fit(&cat[0], &mac(8), None, &CheckpointGeometry::default()).fit,
2307            ModelFitStatus::Fits
2308        );
2309        assert_eq!(
2310            model_fit(&cat[1], &mac(8), None, &CheckpointGeometry::default()).fit,
2311            ModelFitStatus::TooBig
2312        );
2313        let legacy = recommend(
2314            &refs(&cat),
2315            &mac(8),
2316            UseCase::Coding,
2317            QualityTier::Balanced,
2318            Privacy::OnDevice,
2319            &CheckpointGeometry::default(),
2320        );
2321        fn ids(set: &[Recommendation]) -> Vec<&str> {
2322            set.iter().map(|pick| pick.model_id.as_str()).collect()
2323        }
2324        assert_eq!(ids(&legacy.picks), vec!["mlx/qwen3-4b:4bit"]);
2325        assert_eq!(ids(&legacy.not_enough_memory), vec!["mlx/qwen3-8b:4bit"]);
2326    }
2327}
2328
2329#[cfg(test)]
2330mod local_server_fit_tests {
2331    use super::*;
2332    use crate::schema::{ModelCapability, ModelSource};
2333
2334    fn managed_vllm_model(id: &str, size_mb: u64) -> ModelSchema {
2335        let mut m = super::tests::local_model(id, id, "12B", size_mb);
2336        m.capabilities.push(ModelCapability::ToolUse);
2337        m.cost.ram_mb = Some(size_mb + size_mb / 4);
2338        m.source = ModelSource::ManagedVllmMlx {
2339            hf_repo: "mlx-community/whatever-4bit".into(),
2340            hf_weight_file: None,
2341            draft_hf_repo: None,
2342        };
2343        m
2344    }
2345
2346    fn external_vllm_model(id: &str, endpoint: &str, size_mb: u64) -> ModelSchema {
2347        let mut m = super::tests::local_model(id, id, "12B", size_mb);
2348        m.capabilities.push(ModelCapability::ToolUse);
2349        m.cost.ram_mb = Some(size_mb + size_mb / 4);
2350        m.source = ModelSource::VllmMlx {
2351            endpoint: endpoint.to_string(),
2352            model_name: "externally-managed-model".into(),
2353        };
2354        m
2355    }
2356
2357    fn small_mac() -> HardwareInfo {
2358        super::tests::hw(crate::hardware::GpuBackend::Metal, 16384, Some(12288))
2359    }
2360
2361    /// The production break: an explicitly CAR-managed vLLM-MLX model was
2362    /// classified as server-provided, so a 20 GB allocation could be
2363    /// recommended to a 16 GB Mac without consuming the configured budget.
2364    #[test]
2365    fn managed_vllm_mlx_is_memory_checked_and_rejected_when_over_budget() {
2366        let big = managed_vllm_model("vllm-mlx/huge:4bit", 20_000);
2367        let set = recommend_with_policy(
2368            &[&big],
2369            &small_mac(),
2370            &ResourcePolicy::everyday(),
2371            UseCase::Assistant,
2372            QualityTier::Balanced,
2373            Privacy::OnDevice,
2374            &CheckpointGeometry::default(),
2375        );
2376
2377        assert!(set.picks.is_empty());
2378        assert_eq!(set.not_enough_memory.len(), 1);
2379        assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
2380        assert_eq!(
2381            set.not_enough_memory[0].download_mb, 20_000,
2382            "CAR-managed vllm weights must retain their declared download size"
2383        );
2384    }
2385
2386    /// External vLLM is a cloud-consent/server-provided route regardless of the
2387    /// endpoint spelling or the hardware CAR itself is running on.
2388    #[test]
2389    fn external_vllm_mlx_requires_cloud_consent_and_is_cross_platform() {
2390        let machines = [
2391            small_mac(),
2392            super::tests::hw(crate::hardware::GpuBackend::Cpu, 16_384, None),
2393            super::tests::hw(crate::hardware::GpuBackend::Cuda, 16_384, Some(12_288)),
2394        ];
2395        for endpoint in [
2396            "http://localhost:8000",
2397            "http://127.0.0.1:8000",
2398            "https://gpu-owner.example/v1",
2399        ] {
2400            let external = external_vllm_model("external/vllm", endpoint, 20_000);
2401            for machine in &machines {
2402                let on_device = recommend_with_policy(
2403                    &[&external],
2404                    machine,
2405                    &ResourcePolicy::everyday(),
2406                    UseCase::Assistant,
2407                    QualityTier::Balanced,
2408                    Privacy::OnDevice,
2409                    &CheckpointGeometry::default(),
2410                );
2411                assert!(
2412                    on_device.picks.is_empty(),
2413                    "external endpoint {endpoint} must require cloud consent on {:?}",
2414                    machine.gpu_backend
2415                );
2416
2417                let cloud_ok = recommend_with_policy(
2418                    &[&external],
2419                    machine,
2420                    &ResourcePolicy::everyday(),
2421                    UseCase::Assistant,
2422                    QualityTier::Balanced,
2423                    Privacy::CloudOk,
2424                    &CheckpointGeometry::default(),
2425                );
2426                assert_eq!(
2427                    cloud_ok.picks.len(),
2428                    1,
2429                    "external endpoint {endpoint} on {:?}",
2430                    machine.gpu_backend
2431                );
2432                assert_eq!(cloud_ok.picks[0].fit, FitStatus::ServerProvided);
2433                assert_eq!(
2434                    cloud_ok.picks[0].download_mb, 0,
2435                    "external vllm owns its weights, so CAR has no download to report"
2436                );
2437                assert!(
2438                    cloud_ok.picks[0].rationale.contains("external server"),
2439                    "external vllm rationale must describe its actual owner: {}",
2440                    cloud_ok.picks[0].rationale
2441                );
2442                assert!(
2443                    !cloud_ok.picks[0].rationale.contains("Parslee's servers"),
2444                    "external vllm must not be attributed to Parslee: {}",
2445                    cloud_ok.picks[0].rationale
2446                );
2447            }
2448        }
2449    }
2450}
2451
2452#[cfg(test)]
2453mod catalog_capability_gap_tests {
2454    use super::*;
2455    use crate::schema::{ModelCapability, ModelSchema};
2456
2457    fn builtin() -> Vec<ModelSchema> {
2458        serde_json::from_str(include_str!("builtin_catalog.json")).unwrap()
2459    }
2460
2461    fn cuda_box(vram_gb: u64, ram_gb: u64) -> crate::hardware::HardwareInfo {
2462        super::tests::hw(
2463            crate::hardware::GpuBackend::Cuda,
2464            ram_gb * 1024,
2465            Some(vram_gb * 1024),
2466        )
2467    }
2468
2469    /// The real catalog on a Mac offers no GGUF row in any lane: each Qwen3
2470    /// used to appear twice, once as a download that would never run.
2471    #[test]
2472    fn a_mac_is_never_offered_a_gguf_row_from_the_builtin_catalog() {
2473        let catalog = builtin();
2474        let refs: Vec<&ModelSchema> = catalog.iter().collect();
2475        for use_case in [
2476            UseCase::Assistant,
2477            UseCase::Coding,
2478            UseCase::Summarize,
2479            UseCase::Vision,
2480            UseCase::Transcription,
2481            UseCase::Search,
2482        ] {
2483            for tier in [
2484                QualityTier::Fastest,
2485                QualityTier::Balanced,
2486                QualityTier::MostCapable,
2487            ] {
2488                let set = recommend(
2489                    &refs,
2490                    &super::tests::mac(64),
2491                    use_case,
2492                    tier,
2493                    Privacy::OnDevice,
2494                    &CheckpointGeometry::default(),
2495                );
2496                for pick in set.picks.iter().chain(&set.not_enough_memory) {
2497                    let row = catalog.iter().find(|m| m.id == pick.model_id).unwrap();
2498                    assert!(
2499                        !matches!(row.source, crate::schema::ModelSource::Local { .. }),
2500                        "{use_case:?}/{tier:?} offered GGUF row {}",
2501                        row.id
2502                    );
2503                }
2504            }
2505        }
2506    }
2507
2508    fn most_capable_on(ram_gb: u64) -> RecommendationSet {
2509        let catalog: &'static Vec<ModelSchema> = Box::leak(Box::new(builtin()));
2510        let refs: Vec<&ModelSchema> = catalog.iter().collect();
2511        recommend(
2512            &refs,
2513            &super::tests::mac(ram_gb),
2514            UseCase::Assistant,
2515            QualityTier::MostCapable,
2516            Privacy::OnDevice,
2517            &CheckpointGeometry::default(),
2518        )
2519    }
2520
2521    /// The outcome this whole area exists for: on a machine with room for it,
2522    /// "most capable" returns the highest-scoring model that machine can run —
2523    /// not the highest-scoring small one.
2524    ///
2525    /// It used to answer gemma-4-12B (6.6 GB) on a 64 GB Mac while `--tier
2526    /// fastest` answered a 35B, because every model carrying a `car-judged`
2527    /// score was an older, smaller one and an unscored model fell back to a
2528    /// size prior that saturates below what a real score reaches.
2529    #[test]
2530    fn most_capable_returns_the_best_model_the_machine_can_run() {
2531        let catalog = builtin();
2532        let set = most_capable_on(64);
2533        let top = set.picks.first().expect("a 64 GB machine has picks");
2534
2535        let top_score = catalog
2536            .iter()
2537            .find(|m| m.id == top.model_id)
2538            .and_then(|m| m.public_benchmarks.first())
2539            .map(|b| b.score)
2540            .unwrap_or(0.0);
2541
2542        for m in catalog
2543            .iter()
2544            .filter(|m| m.is_local() && m.size_mb() < 24_000)
2545        {
2546            if let Some(s) = m.public_benchmarks.first().map(|b| b.score) {
2547                assert!(
2548                    s <= top_score,
2549                    "{} scores {s} but {} ({top_score}) was recommended as most capable",
2550                    m.id,
2551                    top.model_id
2552                );
2553            }
2554        }
2555        assert!(
2556            top.download_mb > 10_000,
2557            "a 64 GB machine should be offered a large model, got {} at {} MB",
2558            top.model_id,
2559            top.download_mb
2560        );
2561    }
2562
2563    /// A small machine still gets something that fits — the capability tier
2564    /// must not simply always return the biggest model.
2565    #[test]
2566    fn a_small_machine_is_not_offered_a_model_it_cannot_hold() {
2567        let set = most_capable_on(8);
2568        let top = set
2569            .picks
2570            .first()
2571            .expect("an 8 GB machine still gets a pick");
2572        assert!(
2573            top.fit != FitStatus::TooBig,
2574            "{} does not fit an 8 GB machine",
2575            top.model_id
2576        );
2577    }
2578
2579    /// The disclosure fires only while larger models remain unscored, and never
2580    /// claims they are downloaded — available means CAR can fetch and run them.
2581    #[test]
2582    fn the_disclosure_is_accurate_when_it_appears() {
2583        let set = most_capable_on(64);
2584        if let Some(note) = set.note.as_deref() {
2585            if note.contains("unscored") {
2586                assert!(note.contains("bench-contribute"), "must say how: {note}");
2587                assert!(
2588                    !note.contains("installed"),
2589                    "availability is not installation: {note}"
2590                );
2591            }
2592        }
2593    }
2594
2595    /// It is scoped to the tier that makes the capability claim.
2596    #[test]
2597    fn other_tiers_do_not_carry_the_disclosure() {
2598        let catalog = builtin();
2599        let refs: Vec<&ModelSchema> = catalog.iter().collect();
2600        for tier in [QualityTier::Fastest, QualityTier::Balanced] {
2601            let set = recommend(
2602                &refs,
2603                &super::tests::mac(64),
2604                UseCase::Assistant,
2605                tier,
2606                Privacy::OnDevice,
2607                &CheckpointGeometry::default(),
2608            );
2609            let carries = set.note.as_deref().is_some_and(|n| n.contains("unscored"));
2610            assert!(!carries, "{tier:?} should not carry the disclosure");
2611        }
2612    }
2613
2614    /// A CUDA machine must be offered a local model at all. GGUF is its local
2615    /// path — `backend::candle` is compiled on exactly the targets MLX is not —
2616    /// so the catalog's `local` rows are its whole local story.
2617    ///
2618    /// Catalog coverage, not a regression test for the availability fix:
2619    /// `recommend` does not read `ModelSchema::available`. That gates the
2620    /// router's fallback chains and is covered in
2621    /// `registry::local_availability_tests`.
2622    #[test]
2623    fn a_cuda_machine_is_offered_a_local_model() {
2624        let catalog = builtin();
2625        let refs: Vec<&ModelSchema> = catalog.iter().collect();
2626        let set = recommend(
2627            &refs,
2628            &cuda_box(24, 64),
2629            UseCase::Assistant,
2630            QualityTier::MostCapable,
2631            Privacy::OnDevice,
2632            &CheckpointGeometry::default(),
2633        );
2634        assert!(
2635            set.picks.iter().any(|p| p.is_local),
2636            "a 24 GB CUDA GPU must be offered something local, got {:?}",
2637            set.picks.iter().map(|p| &p.model_id).collect::<Vec<_>>()
2638        );
2639    }
2640
2641    /// A blank `param_count` is not neutral: the total falls back to
2642    /// `size_mb / 600` and the *active* count falls back to that total, so a
2643    /// 4-bit MoE reads as a dense model of its whole on-disk size.
2644    #[test]
2645    fn local_generate_models_declare_their_parameter_count() {
2646        let blank: Vec<String> = builtin()
2647            .iter()
2648            .filter(|m| {
2649                m.capabilities.contains(&ModelCapability::Generate)
2650                    && m.is_local()
2651                    && m.param_count.trim().is_empty()
2652            })
2653            .map(|m| m.id.clone())
2654            .collect();
2655        assert!(
2656            blank.is_empty(),
2657            "local generate models with no param_count: {blank:?}"
2658        );
2659    }
2660
2661    /// GLM-4.7-Flash's 4-bit conversion loops ("+ 5 - 5 + 5 - 5 …") on easy
2662    /// prompts at every sampling temperature, so it scored 0.081 on the judged
2663    /// suite while the 8-bit conversion answers cleanly (car#3537). The
2664    /// concierge must offer the 8-bit row and never the 4-bit one.
2665    #[test]
2666    fn glm_flash_is_offered_at_8bit_only() {
2667        let catalog = builtin();
2668        let row = |id: &str| {
2669            catalog
2670                .iter()
2671                .find(|m| m.id == id)
2672                .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
2673        };
2674        let base = |m: &ModelSchema| {
2675            passes_base_filter(
2676                m,
2677                &super::tests::mac(64),
2678                UseCase::Coding,
2679                Privacy::OnDevice,
2680            )
2681        };
2682        assert!(!base(row("vllm-mlx/glm-4.7-flash:4bit")));
2683        assert!(base(row("vllm-mlx/glm-4.7-flash:8bit")));
2684    }
2685
2686    /// An MoE's `(N active)` hint is what separates its latency from its size.
2687    #[test]
2688    fn an_moe_is_scored_on_its_active_parameters() {
2689        let catalog = builtin();
2690        let glm = catalog
2691            .iter()
2692            .find(|m| m.id == "vllm-mlx/glm-4.7-flash:8bit")
2693            .expect("catalog entry");
2694        let active = crate::resource_policy::model_parameter_billions_active(glm);
2695        let total = crate::resource_policy::model_parameter_billions_total(glm);
2696        assert!(
2697            active < 6.0,
2698            "top-4-of-64 MoE runs at a few B active, got {active}"
2699        );
2700        assert!(total > 20.0, "and carries 30B-class knowledge, got {total}");
2701    }
2702}