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        let separate_pools_fit = recommend_with_policy(
1057            &[&model],
1058            &hw(GpuBackend::Cuda, 8 * 1024, Some(16 * 1024)),
1059            &ResourcePolicy::everyday(),
1060            UseCase::Assistant,
1061            QualityTier::Balanced,
1062            Privacy::OnDevice,
1063        );
1064        assert_eq!(separate_pools_fit.picks[0].fit, FitStatus::Fits);
1065        assert!(separate_pools_fit.picks[0].within_recommendation_target);
1066    }
1067
1068    #[test]
1069    fn custom_zero_blocks_automatic_and_explicit_local_fit() {
1070        let model = qwen_mlx_policy_catalog().remove(0);
1071        let set = recommend_with_policy(
1072            &[&model],
1073            &mac(32),
1074            &ResourcePolicy::custom_gb(0.0).unwrap(),
1075            UseCase::Assistant,
1076            QualityTier::Balanced,
1077            Privacy::OnDevice,
1078        );
1079
1080        assert!(set.picks.is_empty());
1081        assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1082        assert!(!set.not_enough_memory[0].within_recommendation_target);
1083    }
1084
1085    #[test]
1086    fn local_focused_uses_its_full_configured_ceiling() {
1087        let mut model = qwen_mlx_policy_catalog().remove(0);
1088        model.cost.ram_mb = Some(11_500);
1089        model.cost.size_mb = Some(11_500);
1090        let set = recommend_with_policy(
1091            &[&model],
1092            &mac(16),
1093            &ResourcePolicy::local_focused(),
1094            UseCase::Assistant,
1095            QualityTier::Balanced,
1096            Privacy::OnDevice,
1097        );
1098
1099        assert_eq!(set.picks[0].fit, FitStatus::Fits);
1100        assert!(set.picks[0].within_recommendation_target);
1101    }
1102
1103    #[test]
1104    fn unknown_memory_never_outranks_a_known_fit() {
1105        let mut known = qwen_mlx_policy_catalog().remove(0);
1106        known.public_benchmarks.clear();
1107        let mut unknown = known.clone();
1108        unknown.id = "local/unknown-memory".into();
1109        unknown.name = "Unknown Memory".into();
1110        unknown.param_count.clear();
1111        unknown.cost.ram_mb = None;
1112        unknown.cost.size_mb = None;
1113        unknown.public_benchmarks = vec![crate::schema::BenchmarkScore {
1114            name: "quality".into(),
1115            score: 1.0,
1116            harness: None,
1117            source_url: None,
1118            measured_at: None,
1119        }];
1120
1121        let set = recommend_with_policy(
1122            &refs(&[unknown, known]),
1123            &mac(32),
1124            &ResourcePolicy::everyday(),
1125            UseCase::Assistant,
1126            QualityTier::Balanced,
1127            Privacy::OnDevice,
1128        );
1129        assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1130        assert_eq!(set.picks[1].fit, FitStatus::Unknown);
1131        assert!(!set.picks[1].within_recommendation_target);
1132    }
1133
1134    #[test]
1135    fn everyday_assistant_tool_floor_excludes_generate_only_cloud_models() {
1136        let mut local = qwen_mlx_policy_catalog().remove(0);
1137        local.public_benchmarks.clear();
1138        let mut cloud = local.clone();
1139        cloud.id = "remote/high-score-generate-only".into();
1140        cloud.name = "Remote Generate Only".into();
1141        cloud.source = ModelSource::RemoteApi {
1142            endpoint: "https://api".into(),
1143            api_key_env: "K".into(),
1144            api_key_envs: vec![],
1145            api_version: None,
1146            protocol: crate::schema::ApiProtocol::OpenAiCompat,
1147        };
1148        cloud.capabilities = vec![ModelCapability::Generate];
1149        cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1150            name: "quality".into(),
1151            score: 1.0,
1152            harness: None,
1153            source_url: None,
1154            measured_at: None,
1155        }];
1156
1157        let set = recommend_with_policy(
1158            &refs(&[cloud, local]),
1159            &mac(32),
1160            &ResourcePolicy::everyday(),
1161            UseCase::Assistant,
1162            QualityTier::Balanced,
1163            Privacy::CloudOk,
1164        );
1165        assert_eq!(set.picks.len(), 1);
1166        assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1167    }
1168
1169    #[test]
1170    fn policy_entry_point_preserves_legacy_order_among_policy_eligible_candidates() {
1171        let catalog = catalog();
1172        for (policy, use_case, tier) in [
1173            (
1174                ResourcePolicy::everyday(),
1175                UseCase::Assistant,
1176                QualityTier::Fastest,
1177            ),
1178            (
1179                ResourcePolicy::everyday(),
1180                UseCase::Assistant,
1181                QualityTier::MostCapable,
1182            ),
1183            (
1184                ResourcePolicy::everyday(),
1185                UseCase::Coding,
1186                QualityTier::Balanced,
1187            ),
1188            (
1189                ResourcePolicy::local_focused(),
1190                UseCase::Assistant,
1191                QualityTier::Balanced,
1192            ),
1193        ] {
1194            let legacy = recommend(&refs(&catalog), &mac(36), use_case, tier, Privacy::OnDevice);
1195            let policy_aware = recommend_with_policy(
1196                &refs(&catalog),
1197                &mac(36),
1198                &policy,
1199                use_case,
1200                tier,
1201                Privacy::OnDevice,
1202            );
1203            let legacy_common: Vec<&str> = legacy
1204                .picks
1205                .iter()
1206                .filter(|pick| {
1207                    policy_aware
1208                        .picks
1209                        .iter()
1210                        .any(|candidate| candidate.model_id == pick.model_id)
1211                })
1212                .map(|pick| pick.model_id.as_str())
1213                .collect();
1214            let policy_common: Vec<&str> = policy_aware
1215                .picks
1216                .iter()
1217                .filter(|pick| {
1218                    legacy
1219                        .picks
1220                        .iter()
1221                        .any(|candidate| candidate.model_id == pick.model_id)
1222                })
1223                .map(|pick| pick.model_id.as_str())
1224                .collect();
1225            assert_eq!(
1226                policy_common, legacy_common,
1227                "{policy:?} {use_case:?} {tier:?}"
1228            );
1229        }
1230    }
1231
1232    #[test]
1233    fn most_capable_prefers_the_big_model_when_it_fits() {
1234        let cat = catalog();
1235        let recs = recommend(
1236            &refs(&cat),
1237            &mac(36), // 36 GB Mac fits the 17 GB model
1238            UseCase::Coding,
1239            QualityTier::MostCapable,
1240            Privacy::OnDevice,
1241        )
1242        .picks;
1243        assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1244        assert_eq!(recs[0].fit, FitStatus::Fits);
1245    }
1246
1247    #[test]
1248    fn too_big_models_are_excluded_on_small_machines() {
1249        let cat = catalog();
1250        let recs = recommend(
1251            &refs(&cat),
1252            &mac(8), // 8 GB: 17 GB and 8 GB models shouldn't be offered
1253            UseCase::Coding,
1254            QualityTier::MostCapable,
1255            Privacy::OnDevice,
1256        )
1257        .picks;
1258        let names: Vec<&str> = recs.iter().map(|r| r.display_name.as_str()).collect();
1259        assert!(!names.contains(&"Qwen3-30B-A3B"), "30B must not fit 8GB");
1260        assert!(recs.iter().all(|r| r.fit == FitStatus::Fits));
1261        assert!(!recs.is_empty(), "the 0.6B model should still be offered");
1262    }
1263
1264    #[test]
1265    fn balanced_picks_a_capable_model_that_fits() {
1266        let cat = catalog();
1267        let recs = recommend(
1268            &refs(&cat),
1269            &mac(16),
1270            UseCase::Coding,
1271            QualityTier::Balanced,
1272            Privacy::OnDevice,
1273        )
1274        .picks;
1275        // On a 16 GB Mac, Balanced should land on a mid model, not the 0.6B
1276        // and not necessarily the largest.
1277        assert!(matches!(
1278            recs[0].display_name.as_str(),
1279            "Qwen3-4B" | "Qwen3-8B"
1280        ));
1281    }
1282
1283    #[test]
1284    fn search_only_returns_embedding_models() {
1285        let mut cat = catalog();
1286        let mut embed = local_model("qwen/embed", "Qwen3-Embedding", "0.6B", 640);
1287        embed.capabilities = vec![ModelCapability::Embed];
1288        cat.push(embed);
1289        let recs = recommend(
1290            &refs(&cat),
1291            &mac(16),
1292            UseCase::Search,
1293            QualityTier::Balanced,
1294            Privacy::OnDevice,
1295        )
1296        .picks;
1297        assert_eq!(recs.len(), 1, "only the embed model is in the Search lane");
1298        assert_eq!(recs[0].display_name, "Qwen3-Embedding");
1299        assert_eq!(recs[0].role, UseCaseRole::Retrieval);
1300    }
1301
1302    #[test]
1303    fn deprecated_models_are_never_recommended() {
1304        let mut cat = catalog();
1305        cat[1].deprecated = true; // deprecate the 4B
1306        let recs = recommend(
1307            &refs(&cat),
1308            &mac(16),
1309            UseCase::Coding,
1310            QualityTier::Balanced,
1311            Privacy::OnDevice,
1312        )
1313        .picks;
1314        assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B"));
1315    }
1316
1317    #[test]
1318    fn on_device_excludes_cloud_but_cloud_ok_includes_it_with_consent() {
1319        let mut cat = catalog();
1320        let mut cloud = local_model("anthropic/sonnet", "Claude Sonnet", "", 0);
1321        cloud.capabilities = vec![ModelCapability::Generate, ModelCapability::Code];
1322        cloud.source = ModelSource::RemoteApi {
1323            endpoint: "https://api".into(),
1324            api_key_env: "K".into(),
1325            api_key_envs: vec![],
1326            api_version: None,
1327            protocol: crate::schema::ApiProtocol::Anthropic,
1328        };
1329        cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1330            name: "SWE-bench".into(),
1331            score: 0.7,
1332            harness: None,
1333            source_url: None,
1334            measured_at: None,
1335        }];
1336        cat.push(cloud);
1337
1338        let on_device = recommend(
1339            &refs(&cat),
1340            &mac(16),
1341            UseCase::Coding,
1342            QualityTier::MostCapable,
1343            Privacy::OnDevice,
1344        )
1345        .picks;
1346        assert!(on_device.iter().all(|r| r.is_local));
1347
1348        let cloud_ok = recommend(
1349            &refs(&cat),
1350            &mac(16),
1351            UseCase::Coding,
1352            QualityTier::MostCapable,
1353            Privacy::CloudOk,
1354        )
1355        .picks;
1356        let claude = cloud_ok
1357            .iter()
1358            .find(|r| r.display_name == "Claude Sonnet")
1359            .expect("cloud model eligible under CloudOk");
1360        assert!(claude.requires_cloud_consent);
1361        assert_eq!(claude.fit, FitStatus::ServerProvided);
1362    }
1363
1364    #[test]
1365    fn metal_only_model_excluded_on_cpu_host() {
1366        let mut cat = catalog();
1367        let mut mlx = local_model("mlx/qwen3-4b", "Qwen3-4B-MLX", "4B", 2400);
1368        mlx.source = ModelSource::Mlx {
1369            hf_repo: "mlx-community/x".into(),
1370            hf_weight_file: None,
1371        };
1372        cat.push(mlx);
1373        // CPU-only Linux box.
1374        let recs = recommend(
1375            &refs(&cat),
1376            &hw(GpuBackend::Cpu, 32 * 1024, None),
1377            UseCase::Coding,
1378            QualityTier::Balanced,
1379            Privacy::OnDevice,
1380        )
1381        .picks;
1382        assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B-MLX"));
1383    }
1384
1385    #[test]
1386    fn ranking_is_deterministic() {
1387        let cat = catalog();
1388        let a = recommend(
1389            &refs(&cat),
1390            &mac(16),
1391            UseCase::Assistant,
1392            QualityTier::Balanced,
1393            Privacy::OnDevice,
1394        );
1395        let b = recommend(
1396            &refs(&cat),
1397            &mac(16),
1398            UseCase::Assistant,
1399            QualityTier::Balanced,
1400            Privacy::OnDevice,
1401        );
1402        let ids_a: Vec<&str> = a.picks.iter().map(|r| r.model_id.as_str()).collect();
1403        let ids_b: Vec<&str> = b.picks.iter().map(|r| r.model_id.as_str()).collect();
1404        assert_eq!(ids_a, ids_b);
1405    }
1406
1407    #[test]
1408    fn rationale_is_plain_language_no_jargon() {
1409        let cat = catalog();
1410        let recs = recommend(
1411            &refs(&cat),
1412            &mac(36),
1413            UseCase::Coding,
1414            QualityTier::Balanced,
1415            Privacy::OnDevice,
1416        )
1417        .picks;
1418        let r = &recs[0].rationale;
1419        assert!(!r.contains("Q4_K_M"), "no quantization jargon");
1420        assert!(!r.contains("gguf") && !r.contains("hf_repo"));
1421        assert!(r.contains("coding"), "states the purpose");
1422    }
1423
1424    #[test]
1425    fn all_too_big_surfaces_needs_more_ram_with_a_note() {
1426        // A 2 GB machine fits nothing in the catalog.
1427        let cat = catalog();
1428        let set = recommend(
1429            &refs(&cat),
1430            &hw(GpuBackend::Cpu, 2 * 1024, None),
1431            UseCase::Coding,
1432            QualityTier::Balanced,
1433            Privacy::OnDevice,
1434        );
1435        assert!(set.picks.is_empty(), "nothing should fit 2 GB");
1436        assert!(
1437            !set.not_enough_memory.is_empty(),
1438            "too-big models surfaced, not dropped"
1439        );
1440        let note = set.note.expect("empty picks must carry a note");
1441        assert!(note.contains("fits"), "note explains the no-fit: {note}");
1442        // Closest miss ranked first.
1443        assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1444    }
1445
1446    #[test]
1447    fn all_deprecated_gives_generic_note_not_a_memory_note() {
1448        // Deprecated models are filtered before the fit partition, so they
1449        // land in neither picks nor not_enough_memory — the note must be the
1450        // generic "no model available", not a misleading "needs more RAM".
1451        let mut cat = catalog();
1452        for m in &mut cat {
1453            m.deprecated = true;
1454        }
1455        let set = recommend(
1456            &refs(&cat),
1457            &mac(36), // plenty of RAM — so a memory note would be wrong
1458            UseCase::Coding,
1459            QualityTier::Balanced,
1460            Privacy::OnDevice,
1461        );
1462        assert!(set.picks.is_empty());
1463        assert!(set.not_enough_memory.is_empty());
1464        let note = set.note.expect("must explain");
1465        assert!(
1466            !note.contains("fits") && !note.contains("memory"),
1467            "deprecated-only must not claim a memory problem: {note}"
1468        );
1469    }
1470
1471    #[test]
1472    fn not_enough_memory_is_ordered_deterministically() {
1473        let cat = catalog();
1474        let mk = || {
1475            recommend(
1476                &refs(&cat),
1477                &hw(GpuBackend::Cpu, 3 * 1024, None), // only the 0.6B fits
1478                UseCase::Coding,
1479                QualityTier::Balanced,
1480                Privacy::OnDevice,
1481            )
1482            .not_enough_memory
1483            .into_iter()
1484            .map(|r| r.model_id)
1485            .collect::<Vec<_>>()
1486        };
1487        assert!(mk().len() >= 2, "several models should be too big for 3 GB");
1488        assert_eq!(mk(), mk(), "too-big ordering must be deterministic");
1489    }
1490
1491    #[test]
1492    fn empty_registry_returns_empty_with_a_note() {
1493        let set = recommend(
1494            &[],
1495            &mac(16),
1496            UseCase::Assistant,
1497            QualityTier::Balanced,
1498            Privacy::OnDevice,
1499        );
1500        assert!(set.picks.is_empty());
1501        assert!(set.not_enough_memory.is_empty());
1502        assert!(set.note.is_some(), "no-model case must explain itself");
1503    }
1504
1505    #[test]
1506    fn cuda_box_sizes_against_vram() {
1507        // A 24 GB CUDA GPU fits the 17 GB model under MostCapable.
1508        let cat = catalog();
1509        let h = hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024));
1510        let recs = recommend(
1511            &refs(&cat),
1512            &h,
1513            UseCase::Coding,
1514            QualityTier::MostCapable,
1515            Privacy::OnDevice,
1516        )
1517        .picks;
1518        assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1519    }
1520
1521    #[test]
1522    fn unsupported_discrete_gpu_uses_system_ram_not_vram() {
1523        // A 24 GB discrete GPU CAR can't drive must NOT be used as the budget;
1524        // a 16 GB-RAM CPU host can't fit the 17 GB model despite the big card.
1525        let cat = catalog();
1526        let mut h = hw(GpuBackend::Cpu, 16 * 1024, None);
1527        h.gpu_devices = vec![GpuDevice {
1528            vendor: GpuVendor::Nvidia,
1529            name: "GeForce RTX 4090".into(),
1530            memory_mb: Some(24_000),
1531        }];
1532        // Sanity: this is the UnsupportedDiscreteGpu tier.
1533        assert!(matches!(
1534            h.supported_acceleration(),
1535            crate::hardware::SupportedAcceleration::UnsupportedDiscreteGpu { .. }
1536        ));
1537        let recs = recommend(
1538            &refs(&cat),
1539            &h,
1540            UseCase::Coding,
1541            QualityTier::MostCapable,
1542            Privacy::OnDevice,
1543        )
1544        .picks;
1545        assert!(
1546            recs.iter().all(|r| r.display_name != "Qwen3-30B-A3B"),
1547            "17 GB model must not fit a 16 GB-RAM CPU host"
1548        );
1549        assert!(!recs.is_empty(), "smaller models still fit");
1550    }
1551
1552    #[test]
1553    fn recommendation_set_wire_shape_is_snake_case_and_stable() {
1554        // Guards the JSON contract that FFI / JSON-RPC clients decode.
1555        let cat = catalog();
1556        let set = recommend(
1557            &refs(&cat),
1558            &mac(36),
1559            UseCase::Coding,
1560            QualityTier::Balanced,
1561            Privacy::OnDevice,
1562        );
1563        let json = serde_json::to_string(&set).unwrap();
1564        assert!(json.contains("\"picks\""));
1565        assert!(json.contains("\"not_enough_memory\""));
1566        assert!(json.contains("\"model_id\""));
1567        assert!(json.contains("\"already_installed\""));
1568        assert!(json.contains("\"requires_cloud_consent\""));
1569        assert!(json.contains("\"within_recommendation_target\""));
1570        assert!(json.contains("\"fit\""));
1571
1572        let mut legacy = serde_json::to_value(&set.picks[0]).unwrap();
1573        legacy
1574            .as_object_mut()
1575            .unwrap()
1576            .remove("within_recommendation_target");
1577        let decoded: Recommendation = serde_json::from_value(legacy).unwrap();
1578        assert!(decoded.within_recommendation_target);
1579    }
1580
1581    #[test]
1582    fn blank_param_count_estimates_from_size_not_zero() {
1583        // An under-curated entry with no param_count must not be treated as a
1584        // 0B model (which would falsely look tiny + low quality).
1585        let mut m = local_model("x/unknown", "Unknown-Model", "", 4900);
1586        m.param_count = String::new();
1587        assert!(
1588            param_billions_total(&m) > 5.0,
1589            "4.9 GB ⇒ roughly an 8B model, not 0B"
1590        );
1591    }
1592
1593    // --- models.list_unified fit annotation (car#1399) --------------------
1594
1595    fn cloud_row(id: &str) -> ModelSchema {
1596        let mut cloud = local_model(id, "Cloud", "", 0);
1597        cloud.source = ModelSource::RemoteApi {
1598            endpoint: "https://example.invalid".into(),
1599            api_key_env: "TEST_KEY".into(),
1600            api_key_envs: vec![],
1601            api_version: None,
1602            protocol: crate::schema::ApiProtocol::OpenAiCompat,
1603        };
1604        cloud.cost.ram_mb = None;
1605        cloud.cost.size_mb = None;
1606        cloud
1607    }
1608
1609    /// Everyday keeps 40% of unified memory for local models: 3,276 MB on an
1610    /// 8 GB Mac, 6,553 MB on 16 GB, 13,107 MB on 32 GB. Metal estimates at
1611    /// the recommendation context: 0.6B ≈ 2,037 MB, 4B ≈ 3,940 MB, 8B ≈
1612    /// 6,344 MB, 30B-A3B ≈ 18,039 MB.
1613    #[test]
1614    fn unified_fit_is_the_recommenders_verdict_on_every_machine_size() {
1615        let policy = ResourcePolicy::everyday();
1616        let cat = qwen_mlx_policy_catalog();
1617        let four = &cat[0];
1618        let eight = &cat[1];
1619        let small = local_model("mlx/qwen3-0.6b:6bit", "Qwen3-0.6B", "0.6B", 500);
1620        let thirty = local_model(
1621            "mlx/qwen3-30b-a3b:4bit",
1622            "Qwen3-30B-A3B",
1623            "30B (3B active)",
1624            16_500,
1625        );
1626        let at = |m: &ModelSchema, gb: u64| model_fit(m, &mac(gb), Some(&policy));
1627
1628        assert_eq!(at(eight, 8).fit, ModelFitStatus::TooBig);
1629        assert_eq!(at(eight, 16).fit, ModelFitStatus::Fits);
1630        assert_eq!(at(eight, 32).fit, ModelFitStatus::Fits);
1631        assert_eq!(at(four, 8).fit, ModelFitStatus::TooBig);
1632        assert_eq!(at(four, 16).fit, ModelFitStatus::Fits);
1633        assert_eq!(at(&small, 8).fit, ModelFitStatus::Fits);
1634        assert_eq!(at(&thirty, 16).fit, ModelFitStatus::TooBig);
1635        assert_eq!(at(&thirty, 32).fit, ModelFitStatus::TooBig);
1636
1637        // The published estimate is the one the verdict was judged on, and
1638        // the accelerator can run every one of these on a Mac.
1639        let eight_at_32 = at(eight, 32);
1640        assert_eq!(
1641            eight_at_32.estimated_peak_mb,
1642            Some(
1643                estimate_model_memory(eight, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
1644                    .estimated_peak_mb
1645            )
1646        );
1647        assert!(eight_at_32.platform_compatible);
1648
1649        // Same rule as the recommender: whatever it partitions as a pick
1650        // reads `fits`, whatever it lists under not_enough_memory reads
1651        // `too_big`, on each machine.
1652        let all = vec![four.clone(), eight.clone(), small, thirty];
1653        let by_id = |id: &str| all.iter().find(|m| m.id == id).unwrap();
1654        for gb in [8u64, 16, 32] {
1655            let set = recommend_with_policy(
1656                &refs(&all),
1657                &mac(gb),
1658                &policy,
1659                UseCase::Assistant,
1660                QualityTier::Balanced,
1661                Privacy::OnDevice,
1662            );
1663            for pick in &set.picks {
1664                assert_eq!(
1665                    model_fit(by_id(&pick.model_id), &mac(gb), Some(&policy)).fit,
1666                    ModelFitStatus::Fits,
1667                    "{gb} GB pick {}",
1668                    pick.model_id
1669                );
1670            }
1671            for miss in &set.not_enough_memory {
1672                assert_eq!(
1673                    model_fit(by_id(&miss.model_id), &mac(gb), Some(&policy)).fit,
1674                    ModelFitStatus::TooBig,
1675                    "{gb} GB miss {}",
1676                    miss.model_id
1677                );
1678            }
1679        }
1680    }
1681
1682    /// A model no machine could ever hold reads `too_big` on every machine
1683    /// CAR runs on — including the CUDA-compiled Linux/Windows build with no
1684    /// NVIDIA card, which used to answer `unknown` and so showed the row.
1685    ///
1686    /// This is the fit half of `car-cli`'s
1687    /// `models_list_hides_too_big_and_deprecated_rows_unless_all`. That test
1688    /// spawns the real binary, so its verdict came from whatever hardware the
1689    /// runner reported: it passed under `ci.yml` (`--cfg=car_skip_cuda`, CPU
1690    /// tier) and failed under `build.yml` (CUDA toolkit installed, no GPU) on
1691    /// the very same commit. Judging the fixture here pins the rule to the
1692    /// machine profile instead of to the host.
1693    #[test]
1694    fn a_model_larger_than_any_machine_is_too_big_on_every_machine() {
1695        let policy = ResourcePolicy::everyday();
1696        // The `car models list` fixture: 900 TB of weights.
1697        let enormous = local_model("test/enormous-model:q4", "Enormous", "9000B", 900_000_000);
1698        let machines = [
1699            ("apple 8 GB", mac(8)),
1700            ("apple 128 GB", mac(128)),
1701            ("cpu 32 GB", hw(GpuBackend::Cpu, 32 * 1024, None)),
1702            // The shipped x86_64 Linux/Windows build on a GPU-less box:
1703            // `detect_gpu_backend` says Cuda from a `#[cfg]`, nvidia-smi
1704            // reports nothing.
1705            ("cuda build, no card", hw(GpuBackend::Cuda, 64 * 1024, None)),
1706            (
1707                "cuda 24 GB card",
1708                hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024)),
1709            ),
1710        ];
1711        for (label, machine) in machines {
1712            assert_eq!(
1713                model_fit(&enormous, &machine, Some(&policy)).fit,
1714                ModelFitStatus::TooBig,
1715                "{label} must not claim to hold a 900 TB model"
1716            );
1717            assert_eq!(
1718                model_fit(&enormous, &machine, None).fit,
1719                ModelFitStatus::TooBig,
1720                "{label} without a policy must not claim to hold a 900 TB model"
1721            );
1722        }
1723    }
1724
1725    /// The same machine still judges a model it CAN hold as fitting, so the
1726    /// rule above is a memory verdict and not a blanket refusal.
1727    #[test]
1728    fn cuda_build_without_a_card_still_fits_models_that_fit_system_ram() {
1729        let policy = ResourcePolicy::everyday();
1730        let small = local_model("qwen/qwen3-0.6b:q4_k_m", "Qwen3-0.6B", "0.6B", 500);
1731        let no_card = hw(GpuBackend::Cuda, 64 * 1024, None);
1732        assert_eq!(
1733            model_fit(&small, &no_card, Some(&policy)).fit,
1734            ModelFitStatus::Fits
1735        );
1736    }
1737
1738    #[test]
1739    fn unified_fit_platform_check_is_the_base_filters() {
1740        let cat = qwen_mlx_policy_catalog();
1741        let mlx = &cat[0];
1742        let cpu_box = hw(GpuBackend::Cpu, 32 * 1024, None);
1743        let fit = model_fit(mlx, &cpu_box, Some(&ResourcePolicy::everyday()));
1744        assert!(!fit.platform_compatible, "MLX needs Apple Silicon");
1745        assert!(!passes_base_filter(
1746            mlx,
1747            &cpu_box,
1748            UseCase::Coding,
1749            Privacy::OnDevice
1750        ));
1751        assert!(platform_compatible(mlx, &mac(32)));
1752
1753        let gguf = local_model("qwen/qwen3-4b:q4_k_m", "Qwen3-4B", "4B", 2_500);
1754        assert!(model_fit(&gguf, &cpu_box, None).platform_compatible);
1755        assert!(passes_base_filter(
1756            &gguf,
1757            &cpu_box,
1758            UseCase::Coding,
1759            Privacy::OnDevice
1760        ));
1761    }
1762
1763    #[test]
1764    fn unified_fit_for_rows_whose_memory_is_not_this_machines() {
1765        // Remote: the server owns its memory → fits, no estimate, any platform.
1766        let cloud = cloud_row("remote/cloud");
1767        let fit = model_fit(&cloud, &mac(8), Some(&ResourcePolicy::everyday()));
1768        assert_eq!(fit.fit, ModelFitStatus::Fits);
1769        assert_eq!(fit.estimated_peak_mb, None);
1770        assert!(fit.platform_compatible);
1771        assert!(model_fit(&cloud, &hw(GpuBackend::Cpu, 8 * 1024, None), None).platform_compatible);
1772
1773        // A local row that declares neither size nor RAM: nothing to judge.
1774        let mut undeclared = local_model("local/undeclared", "Undeclared", "4B", 0);
1775        undeclared.cost.ram_mb = None;
1776        undeclared.cost.size_mb = None;
1777        let fit = model_fit(&undeclared, &mac(32), Some(&ResourcePolicy::everyday()));
1778        assert_eq!(fit.fit, ModelFitStatus::Unknown);
1779        assert_eq!(fit.estimated_peak_mb, None);
1780
1781        // OS-owned rows fit by definition because their memory is outside the
1782        // CAR model budget. Their own platform requirement remains separate.
1783        let mut foundation = local_model("apple/foundation:default", "Apple", "", 0);
1784        foundation.source = ModelSource::AppleFoundationModels { use_case: None };
1785        foundation.cost.ram_mb = None;
1786        foundation.cost.size_mb = None;
1787        let on_mac = model_fit(&foundation, &mac(8), Some(&ResourcePolicy::everyday()));
1788        assert_eq!(on_mac.fit, ModelFitStatus::Fits);
1789        assert_eq!(on_mac.estimated_peak_mb, None);
1790        assert!(on_mac.platform_compatible);
1791        assert!(
1792            !model_fit(&foundation, &hw(GpuBackend::Cpu, 64 * 1024, None), None)
1793                .platform_compatible
1794        );
1795
1796        let mut windows = local_model("windows/speech-synthesis:os", "Windows", "", 0);
1797        windows.source = ModelSource::WindowsSpeech {};
1798        windows.cost.ram_mb = None;
1799        windows.cost.size_mb = None;
1800        let on_mac = model_fit(&windows, &mac(8), Some(&ResourcePolicy::everyday()));
1801        assert_eq!(on_mac.fit, ModelFitStatus::Fits);
1802        assert_eq!(on_mac.estimated_peak_mb, None);
1803        assert!(!on_mac.platform_compatible);
1804        let mut windows_host = hw(GpuBackend::Cpu, 8 * 1024, None);
1805        windows_host.os = "windows".into();
1806        assert!(model_fit(&windows, &windows_host, None).platform_compatible);
1807
1808        // The explicit platform tags cover future sources without teaching
1809        // this rule another backend variant.
1810        let mut linux = undeclared.clone();
1811        linux.tags.push("linux-only".into());
1812        let mut linux_host = hw(GpuBackend::Cpu, 8 * 1024, None);
1813        linux_host.os = "linux".into();
1814        assert!(platform_compatible(&linux, &linux_host));
1815        assert!(!platform_compatible(&linux, &windows_host));
1816        let mut tagged_windows = undeclared.clone();
1817        tagged_windows.tags.push("windows-only".into());
1818        assert!(platform_compatible(&tagged_windows, &windows_host));
1819        assert!(!platform_compatible(&tagged_windows, &linux_host));
1820
1821        // Without a policy the legacy tier budget applies — unified memory
1822        // minus the 3 GB OS reserve, 5,120 MB on an 8 GB Mac — exactly as
1823        // `recommend` does: the 4B (≈3,940 MB) fits, the 8B (≈6,344 MB) does
1824        // not, and that is the partition the recommender publishes.
1825        let cat = qwen_mlx_policy_catalog();
1826        assert_eq!(model_fit(&cat[0], &mac(8), None).fit, ModelFitStatus::Fits);
1827        assert_eq!(
1828            model_fit(&cat[1], &mac(8), None).fit,
1829            ModelFitStatus::TooBig
1830        );
1831        let legacy = recommend(
1832            &refs(&cat),
1833            &mac(8),
1834            UseCase::Coding,
1835            QualityTier::Balanced,
1836            Privacy::OnDevice,
1837        );
1838        fn ids(set: &[Recommendation]) -> Vec<&str> {
1839            set.iter().map(|pick| pick.model_id.as_str()).collect()
1840        }
1841        assert_eq!(ids(&legacy.picks), vec!["mlx/qwen3-4b:4bit"]);
1842        assert_eq!(ids(&legacy.not_enough_memory), vec!["mlx/qwen3-8b:4bit"]);
1843    }
1844}
1845
1846#[cfg(test)]
1847mod local_server_fit_tests {
1848    use super::*;
1849    use crate::schema::{ModelCapability, ModelSource};
1850
1851    fn managed_vllm_model(id: &str, size_mb: u64) -> ModelSchema {
1852        let mut m = super::tests::local_model(id, id, "12B", size_mb);
1853        m.capabilities.push(ModelCapability::ToolUse);
1854        m.cost.ram_mb = Some(size_mb + size_mb / 4);
1855        m.source = ModelSource::ManagedVllmMlx {
1856            hf_repo: "mlx-community/whatever-4bit".into(),
1857            hf_weight_file: None,
1858        };
1859        m
1860    }
1861
1862    fn external_vllm_model(id: &str, endpoint: &str, size_mb: u64) -> ModelSchema {
1863        let mut m = super::tests::local_model(id, id, "12B", size_mb);
1864        m.capabilities.push(ModelCapability::ToolUse);
1865        m.cost.ram_mb = Some(size_mb + size_mb / 4);
1866        m.source = ModelSource::VllmMlx {
1867            endpoint: endpoint.to_string(),
1868            model_name: "externally-managed-model".into(),
1869        };
1870        m
1871    }
1872
1873    fn small_mac() -> HardwareInfo {
1874        super::tests::hw(crate::hardware::GpuBackend::Metal, 16384, Some(12288))
1875    }
1876
1877    /// The production break: an explicitly CAR-managed vLLM-MLX model was
1878    /// classified as server-provided, so a 20 GB allocation could be
1879    /// recommended to a 16 GB Mac without consuming the configured budget.
1880    #[test]
1881    fn managed_vllm_mlx_is_memory_checked_and_rejected_when_over_budget() {
1882        let big = managed_vllm_model("vllm-mlx/huge:4bit", 20_000);
1883        let set = recommend_with_policy(
1884            &[&big],
1885            &small_mac(),
1886            &ResourcePolicy::everyday(),
1887            UseCase::Assistant,
1888            QualityTier::Balanced,
1889            Privacy::OnDevice,
1890        );
1891
1892        assert!(set.picks.is_empty());
1893        assert_eq!(set.not_enough_memory.len(), 1);
1894        assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1895        assert_eq!(
1896            set.not_enough_memory[0].download_mb, 20_000,
1897            "CAR-managed vllm weights must retain their declared download size"
1898        );
1899    }
1900
1901    /// External vLLM is a cloud-consent/server-provided route regardless of the
1902    /// endpoint spelling or the hardware CAR itself is running on.
1903    #[test]
1904    fn external_vllm_mlx_requires_cloud_consent_and_is_cross_platform() {
1905        let machines = [
1906            small_mac(),
1907            super::tests::hw(crate::hardware::GpuBackend::Cpu, 16_384, None),
1908            super::tests::hw(crate::hardware::GpuBackend::Cuda, 16_384, Some(12_288)),
1909        ];
1910        for endpoint in [
1911            "http://localhost:8000",
1912            "http://127.0.0.1:8000",
1913            "https://gpu-owner.example/v1",
1914        ] {
1915            let external = external_vllm_model("external/vllm", endpoint, 20_000);
1916            for machine in &machines {
1917                let on_device = recommend_with_policy(
1918                    &[&external],
1919                    machine,
1920                    &ResourcePolicy::everyday(),
1921                    UseCase::Assistant,
1922                    QualityTier::Balanced,
1923                    Privacy::OnDevice,
1924                );
1925                assert!(
1926                    on_device.picks.is_empty(),
1927                    "external endpoint {endpoint} must require cloud consent on {:?}",
1928                    machine.gpu_backend
1929                );
1930
1931                let cloud_ok = recommend_with_policy(
1932                    &[&external],
1933                    machine,
1934                    &ResourcePolicy::everyday(),
1935                    UseCase::Assistant,
1936                    QualityTier::Balanced,
1937                    Privacy::CloudOk,
1938                );
1939                assert_eq!(
1940                    cloud_ok.picks.len(),
1941                    1,
1942                    "external endpoint {endpoint} on {:?}",
1943                    machine.gpu_backend
1944                );
1945                assert_eq!(cloud_ok.picks[0].fit, FitStatus::ServerProvided);
1946                assert_eq!(
1947                    cloud_ok.picks[0].download_mb, 0,
1948                    "external vllm owns its weights, so CAR has no download to report"
1949                );
1950                assert!(
1951                    cloud_ok.picks[0].rationale.contains("external server"),
1952                    "external vllm rationale must describe its actual owner: {}",
1953                    cloud_ok.picks[0].rationale
1954                );
1955                assert!(
1956                    !cloud_ok.picks[0].rationale.contains("Parslee's servers"),
1957                    "external vllm must not be attributed to Parslee: {}",
1958                    cloud_ok.picks[0].rationale
1959                );
1960            }
1961        }
1962    }
1963}
1964
1965#[cfg(test)]
1966mod catalog_capability_gap_tests {
1967    use super::*;
1968    use crate::schema::{ModelCapability, ModelSchema};
1969
1970    fn builtin() -> Vec<ModelSchema> {
1971        serde_json::from_str(include_str!("builtin_catalog.json")).unwrap()
1972    }
1973
1974    fn cuda_box(vram_gb: u64, ram_gb: u64) -> crate::hardware::HardwareInfo {
1975        super::tests::hw(
1976            crate::hardware::GpuBackend::Cuda,
1977            ram_gb * 1024,
1978            Some(vram_gb * 1024),
1979        )
1980    }
1981
1982    fn most_capable_on(ram_gb: u64) -> RecommendationSet {
1983        let catalog: &'static Vec<ModelSchema> = Box::leak(Box::new(builtin()));
1984        let refs: Vec<&ModelSchema> = catalog.iter().collect();
1985        recommend(
1986            &refs,
1987            &super::tests::mac(ram_gb),
1988            UseCase::Assistant,
1989            QualityTier::MostCapable,
1990            Privacy::OnDevice,
1991        )
1992    }
1993
1994    /// The outcome this whole area exists for: on a machine with room for it,
1995    /// "most capable" returns the highest-scoring model that machine can run —
1996    /// not the highest-scoring small one.
1997    ///
1998    /// It used to answer gemma-4-12B (6.6 GB) on a 64 GB Mac while `--tier
1999    /// fastest` answered a 35B, because every model carrying a `car-judged`
2000    /// score was an older, smaller one and an unscored model fell back to a
2001    /// size prior that saturates below what a real score reaches.
2002    #[test]
2003    fn most_capable_returns_the_best_model_the_machine_can_run() {
2004        let catalog = builtin();
2005        let set = most_capable_on(64);
2006        let top = set.picks.first().expect("a 64 GB machine has picks");
2007
2008        let top_score = catalog
2009            .iter()
2010            .find(|m| m.id == top.model_id)
2011            .and_then(|m| m.public_benchmarks.first())
2012            .map(|b| b.score)
2013            .unwrap_or(0.0);
2014
2015        for m in catalog
2016            .iter()
2017            .filter(|m| m.is_local() && m.size_mb() < 24_000)
2018        {
2019            if let Some(s) = m.public_benchmarks.first().map(|b| b.score) {
2020                assert!(
2021                    s <= top_score,
2022                    "{} scores {s} but {} ({top_score}) was recommended as most capable",
2023                    m.id,
2024                    top.model_id
2025                );
2026            }
2027        }
2028        assert!(
2029            top.download_mb > 10_000,
2030            "a 64 GB machine should be offered a large model, got {} at {} MB",
2031            top.model_id,
2032            top.download_mb
2033        );
2034    }
2035
2036    /// A small machine still gets something that fits — the capability tier
2037    /// must not simply always return the biggest model.
2038    #[test]
2039    fn a_small_machine_is_not_offered_a_model_it_cannot_hold() {
2040        let set = most_capable_on(8);
2041        if let Some(top) = set.picks.first() {
2042            assert!(
2043                top.fit != FitStatus::TooBig,
2044                "{} does not fit an 8 GB machine",
2045                top.model_id
2046            );
2047        }
2048    }
2049
2050    /// The disclosure fires only while larger models remain unscored, and never
2051    /// claims they are downloaded — available means CAR can fetch and run them.
2052    #[test]
2053    fn the_disclosure_is_accurate_when_it_appears() {
2054        let set = most_capable_on(64);
2055        if let Some(note) = set.note.as_deref() {
2056            if note.contains("unscored") {
2057                assert!(note.contains("bench-contribute"), "must say how: {note}");
2058                assert!(
2059                    !note.contains("installed"),
2060                    "availability is not installation: {note}"
2061                );
2062            }
2063        }
2064    }
2065
2066    /// It is scoped to the tier that makes the capability claim.
2067    #[test]
2068    fn other_tiers_do_not_carry_the_disclosure() {
2069        let catalog = builtin();
2070        let refs: Vec<&ModelSchema> = catalog.iter().collect();
2071        for tier in [QualityTier::Fastest, QualityTier::Balanced] {
2072            let set = recommend(
2073                &refs,
2074                &super::tests::mac(64),
2075                UseCase::Assistant,
2076                tier,
2077                Privacy::OnDevice,
2078            );
2079            let carries = set.note.as_deref().is_some_and(|n| n.contains("unscored"));
2080            assert!(!carries, "{tier:?} should not carry the disclosure");
2081        }
2082    }
2083
2084    /// A CUDA machine must be offered a local model at all. GGUF is its local
2085    /// path — `backend::candle` is compiled on exactly the targets MLX is not —
2086    /// so the catalog's `local` rows are its whole local story.
2087    ///
2088    /// Catalog coverage, not a regression test for the availability fix:
2089    /// `recommend` does not read `ModelSchema::available`. That gates the
2090    /// router's fallback chains and is covered in
2091    /// `registry::local_availability_tests`.
2092    #[test]
2093    fn a_cuda_machine_is_offered_a_local_model() {
2094        let catalog = builtin();
2095        let refs: Vec<&ModelSchema> = catalog.iter().collect();
2096        let set = recommend(
2097            &refs,
2098            &cuda_box(24, 64),
2099            UseCase::Assistant,
2100            QualityTier::MostCapable,
2101            Privacy::OnDevice,
2102        );
2103        assert!(
2104            set.picks.iter().any(|p| p.is_local),
2105            "a 24 GB CUDA GPU must be offered something local, got {:?}",
2106            set.picks.iter().map(|p| &p.model_id).collect::<Vec<_>>()
2107        );
2108    }
2109
2110    /// A blank `param_count` is not neutral: the total falls back to
2111    /// `size_mb / 600` and the *active* count falls back to that total, so a
2112    /// 4-bit MoE reads as a dense model of its whole on-disk size.
2113    #[test]
2114    fn local_generate_models_declare_their_parameter_count() {
2115        let blank: Vec<String> = builtin()
2116            .iter()
2117            .filter(|m| {
2118                m.capabilities.contains(&ModelCapability::Generate)
2119                    && m.is_local()
2120                    && m.param_count.trim().is_empty()
2121            })
2122            .map(|m| m.id.clone())
2123            .collect();
2124        assert!(
2125            blank.is_empty(),
2126            "local generate models with no param_count: {blank:?}"
2127        );
2128    }
2129
2130    /// An MoE's `(N active)` hint is what separates its latency from its size.
2131    #[test]
2132    fn an_moe_is_scored_on_its_active_parameters() {
2133        let catalog = builtin();
2134        let glm = catalog
2135            .iter()
2136            .find(|m| m.id == "vllm-mlx/glm-4.7-flash:4bit")
2137            .expect("catalog entry");
2138        let active = crate::resource_policy::model_parameter_billions_active(glm);
2139        let total = crate::resource_policy::model_parameter_billions_total(glm);
2140        assert!(
2141            active < 6.0,
2142            "top-4-of-64 MoE runs at a few B active, got {active}"
2143        );
2144        assert!(total > 20.0, "and carries 30B-class knowledge, got {total}");
2145    }
2146}