1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum FitStatus {
34 Fits,
36 TooBig,
38 ServerProvided,
41 Unknown,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Recommendation {
48 pub model_id: String,
50 pub display_name: String,
52 pub role: UseCaseRole,
54 pub rationale: String,
57 pub download_mb: u64,
59 pub already_installed: bool,
61 pub fit: FitStatus,
63 pub acceleration: SupportedAcceleration,
65 pub is_local: bool,
67 pub requires_cloud_consent: bool,
70 pub trust_tier: TrustTier,
72 pub score: f32,
74 #[serde(default = "default_true")]
81 pub within_recommendation_target: bool,
82}
83
84const fn default_true() -> bool {
85 true
86}
87
88const OS_RESERVE_MB: u64 = 3072;
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RecommendationSet {
97 pub picks: Vec<Recommendation>,
100 pub not_enough_memory: Vec<Recommendation>,
103 pub note: Option<String>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
114#[serde(rename_all = "snake_case")]
115pub enum ModelFitStatus {
116 Fits,
120 TooBig,
122 #[default]
127 Unknown,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct ModelFit {
134 pub fit: ModelFitStatus,
135 pub estimated_peak_mb: Option<u64>,
140 pub platform_compatible: bool,
142}
143
144pub 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 _ 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
188pub 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
206pub 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 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 v.sort_by(|a, b| {
254 if everyday_assistant_balanced {
255 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, ¬_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 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
377fn 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 if !use_case
391 .required_capabilities()
392 .iter()
393 .all(|c| m.has_capability(*c))
394 {
395 return false;
396 }
397 if privacy == Privacy::OnDevice && !m.is_local() {
399 return false;
400 }
401 platform_compatible(m, hw)
403}
404
405pub 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 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
431fn 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 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
475fn 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 let mut score =
563 w.quality * quality + w.latency * latency + w.memory_pressure * (1.0 - pressure);
564 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
623fn 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
632fn 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
650fn quality_score(m: &ModelSchema, ceiling: f32) -> f32 {
654 if let Some(measured) = measured_quality(m) {
655 return measured;
656 }
657 let b = param_billions_total(m).max(0.1);
661 ((b / (b + 7.0)) * ceiling).clamp(0.0, 1.0)
662}
663
664fn latency_score(m: &ModelSchema, accel: &SupportedAcceleration) -> f32 {
667 let b = param_billions_active(m).max(0.1);
668 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
677fn 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
686fn fit_status(
688 m: &ModelSchema,
689 hw: &HardwareInfo,
690 estimate: &crate::resource_policy::ModelMemoryEstimate,
691 memory_limits: &RecommendationMemoryLimits,
692) -> FitStatus {
693 if m.is_remote() || m.is_delegated() {
697 return FitStatus::ServerProvided;
698 }
699 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
734pub(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 _ => hw.total_ram_mb.saturating_sub(OS_RESERVE_MB),
747 }
748}
749
750fn param_billions_total(m: &ModelSchema) -> f32 {
755 model_parameter_billions_total(m)
756}
757
758fn param_billions_active(m: &ModelSchema) -> f32 {
761 model_parameter_billions_active(m)
762}
763
764fn 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 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 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 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 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 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 #[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 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 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), 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), 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 assert!(matches!(
1493 recs[0].display_name.as_str(),
1494 "Qwen3-4B" | "Qwen3-8B"
1495 ));
1496 }
1497
1498 #[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; 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 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 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 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 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), 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), 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 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 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 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 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 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 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 #[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 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 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 #[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 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 #[test]
2046 fn a_model_larger_than_any_machine_is_too_big_on_every_machine() {
2047 let policy = ResourcePolicy::everyday();
2048 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 ("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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}