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