Skip to main content

car_inference/
recommend.rs

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