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 #[serde(default)]
87 pub estimated_peak_mb: u64,
88}
89
90const fn default_true() -> bool {
91 true
92}
93
94const OS_RESERVE_MB: u64 = 3072;
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RecommendationSet {
103 pub picks: Vec<Recommendation>,
106 pub not_enough_memory: Vec<Recommendation>,
109 pub note: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub memory_ceiling_mb: Option<u64>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
126#[serde(rename_all = "snake_case")]
127pub enum ModelFitStatus {
128 Fits,
132 TooBig,
134 #[default]
139 Unknown,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct ModelFit {
146 pub fit: ModelFitStatus,
147 pub estimated_peak_mb: Option<u64>,
152 pub platform_compatible: bool,
154}
155
156pub fn model_fit(
172 m: &ModelSchema,
173 hw: &HardwareInfo,
174 policy: Option<&ResourcePolicy>,
175 geometry: &CheckpointGeometry,
176) -> ModelFit {
177 let memory_limits = RecommendationMemoryLimits::for_policy(hw, policy);
178 let estimate =
179 estimate_model_memory_for_checkpoint(m, hw, RECOMMENDATION_CONTEXT_TOKENS, geometry);
180 let status = fit_status(m, hw, &estimate, &memory_limits);
181 let fit = match status {
182 FitStatus::Fits | FitStatus::ServerProvided => ModelFitStatus::Fits,
183 FitStatus::TooBig => ModelFitStatus::TooBig,
184 FitStatus::Unknown => ModelFitStatus::Unknown,
185 };
186 let estimated_peak_mb = match status {
187 FitStatus::ServerProvided => None,
188 _ if m.size_mb() == 0 && m.ram_mb() == 0 => None,
191 _ => Some(estimate.estimated_peak_mb),
192 };
193 ModelFit {
194 fit,
195 estimated_peak_mb,
196 platform_compatible: platform_compatible(m, hw),
197 }
198}
199
200pub fn recommend(
208 models: &[&ModelSchema],
209 hw: &HardwareInfo,
210 use_case: UseCase,
211 tier: QualityTier,
212 privacy: Privacy,
213 geometry: &CheckpointGeometry,
214) -> RecommendationSet {
215 recommend_inner(models, hw, use_case, tier, privacy, None, geometry)
216}
217
218pub fn recommend_with_policy(
222 models: &[&ModelSchema],
223 hw: &HardwareInfo,
224 policy: &ResourcePolicy,
225 use_case: UseCase,
226 tier: QualityTier,
227 privacy: Privacy,
228 geometry: &CheckpointGeometry,
229) -> RecommendationSet {
230 recommend_inner(models, hw, use_case, tier, privacy, Some(policy), geometry)
231}
232
233fn recommend_inner(
234 models: &[&ModelSchema],
235 hw: &HardwareInfo,
236 use_case: UseCase,
237 tier: QualityTier,
238 privacy: Privacy,
239 policy: Option<&ResourcePolicy>,
240 geometry: &CheckpointGeometry,
241) -> RecommendationSet {
242 let accel = hw.supported_acceleration();
243 let assistant_requires_tools = use_case == UseCase::Assistant;
255 let everyday_assistant_balanced = policy.is_some_and(|policy| {
256 policy.profile == ResourceProfile::Everyday
257 && use_case == UseCase::Assistant
258 && tier == QualityTier::Balanced
259 });
260 let memory_limits = RecommendationMemoryLimits::for_policy(hw, policy);
261 let sort = |v: &mut Vec<RankedRecommendation>| {
262 v.sort_by(|a, b| {
266 if everyday_assistant_balanced {
267 let policy_class = |recommendation: &Recommendation| {
272 if recommendation.fit == FitStatus::Unknown {
273 3
274 } else if recommendation.is_local && recommendation.within_recommendation_target
275 {
276 0
277 } else if !recommendation.is_local
278 && recommendation.within_recommendation_target
279 {
280 1
281 } else {
282 2
283 }
284 };
285 let a_recommendation = &a.recommendation;
286 let b_recommendation = &b.recommendation;
287 return policy_class(a_recommendation)
288 .cmp(&policy_class(b_recommendation))
289 .then(
290 b_recommendation
291 .already_installed
292 .cmp(&a_recommendation.already_installed),
293 )
294 .then(a.estimated_peak_mb.cmp(&b.estimated_peak_mb))
295 .then(a.latency_p50_ms.cmp(&b.latency_p50_ms))
296 .then_with(|| b_recommendation.score.total_cmp(&a_recommendation.score))
297 .then(a_recommendation.model_id.cmp(&b_recommendation.model_id));
298 }
299 b.recommendation
300 .score
301 .total_cmp(&a.recommendation.score)
302 .then(
303 b.recommendation
304 .already_installed
305 .cmp(&a.recommendation.already_installed),
306 )
307 .then(
308 a.recommendation
309 .download_mb
310 .cmp(&b.recommendation.download_mb),
311 )
312 .then(a.recommendation.model_id.cmp(&b.recommendation.model_id))
313 });
314 };
315
316 let size_prior_ceiling = size_prior_ceiling(models);
317 let (mut picks, mut not_enough_memory): (Vec<_>, Vec<_>) = models
318 .iter()
319 .filter(|m| {
320 passes_base_filter(m, hw, use_case, privacy)
321 && (!assistant_requires_tools
322 || m.has_capability(crate::schema::ModelCapability::ToolUse))
323 })
324 .map(|model| {
325 build_recommendation(
326 model,
327 hw,
328 &accel,
329 use_case,
330 tier,
331 &memory_limits,
332 geometry,
333 size_prior_ceiling,
334 )
335 })
336 .partition(|ranked| ranked.recommendation.fit != FitStatus::TooBig);
337 sort(&mut picks);
338 sort(&mut not_enough_memory);
339 let picks: Vec<_> = picks
340 .into_iter()
341 .map(|ranked| ranked.recommendation)
342 .collect();
343 let not_enough_memory: Vec<_> = not_enough_memory
344 .into_iter()
345 .map(|ranked| ranked.recommendation)
346 .collect();
347
348 let note = explain_if_needed(&picks, ¬_enough_memory, hw, use_case, tier, privacy)
349 .or_else(|| unmeasured_larger_candidates(models, &picks, tier));
350 RecommendationSet {
351 picks,
352 not_enough_memory,
353 note,
354 memory_ceiling_mb: memory_limits.policy_host_budget_mb,
355 }
356}
357
358struct RankedRecommendation {
359 recommendation: Recommendation,
360 estimated_peak_mb: u64,
361 latency_p50_ms: u64,
362}
363
364#[derive(Clone, Copy)]
365struct RecommendationMemoryLimits {
366 legacy_budget_mb: u64,
367 policy_host_budget_mb: Option<u64>,
368 recommendation_target_mb: Option<u64>,
369}
370
371impl RecommendationMemoryLimits {
372 fn for_policy(hw: &HardwareInfo, policy: Option<&ResourcePolicy>) -> Self {
377 Self {
378 legacy_budget_mb: memory_budget_mb(hw),
379 policy_host_budget_mb: policy.map(|policy| {
380 policy
381 .effective_budget(hw.total_ram_mb)
382 .configured_model_ceiling_mb
383 }),
384 recommendation_target_mb: policy
385 .map(|policy| policy.recommendation_target_mb(hw.total_ram_mb)),
386 }
387 }
388}
389
390fn passes_base_filter(
394 m: &ModelSchema,
395 hw: &HardwareInfo,
396 use_case: UseCase,
397 privacy: Privacy,
398) -> bool {
399 if m.deprecated {
400 return false;
401 }
402 if !use_case
404 .required_capabilities()
405 .iter()
406 .all(|c| m.has_capability(*c))
407 {
408 return false;
409 }
410 if privacy == Privacy::OnDevice && !m.is_local() {
412 return false;
413 }
414 platform_compatible(m, hw)
416}
417
418pub fn platform_compatible(m: &ModelSchema, hw: &HardwareInfo) -> bool {
426 let windows_only = matches!(m.source, crate::schema::ModelSource::WindowsSpeech { .. })
427 || m.tags.iter().any(|tag| tag == "windows-only");
428 let linux_only = m.tags.iter().any(|tag| tag == "linux-only");
429 let apple = matches!(
430 hw.supported_acceleration(),
431 SupportedAcceleration::Apple { .. }
432 );
433 let apple_compatible = !m.requires_apple_silicon() || apple;
434 let candle_runs_here = !(apple && matches!(m.source, crate::schema::ModelSource::Local { .. }));
439 let windows_compatible = !windows_only || hw.os.eq_ignore_ascii_case("windows");
440 let linux_compatible = !linux_only || hw.os.eq_ignore_ascii_case("linux");
441 apple_compatible && candle_runs_here && windows_compatible && linux_compatible
442}
443
444fn explain_if_needed(
446 picks: &[Recommendation],
447 too_big: &[Recommendation],
448 hw: &HardwareInfo,
449 use_case: UseCase,
450 tier: QualityTier,
451 privacy: Privacy,
452) -> Option<String> {
453 let purpose = use_case_purpose(use_case);
454 if picks.is_empty() {
455 let ram_gb = hw.total_ram_mb / 1024;
456 return Some(if !too_big.is_empty() {
457 match privacy {
458 Privacy::OnDevice => format!(
459 "No on-device model for {purpose} fits your {ram_gb} GB machine. \
460 Free up memory, pick a smaller tier, or allow cloud models."
461 ),
462 Privacy::CloudOk => format!(
463 "No local model for {purpose} fits your {ram_gb} GB machine, and no \
464 cloud model is configured. Add an API key or free up memory."
465 ),
466 }
467 } else {
468 format!("No model available for {purpose} on this machine.")
469 });
470 }
471 if picks[0].requires_cloud_consent {
474 return Some(format!(
475 "The best {purpose} pick runs in the cloud and needs your OK before first use. \
476 {} fits locally if you prefer on-device.",
477 picks
478 .iter()
479 .find(|p| p.is_local)
480 .map(|p| p.display_name.as_str())
481 .unwrap_or("No local model")
482 ));
483 }
484 let _ = tier;
485 None
486}
487
488fn unmeasured_larger_candidates(
500 models: &[&ModelSchema],
501 picks: &[Recommendation],
502 tier: QualityTier,
503) -> Option<String> {
504 if tier != QualityTier::MostCapable {
505 return None;
506 }
507 let top = picks.first()?;
508 let top_params = models
509 .iter()
510 .find(|m| m.id == top.model_id)
511 .map(|m| param_billions_total(m))?;
512
513 let mut larger: Vec<&str> = models
514 .iter()
515 .filter(|m| {
516 m.is_local()
517 && m.public_benchmarks.is_empty()
518 && param_billions_total(m) > top_params * 1.5
519 })
520 .map(|m| m.name.as_str())
521 .collect();
522 if larger.is_empty() {
523 return None;
524 }
525 larger.sort_unstable();
526 larger.dedup();
527 let shown = larger
528 .iter()
529 .take(3)
530 .copied()
531 .collect::<Vec<_>>()
532 .join(", ");
533 let rest = larger.len().saturating_sub(3);
534 let and_more = if rest > 0 {
535 format!(" and {rest} more")
536 } else {
537 String::new()
538 };
539 Some(format!(
540 "Ranked among models with a measured quality score. Larger ones this \
541 machine can run are unscored, so they cannot be ranked here yet: \
542 {shown}{and_more}. Run `scripts/bench-contribute.sh` to score them."
543 ))
544}
545
546fn use_case_purpose(use_case: UseCase) -> &'static str {
547 match use_case {
548 UseCase::Assistant => "chat & general help",
549 UseCase::Coding => "coding",
550 UseCase::Summarize => "summarizing",
551 UseCase::Vision => "understanding images",
552 UseCase::Transcription => "transcription",
553 UseCase::Search => "semantic search",
554 }
555}
556
557fn build_recommendation(
558 m: &ModelSchema,
559 hw: &HardwareInfo,
560 accel: &SupportedAcceleration,
561 use_case: UseCase,
562 tier: QualityTier,
563 memory_limits: &RecommendationMemoryLimits,
564 geometry: &CheckpointGeometry,
565 size_prior_ceiling: f32,
566) -> RankedRecommendation {
567 let estimate =
568 estimate_model_memory_for_checkpoint(m, hw, RECOMMENDATION_CONTEXT_TOKENS, geometry);
569 let fit = fit_status(m, hw, &estimate, memory_limits);
570 let quality = quality_score(m, size_prior_ceiling);
571 let latency = latency_score(m, accel);
572 let pressure = memory_pressure(&estimate, memory_limits.legacy_budget_mb);
573 let w = tier.weights();
574 let mut score =
576 w.quality * quality + w.latency * latency + w.memory_pressure * (1.0 - pressure);
577 let pref_hits = use_case
579 .preferred_capabilities()
580 .iter()
581 .filter(|c| m.has_capability(**c))
582 .count();
583 score += 0.05 * pref_hits as f32;
584
585 let is_local = m.is_local();
586 let within_recommendation_target = match memory_limits.recommendation_target_mb {
587 None => true,
588 Some(_) if fit == FitStatus::Unknown => false,
589 Some(target_mb) if is_local => match hw.supported_acceleration() {
590 SupportedAcceleration::Cuda {
591 device_memory_mb: Some(device_memory_mb),
592 } => {
593 let host_required_mb = estimate
594 .estimated_peak_mb
595 .saturating_sub(estimate.weights_mb);
596 fit == FitStatus::Fits
597 && estimate.weights_mb <= device_memory_mb
598 && host_required_mb <= target_mb
599 }
600 SupportedAcceleration::Cuda {
601 device_memory_mb: None,
602 } => false,
603 _ => fit == FitStatus::Fits && estimate.estimated_peak_mb <= target_mb,
604 },
605 Some(_) => true,
606 };
607 RankedRecommendation {
608 estimated_peak_mb: if is_local {
609 estimate.estimated_peak_mb
610 } else {
611 0
612 },
613 latency_p50_ms: m.performance.latency_p50_ms.unwrap_or(u64::MAX),
614 recommendation: Recommendation {
615 model_id: m.id.clone(),
616 display_name: m.name.clone(),
617 role: use_case.role(),
618 rationale: rationale(m, hw, use_case, tier, fit, quality),
619 download_mb: if m.downloads_weights() {
620 m.size_mb()
621 } else {
622 0
623 },
624 already_installed: m.has_installed_weights(),
625 fit,
626 acceleration: accel.clone(),
627 is_local,
628 requires_cloud_consent: !is_local,
629 trust_tier: m.trust_tier,
630 score,
631 within_recommendation_target,
632 estimated_peak_mb: if is_local {
633 estimate.estimated_peak_mb
634 } else {
635 0
636 },
637 },
638 }
639}
640
641fn measured_quality(m: &ModelSchema) -> Option<f32> {
643 if m.public_benchmarks.is_empty() {
644 return None;
645 }
646 let sum: f64 = m.public_benchmarks.iter().map(|b| b.score).sum();
647 Some((sum / m.public_benchmarks.len() as f64).clamp(0.0, 1.0) as f32)
648}
649
650fn size_prior_ceiling(models: &[&ModelSchema]) -> f32 {
660 models
661 .iter()
662 .filter(|m| m.is_local())
663 .filter_map(|m| measured_quality(m))
664 .reduce(f32::max)
665 .unwrap_or(1.0)
666}
667
668fn quality_score(m: &ModelSchema, ceiling: f32) -> f32 {
672 if let Some(measured) = measured_quality(m) {
673 return measured;
674 }
675 let b = param_billions_total(m).max(0.1);
679 ((b / (b + 7.0)) * ceiling).clamp(0.0, 1.0)
680}
681
682fn latency_score(m: &ModelSchema, accel: &SupportedAcceleration) -> f32 {
685 let b = param_billions_active(m).max(0.1);
686 let size_term = 8.0 / (b + 8.0);
688 let accel_bonus = match accel {
689 SupportedAcceleration::Apple { .. } | SupportedAcceleration::Cuda { .. } => 0.1,
690 _ => 0.0,
691 };
692 (size_term + accel_bonus).clamp(0.0, 1.0)
693}
694
695fn memory_pressure(estimate: &crate::resource_policy::ModelMemoryEstimate, budget: u64) -> f32 {
698 if budget == 0 {
699 return 1.0;
700 }
701 (estimate.estimated_peak_mb as f32 / budget as f32).clamp(0.0, 1.5)
702}
703
704fn fit_status(
706 m: &ModelSchema,
707 hw: &HardwareInfo,
708 estimate: &crate::resource_policy::ModelMemoryEstimate,
709 memory_limits: &RecommendationMemoryLimits,
710) -> FitStatus {
711 if m.is_remote() || m.is_delegated() {
715 return FitStatus::ServerProvided;
716 }
717 if m.is_os_provided() {
721 return FitStatus::Fits;
722 }
723 if m.size_mb() == 0 && m.ram_mb() == 0 {
724 return FitStatus::Unknown;
725 }
726 let required_mb = estimate.estimated_peak_mb;
727 let fits = if let Some(host_budget) = memory_limits.policy_host_budget_mb {
728 match hw.supported_acceleration() {
729 SupportedAcceleration::Cuda {
730 device_memory_mb: Some(device_memory_mb),
731 } => {
732 let host_required_mb = estimate
733 .estimated_peak_mb
734 .saturating_sub(estimate.weights_mb);
735 estimate.weights_mb <= device_memory_mb && host_required_mb <= host_budget
736 }
737 SupportedAcceleration::Cuda {
738 device_memory_mb: None,
739 } => return FitStatus::Unknown,
740 _ => required_mb <= host_budget,
741 }
742 } else {
743 required_mb <= memory_limits.legacy_budget_mb
744 };
745 if fits {
746 FitStatus::Fits
747 } else {
748 FitStatus::TooBig
749 }
750}
751
752pub(crate) fn memory_budget_mb(hw: &HardwareInfo) -> u64 {
756 match hw.supported_acceleration() {
757 SupportedAcceleration::Apple { unified_memory_mb } => {
758 unified_memory_mb.saturating_sub(OS_RESERVE_MB)
759 }
760 SupportedAcceleration::Cuda { device_memory_mb } => {
761 device_memory_mb.unwrap_or(hw.total_ram_mb)
762 }
763 _ => hw.total_ram_mb.saturating_sub(OS_RESERVE_MB),
765 }
766}
767
768fn param_billions_total(m: &ModelSchema) -> f32 {
773 model_parameter_billions_total(m)
774}
775
776fn param_billions_active(m: &ModelSchema) -> f32 {
779 model_parameter_billions_active(m)
780}
781
782fn rationale(
785 m: &ModelSchema,
786 hw: &HardwareInfo,
787 use_case: UseCase,
788 tier: QualityTier,
789 fit: FitStatus,
790 quality: f32,
791) -> String {
792 let purpose = use_case_purpose(use_case);
793 let machine = match hw.supported_acceleration() {
794 SupportedAcceleration::Apple { unified_memory_mb } => {
795 format!(
796 "your {} GB Apple Silicon Mac (Metal)",
797 unified_memory_mb / 1024
798 )
799 }
800 SupportedAcceleration::Cuda { device_memory_mb } => match device_memory_mb {
801 Some(mb) => format!("your {} GB NVIDIA GPU (CUDA)", mb / 1024),
802 None => "your NVIDIA GPU (CUDA)".to_string(),
803 },
804 SupportedAcceleration::UnsupportedDiscreteGpu { .. } | SupportedAcceleration::Cpu => {
805 format!("your {} GB machine (CPU)", hw.total_ram_mb / 1024)
806 }
807 };
808
809 match fit {
810 FitStatus::ServerProvided
811 if matches!(&m.source, crate::schema::ModelSource::VllmMlx { .. }) =>
812 {
813 format!(
814 "{}: external server for {} — its operator runs the model, nothing to download",
815 m.name, purpose
816 )
817 }
818 FitStatus::ServerProvided if m.is_remote() => format!(
819 "{}: cloud model for {} — runs on Parslee's servers, nothing to download",
820 m.name, purpose
821 ),
822 FitStatus::ServerProvided => format!(
823 "{}: served externally for {} — no local memory needed",
824 m.name, purpose
825 ),
826 _ => {
827 let tier_word = match tier {
828 QualityTier::Fastest => "fastest",
829 QualityTier::Balanced => "best-balanced",
830 QualityTier::MostCapable => "most capable",
831 };
832 let quality_note = if quality >= 0.7 { "high-quality " } else { "" };
833 let size = if m.size_mb() >= 1024 {
834 format!("{:.1} GB download", m.size_mb() as f64 / 1024.0)
835 } else {
836 format!("{} MB download", m.size_mb())
837 };
838 format!(
839 "{}: the {} {}{} model that fits {} ({})",
840 m.name, tier_word, quality_note, purpose, machine, size
841 )
842 }
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::hardware::{GpuBackend, GpuDevice, GpuVendor};
850 use crate::resource_policy::estimate_model_memory;
851 use crate::schema::{CostModel, ModelCapability, ModelSource, PerformanceEnvelope};
852
853 pub(super) fn hw(accel_backend: GpuBackend, ram_mb: u64, gpu_mb: Option<u64>) -> HardwareInfo {
854 HardwareInfo {
855 os: "test".into(),
856 arch: "test".into(),
857 cpu_cores: 8,
858 total_ram_mb: ram_mb,
859 gpu_backend: accel_backend,
860 gpu_memory_mb: gpu_mb,
861 gpu_devices: vec![],
862 recommended_model: String::new(),
863 recommended_context: 4096,
864 max_model_mb: 0,
865 }
866 }
867
868 pub(super) fn mac(ram_gb: u64) -> HardwareInfo {
869 hw(GpuBackend::Metal, ram_gb * 1024, None)
871 }
872
873 pub(super) fn local_model(id: &str, name: &str, params: &str, size_mb: u64) -> ModelSchema {
874 ModelSchema {
875 id: id.into(),
876 name: name.into(),
877 provider: "qwen".into(),
878 family: "qwen3".into(),
879 version: String::new(),
880 capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
881 context_length: 32768,
882 max_output_tokens: None,
883 param_count: params.into(),
884 quantization: Some(crate::schema::Quantization::parse("Q4_K_M")),
885 performance: PerformanceEnvelope::default(),
886 cost: CostModel {
887 size_mb: Some(size_mb),
888 ram_mb: Some(size_mb),
889 ..Default::default()
890 },
891 source: ModelSource::Local {
892 hf_repo: "x/y".into(),
893 hf_filename: "m.gguf".into(),
894 tokenizer_repo: "x/y".into(),
895 },
896 tags: vec![],
897 supported_params: vec![],
898 public_benchmarks: vec![],
899 trust_tier: TrustTier::Curated,
900 deprecated: false,
901 available: false,
902 weights_ready: false,
903 }
904 }
905
906 fn catalog() -> Vec<ModelSchema> {
907 vec![
908 local_model("qwen/qwen3-0.6b", "Qwen3-0.6B", "0.6B", 650),
909 local_model("qwen/qwen3-4b", "Qwen3-4B", "4B", 2500),
910 local_model("qwen/qwen3-8b", "Qwen3-8B", "8B", 4900),
911 local_model("qwen/qwen3-30b", "Qwen3-30B-A3B", "30B (3B active)", 17000),
912 ]
913 }
914
915 fn mac_catalog() -> Vec<ModelSchema> {
918 catalog().into_iter().map(as_mlx).collect()
919 }
920
921 fn as_mlx(mut m: ModelSchema) -> ModelSchema {
922 let name =
923 m.id.rsplit('/')
924 .next()
925 .expect("rsplit yields one")
926 .to_string();
927 m.id = format!("mlx/{name}");
928 m.source = ModelSource::Mlx {
929 hf_repo: format!("mlx-community/{}", m.name),
930 hf_weight_file: None,
931 };
932 m
933 }
934
935 fn qwen_mlx_policy_catalog() -> Vec<ModelSchema> {
936 let mut four = local_model("mlx/qwen3-4b:4bit", "Qwen3-4B-MLX", "4B", 2400);
937 four.source = ModelSource::Mlx {
938 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
939 hf_weight_file: None,
940 };
941 four.capabilities = vec![
942 ModelCapability::Generate,
943 ModelCapability::Code,
944 ModelCapability::ToolUse,
945 ];
946 four.performance.latency_p50_ms = Some(294);
947
948 let mut eight = local_model("mlx/qwen3-8b:4bit", "Qwen3-8B-MLX", "8B", 4800);
949 eight.source = ModelSource::Mlx {
950 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
951 hf_weight_file: None,
952 };
953 eight.capabilities = vec![
954 ModelCapability::Generate,
955 ModelCapability::Code,
956 ModelCapability::ToolUse,
957 ];
958 eight.performance.latency_p50_ms = Some(451);
959 vec![four, eight]
960 }
961
962 fn refs(v: &[ModelSchema]) -> Vec<&ModelSchema> {
963 v.iter().collect()
964 }
965
966 #[test]
967 fn fastest_prefers_the_small_model() {
968 let cat = mac_catalog();
969 let recs = recommend(
970 &refs(&cat),
971 &mac(36),
972 UseCase::Coding,
973 QualityTier::Fastest,
974 Privacy::OnDevice,
975 &CheckpointGeometry::default(),
976 )
977 .picks;
978 assert_eq!(recs[0].display_name, "Qwen3-0.6B");
979 }
980
981 #[test]
982 fn downloadable_catalog_entry_is_not_installed_until_weights_are_ready() {
983 let mut model = qwen_mlx_policy_catalog().remove(0);
984 model.available = true;
985 model.weights_ready = false;
986
987 let set = recommend(
988 &[&model],
989 &mac(32),
990 UseCase::Assistant,
991 QualityTier::Balanced,
992 Privacy::OnDevice,
993 &CheckpointGeometry::default(),
994 );
995
996 assert_eq!(set.picks.len(), 1);
997 assert!(!set.picks[0].already_installed);
998 }
999
1000 #[test]
1005 fn the_35b_a3b_row_fits_a_64gb_mac_under_everyday() {
1006 let row = crate::registry::builtin_catalog()
1007 .into_iter()
1008 .find(|m| m.id == "vllm-mlx/qwen3.6-35b-a3b:4bit")
1009 .expect("catalog row");
1010 let policy = crate::resource_policy::ResourcePolicy::everyday();
1011 let ceiling = policy
1012 .effective_budget(64 * 1024)
1013 .configured_model_ceiling_mb;
1014 let peak =
1015 estimate_model_memory(&row, &mac(64), RECOMMENDATION_CONTEXT_TOKENS).estimated_peak_mb;
1016 assert!(
1017 peak <= ceiling,
1018 "peak {peak} MB over the {ceiling} MB ceiling"
1019 );
1020 assert!(
1021 peak >= 20_300,
1022 "peak {peak} MB under the measured residency"
1023 );
1024 let set = recommend_with_policy(
1025 &refs(&[row]),
1026 &mac(64),
1027 &policy,
1028 UseCase::Coding,
1029 QualityTier::MostCapable,
1030 Privacy::OnDevice,
1031 &CheckpointGeometry::default(),
1032 );
1033 assert!(
1034 set.not_enough_memory.is_empty(),
1035 "{:?}",
1036 set.not_enough_memory
1037 );
1038 assert_eq!(set.memory_ceiling_mb, Some(ceiling));
1039 assert!(
1040 set.picks
1041 .iter()
1042 .any(|pick| pick.model_id == "vllm-mlx/qwen3.6-35b-a3b:4bit"
1043 && pick.estimated_peak_mb == peak),
1044 "{:?}",
1045 set.picks
1046 );
1047 }
1048
1049 #[test]
1050 fn everyday_32gb_apple_assistant_balanced_prefers_four_b_and_keeps_eight_b() {
1051 let mut catalog = qwen_mlx_policy_catalog();
1052 let mut no_tools = local_model("mlx/qwen3-1.7b:3bit", "Qwen3-1.7B-MLX", "1.7B", 900);
1053 no_tools.source = ModelSource::Mlx {
1054 hf_repo: "mlx-community/Qwen3-1.7B-3bit".into(),
1055 hf_weight_file: None,
1056 };
1057 no_tools.capabilities = vec![ModelCapability::Generate];
1058 catalog.push(no_tools);
1059 let set = recommend_with_policy(
1060 &refs(&catalog),
1061 &mac(32),
1062 &crate::resource_policy::ResourcePolicy::everyday(),
1063 UseCase::Assistant,
1064 QualityTier::Balanced,
1065 Privacy::OnDevice,
1066 &CheckpointGeometry::default(),
1067 );
1068
1069 let ids: Vec<&str> = set
1070 .picks
1071 .iter()
1072 .map(|pick| pick.model_id.as_str())
1073 .collect();
1074 assert_eq!(ids, ["mlx/qwen3-4b:4bit", "mlx/qwen3-8b:4bit"]);
1075 let budget = crate::resource_policy::ResourcePolicy::everyday().effective_budget(32 * 1024);
1076 for model in &catalog[..2] {
1077 assert!(
1078 estimate_model_memory(model, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
1079 .estimated_peak_mb
1080 < budget.configured_model_ceiling_mb
1081 );
1082 }
1083 }
1084
1085 #[test]
1086 fn everyday_recommends_up_to_its_full_ceiling() {
1087 let mut four = qwen_mlx_policy_catalog().remove(0);
1088 four.cost.ram_mb = Some(3_500);
1089 four.cost.size_mb = Some(3_500);
1090 let mut nine = four.clone();
1091 nine.id = "mlx/qwen3-9b:4bit".into();
1092 nine.name = "Qwen3-9B-MLX".into();
1093 nine.param_count = "9B".into();
1094 nine.cost.ram_mb = Some(9_000);
1095 nine.cost.size_mb = Some(9_000);
1096 nine.weights_ready = true;
1097 nine.public_benchmarks = vec![crate::schema::BenchmarkScore {
1098 name: "quality".into(),
1099 score: 0.99,
1100 harness: None,
1101 source_url: None,
1102 measured_at: None,
1103 runs: None,
1104 spread: None,
1105 }];
1106 let catalog = vec![nine, four];
1107
1108 let set = recommend_with_policy(
1109 &refs(&catalog),
1110 &mac(32),
1111 &ResourcePolicy::everyday(),
1112 UseCase::Assistant,
1113 QualityTier::Balanced,
1114 Privacy::OnDevice,
1115 &CheckpointGeometry::default(),
1116 );
1117
1118 assert_eq!(set.picks[0].model_id, "mlx/qwen3-9b:4bit");
1123 assert_eq!(set.picks[1].model_id, "mlx/qwen3-4b:4bit");
1124 assert!(set.picks[0].within_recommendation_target);
1125 assert!(set.picks[1].within_recommendation_target);
1126
1127 let only_heavy = recommend_with_policy(
1128 &[&catalog[0]],
1129 &mac(32),
1130 &ResourcePolicy::everyday(),
1131 UseCase::Assistant,
1132 QualityTier::Balanced,
1133 Privacy::OnDevice,
1134 &CheckpointGeometry::default(),
1135 );
1136 assert_eq!(only_heavy.picks[0].fit, FitStatus::Fits);
1137 assert!(only_heavy.picks[0].within_recommendation_target);
1138 }
1139
1140 #[test]
1141 fn everyday_ranking_is_permutation_stable_across_local_and_cloud_candidates() {
1142 let mut four = qwen_mlx_policy_catalog().remove(0);
1143 four.cost.ram_mb = Some(3_500);
1144 four.cost.size_mb = Some(3_500);
1145
1146 let mut nine = four.clone();
1147 nine.id = "mlx/qwen3-9b:4bit".into();
1148 nine.name = "Qwen3-9B-MLX".into();
1149 nine.param_count = "9B".into();
1150 nine.cost.ram_mb = Some(9_000);
1151 nine.cost.size_mb = Some(9_000);
1152 nine.public_benchmarks = vec![crate::schema::BenchmarkScore {
1153 name: "quality".into(),
1154 score: 0.99,
1155 harness: None,
1156 source_url: None,
1157 measured_at: None,
1158 runs: None,
1159 spread: None,
1160 }];
1161
1162 let mut cloud = four.clone();
1163 cloud.id = "remote/tool-use".into();
1164 cloud.name = "ToolUse Cloud".into();
1165 cloud.source = ModelSource::RemoteApi {
1166 endpoint: "https://example.invalid".into(),
1167 api_key_env: "TEST_KEY".into(),
1168 api_key_envs: vec![],
1169 api_version: None,
1170 protocol: crate::schema::ApiProtocol::OpenAiCompat,
1171 };
1172 cloud.cost.ram_mb = None;
1173 cloud.cost.size_mb = None;
1174 cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1175 name: "quality".into(),
1176 score: 1.0,
1177 harness: None,
1178 source_url: None,
1179 measured_at: None,
1180 runs: None,
1181 spread: None,
1182 }];
1183
1184 let candidates = [four, nine, cloud];
1185 let permutations = [
1186 [0, 1, 2],
1187 [0, 2, 1],
1188 [1, 0, 2],
1189 [1, 2, 0],
1190 [2, 0, 1],
1191 [2, 1, 0],
1192 ];
1193 let expected = ["mlx/qwen3-4b:4bit", "mlx/qwen3-9b:4bit", "remote/tool-use"];
1194 for permutation in permutations {
1195 let catalog: Vec<ModelSchema> = permutation
1196 .into_iter()
1197 .map(|index| candidates[index].clone())
1198 .collect();
1199 let set = recommend_with_policy(
1200 &refs(&catalog),
1201 &mac(32),
1202 &ResourcePolicy::everyday(),
1203 UseCase::Assistant,
1204 QualityTier::Balanced,
1205 Privacy::CloudOk,
1206 &CheckpointGeometry::default(),
1207 );
1208 let actual: Vec<&str> = set
1209 .picks
1210 .iter()
1211 .map(|pick| pick.model_id.as_str())
1212 .collect();
1213 assert_eq!(actual, expected, "permutation {permutation:?}");
1214 }
1215 }
1216
1217 #[test]
1218 fn cuda_policy_checks_gpu_weights_and_host_overhead_as_separate_pools() {
1219 let mut model = qwen_mlx_policy_catalog().remove(0);
1220 model.source = ModelSource::Local {
1221 hf_repo: "x/y".into(),
1222 hf_filename: "m.gguf".into(),
1223 tokenizer_repo: "x/y".into(),
1224 };
1225
1226 model.cost.ram_mb = Some(1_000);
1227 model.cost.size_mb = Some(1_000);
1228 let vram_too_small = recommend_with_policy(
1229 &[&model],
1230 &hw(GpuBackend::Cuda, 64 * 1024, Some(900)),
1231 &ResourcePolicy::everyday(),
1232 UseCase::Assistant,
1233 QualityTier::Balanced,
1234 Privacy::OnDevice,
1235 &CheckpointGeometry::default(),
1236 );
1237 assert_eq!(vram_too_small.not_enough_memory[0].fit, FitStatus::TooBig);
1238
1239 model.cost.ram_mb = Some(5_000);
1240 model.cost.size_mb = Some(5_000);
1241 let separate_pools_fit = recommend_with_policy(
1245 &[&model],
1246 &hw(GpuBackend::Cuda, 16 * 1024, Some(16 * 1024)),
1247 &ResourcePolicy::everyday(),
1248 UseCase::Assistant,
1249 QualityTier::Balanced,
1250 Privacy::OnDevice,
1251 &CheckpointGeometry::default(),
1252 );
1253 assert_eq!(separate_pools_fit.picks[0].fit, FitStatus::Fits);
1254 assert!(separate_pools_fit.picks[0].within_recommendation_target);
1255 }
1256
1257 #[test]
1258 fn custom_zero_blocks_automatic_and_explicit_local_fit() {
1259 let model = qwen_mlx_policy_catalog().remove(0);
1260 let set = recommend_with_policy(
1261 &[&model],
1262 &mac(32),
1263 &ResourcePolicy::custom_gb(0.0).unwrap(),
1264 UseCase::Assistant,
1265 QualityTier::Balanced,
1266 Privacy::OnDevice,
1267 &CheckpointGeometry::default(),
1268 );
1269
1270 assert!(set.picks.is_empty());
1271 assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1272 assert!(!set.not_enough_memory[0].within_recommendation_target);
1273 }
1274
1275 #[test]
1276 fn local_focused_uses_its_full_configured_ceiling() {
1277 let mut model = qwen_mlx_policy_catalog().remove(0);
1278 model.cost.ram_mb = Some(10_400);
1282 model.cost.size_mb = Some(10_400);
1283 let set = recommend_with_policy(
1284 &[&model],
1285 &mac(16),
1286 &ResourcePolicy::local_focused(),
1287 UseCase::Assistant,
1288 QualityTier::Balanced,
1289 Privacy::OnDevice,
1290 &CheckpointGeometry::default(),
1291 );
1292
1293 assert_eq!(set.picks[0].fit, FitStatus::Fits);
1294 assert!(set.picks[0].within_recommendation_target);
1295 }
1296
1297 #[test]
1298 fn unknown_memory_never_outranks_a_known_fit() {
1299 let mut known = qwen_mlx_policy_catalog().remove(0);
1300 known.public_benchmarks.clear();
1301 let mut unknown = known.clone();
1302 unknown.id = "local/unknown-memory".into();
1303 unknown.name = "Unknown Memory".into();
1304 unknown.param_count.clear();
1305 unknown.cost.ram_mb = None;
1306 unknown.cost.size_mb = None;
1307 unknown.public_benchmarks = vec![crate::schema::BenchmarkScore {
1308 name: "quality".into(),
1309 score: 1.0,
1310 harness: None,
1311 source_url: None,
1312 measured_at: None,
1313 runs: None,
1314 spread: None,
1315 }];
1316
1317 let set = recommend_with_policy(
1318 &refs(&[unknown, known]),
1319 &mac(32),
1320 &ResourcePolicy::everyday(),
1321 UseCase::Assistant,
1322 QualityTier::Balanced,
1323 Privacy::OnDevice,
1324 &CheckpointGeometry::default(),
1325 );
1326 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1327 assert_eq!(set.picks[1].fit, FitStatus::Unknown);
1328 assert!(!set.picks[1].within_recommendation_target);
1329 }
1330
1331 #[test]
1332 fn everyday_assistant_tool_floor_excludes_generate_only_cloud_models() {
1333 let mut local = qwen_mlx_policy_catalog().remove(0);
1334 local.public_benchmarks.clear();
1335 let mut cloud = local.clone();
1336 cloud.id = "remote/high-score-generate-only".into();
1337 cloud.name = "Remote Generate Only".into();
1338 cloud.source = ModelSource::RemoteApi {
1339 endpoint: "https://api".into(),
1340 api_key_env: "K".into(),
1341 api_key_envs: vec![],
1342 api_version: None,
1343 protocol: crate::schema::ApiProtocol::OpenAiCompat,
1344 };
1345 cloud.capabilities = vec![ModelCapability::Generate];
1346 cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1347 name: "quality".into(),
1348 score: 1.0,
1349 harness: None,
1350 source_url: None,
1351 measured_at: None,
1352 runs: None,
1353 spread: None,
1354 }];
1355
1356 let set = recommend_with_policy(
1357 &refs(&[cloud, local]),
1358 &mac(32),
1359 &ResourcePolicy::everyday(),
1360 UseCase::Assistant,
1361 QualityTier::Balanced,
1362 Privacy::CloudOk,
1363 &CheckpointGeometry::default(),
1364 );
1365 assert_eq!(set.picks.len(), 1);
1366 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1367 }
1368
1369 #[test]
1380 fn the_assistant_tool_floor_holds_at_every_tier_and_entry_point() {
1381 let mut tool_capable = as_mlx(local_model("mlx/tools-4b", "Tools-4B", "4B", 2500));
1382 tool_capable.capabilities.push(ModelCapability::ToolUse);
1383 let generate_only = as_mlx(local_model("mlx/chat-0.6b", "Chat-0.6B", "0.6B", 650));
1385 let models = [tool_capable, generate_only];
1386
1387 for tier in [
1388 QualityTier::Fastest,
1389 QualityTier::Balanced,
1390 QualityTier::MostCapable,
1391 ] {
1392 for with_policy in [false, true] {
1393 let set = if with_policy {
1394 recommend_with_policy(
1395 &refs(&models),
1396 &mac(32),
1397 &ResourcePolicy::everyday(),
1398 UseCase::Assistant,
1399 tier,
1400 Privacy::OnDevice,
1401 &CheckpointGeometry::default(),
1402 )
1403 } else {
1404 recommend(
1405 &refs(&models),
1406 &mac(32),
1407 UseCase::Assistant,
1408 tier,
1409 Privacy::OnDevice,
1410 &CheckpointGeometry::default(),
1411 )
1412 };
1413 let ids: Vec<&str> = set.picks.iter().map(|p| p.model_id.as_str()).collect();
1414 assert_eq!(
1415 ids,
1416 vec!["mlx/tools-4b"],
1417 "tier {tier:?}, with_policy {with_policy}: a model without ToolUse \
1418 cannot serve the assistant"
1419 );
1420 }
1421 }
1422
1423 let coding = recommend(
1426 &refs(&models),
1427 &mac(32),
1428 UseCase::Coding,
1429 QualityTier::Fastest,
1430 Privacy::OnDevice,
1431 &CheckpointGeometry::default(),
1432 );
1433 assert_eq!(
1434 coding.picks.len(),
1435 2,
1436 "coding has no tool floor; both candidates should rank"
1437 );
1438 }
1439
1440 #[test]
1441 fn policy_entry_point_preserves_legacy_order_among_policy_eligible_candidates() {
1442 let catalog = mac_catalog();
1443 for (policy, use_case, tier) in [
1444 (
1445 ResourcePolicy::everyday(),
1446 UseCase::Assistant,
1447 QualityTier::Fastest,
1448 ),
1449 (
1450 ResourcePolicy::everyday(),
1451 UseCase::Assistant,
1452 QualityTier::MostCapable,
1453 ),
1454 (
1455 ResourcePolicy::everyday(),
1456 UseCase::Coding,
1457 QualityTier::Balanced,
1458 ),
1459 (
1460 ResourcePolicy::local_focused(),
1461 UseCase::Assistant,
1462 QualityTier::Balanced,
1463 ),
1464 ] {
1465 let legacy = recommend(
1466 &refs(&catalog),
1467 &mac(36),
1468 use_case,
1469 tier,
1470 Privacy::OnDevice,
1471 &CheckpointGeometry::default(),
1472 );
1473 let policy_aware = recommend_with_policy(
1474 &refs(&catalog),
1475 &mac(36),
1476 &policy,
1477 use_case,
1478 tier,
1479 Privacy::OnDevice,
1480 &CheckpointGeometry::default(),
1481 );
1482 let legacy_common: Vec<&str> = legacy
1483 .picks
1484 .iter()
1485 .filter(|pick| {
1486 policy_aware
1487 .picks
1488 .iter()
1489 .any(|candidate| candidate.model_id == pick.model_id)
1490 })
1491 .map(|pick| pick.model_id.as_str())
1492 .collect();
1493 let policy_common: Vec<&str> = policy_aware
1494 .picks
1495 .iter()
1496 .filter(|pick| {
1497 legacy
1498 .picks
1499 .iter()
1500 .any(|candidate| candidate.model_id == pick.model_id)
1501 })
1502 .map(|pick| pick.model_id.as_str())
1503 .collect();
1504 assert_eq!(
1505 policy_common, legacy_common,
1506 "{policy:?} {use_case:?} {tier:?}"
1507 );
1508 }
1509 }
1510
1511 #[test]
1512 fn most_capable_prefers_the_big_model_when_it_fits() {
1513 let cat = mac_catalog();
1514 let recs = recommend(
1515 &refs(&cat),
1516 &mac(36), UseCase::Coding,
1518 QualityTier::MostCapable,
1519 Privacy::OnDevice,
1520 &CheckpointGeometry::default(),
1521 )
1522 .picks;
1523 assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1524 assert_eq!(recs[0].fit, FitStatus::Fits);
1525 }
1526
1527 #[test]
1528 fn too_big_models_are_excluded_on_small_machines() {
1529 let cat = mac_catalog();
1530 let recs = recommend(
1531 &refs(&cat),
1532 &mac(8), UseCase::Coding,
1534 QualityTier::MostCapable,
1535 Privacy::OnDevice,
1536 &CheckpointGeometry::default(),
1537 )
1538 .picks;
1539 let names: Vec<&str> = recs.iter().map(|r| r.display_name.as_str()).collect();
1540 assert!(!names.contains(&"Qwen3-30B-A3B"), "30B must not fit 8GB");
1541 assert!(recs.iter().all(|r| r.fit == FitStatus::Fits));
1542 assert!(!recs.is_empty(), "the 0.6B model should still be offered");
1543 }
1544
1545 #[test]
1546 fn balanced_picks_a_capable_model_that_fits() {
1547 let cat = mac_catalog();
1548 let recs = recommend(
1549 &refs(&cat),
1550 &mac(16),
1551 UseCase::Coding,
1552 QualityTier::Balanced,
1553 Privacy::OnDevice,
1554 &CheckpointGeometry::default(),
1555 )
1556 .picks;
1557 assert!(matches!(
1560 recs[0].display_name.as_str(),
1561 "Qwen3-4B" | "Qwen3-8B"
1562 ));
1563 }
1564
1565 #[test]
1569 fn gguf_rows_are_offered_everywhere_but_apple_silicon() {
1570 let gguf = catalog();
1571 let both: Vec<ModelSchema> = gguf.iter().cloned().chain(mac_catalog()).collect();
1572 let on_mac = recommend(
1573 &refs(&both),
1574 &mac(64),
1575 UseCase::Coding,
1576 QualityTier::Balanced,
1577 Privacy::OnDevice,
1578 &CheckpointGeometry::default(),
1579 );
1580 assert!(!on_mac.picks.is_empty());
1581 assert!(on_mac
1582 .picks
1583 .iter()
1584 .chain(&on_mac.not_enough_memory)
1585 .all(|p| !gguf.iter().any(|g| g.id == p.model_id)));
1586 let cuda = hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024));
1587 let on_cuda = recommend(
1588 &refs(&both),
1589 &cuda,
1590 UseCase::Coding,
1591 QualityTier::Balanced,
1592 Privacy::OnDevice,
1593 &CheckpointGeometry::default(),
1594 );
1595 assert!(!on_cuda.picks.is_empty());
1596 assert!(on_cuda
1597 .picks
1598 .iter()
1599 .all(|p| gguf.iter().any(|g| g.id == p.model_id)));
1600 assert!(!platform_compatible(&gguf[0], &mac(64)));
1601 assert!(platform_compatible(&gguf[0], &cuda));
1602 }
1603
1604 #[test]
1605 fn search_only_returns_embedding_models() {
1606 let mut cat = mac_catalog();
1607 let mut embed = as_mlx(local_model("qwen/embed", "Qwen3-Embedding", "0.6B", 640));
1608 embed.capabilities = vec![ModelCapability::Embed];
1609 cat.push(embed);
1610 let recs = recommend(
1611 &refs(&cat),
1612 &mac(16),
1613 UseCase::Search,
1614 QualityTier::Balanced,
1615 Privacy::OnDevice,
1616 &CheckpointGeometry::default(),
1617 )
1618 .picks;
1619 assert_eq!(recs.len(), 1, "only the embed model is in the Search lane");
1620 assert_eq!(recs[0].display_name, "Qwen3-Embedding");
1621 assert_eq!(recs[0].role, UseCaseRole::Retrieval);
1622 }
1623
1624 #[test]
1625 fn deprecated_models_are_never_recommended() {
1626 let mut cat = mac_catalog();
1627 cat[1].deprecated = true; let recs = recommend(
1629 &refs(&cat),
1630 &mac(16),
1631 UseCase::Coding,
1632 QualityTier::Balanced,
1633 Privacy::OnDevice,
1634 &CheckpointGeometry::default(),
1635 )
1636 .picks;
1637 assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B"));
1638 }
1639
1640 #[test]
1641 fn on_device_excludes_cloud_but_cloud_ok_includes_it_with_consent() {
1642 let mut cat = mac_catalog();
1643 let mut cloud = local_model("anthropic/sonnet", "Claude Sonnet", "", 0);
1644 cloud.capabilities = vec![ModelCapability::Generate, ModelCapability::Code];
1645 cloud.source = ModelSource::RemoteApi {
1646 endpoint: "https://api".into(),
1647 api_key_env: "K".into(),
1648 api_key_envs: vec![],
1649 api_version: None,
1650 protocol: crate::schema::ApiProtocol::Anthropic,
1651 };
1652 cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1653 name: "SWE-bench".into(),
1654 score: 0.7,
1655 harness: None,
1656 source_url: None,
1657 measured_at: None,
1658 runs: None,
1659 spread: None,
1660 }];
1661 cat.push(cloud);
1662
1663 let on_device = recommend(
1664 &refs(&cat),
1665 &mac(16),
1666 UseCase::Coding,
1667 QualityTier::MostCapable,
1668 Privacy::OnDevice,
1669 &CheckpointGeometry::default(),
1670 )
1671 .picks;
1672 assert!(on_device.iter().all(|r| r.is_local));
1673
1674 let cloud_ok = recommend(
1675 &refs(&cat),
1676 &mac(16),
1677 UseCase::Coding,
1678 QualityTier::MostCapable,
1679 Privacy::CloudOk,
1680 &CheckpointGeometry::default(),
1681 )
1682 .picks;
1683 let claude = cloud_ok
1684 .iter()
1685 .find(|r| r.display_name == "Claude Sonnet")
1686 .expect("cloud model eligible under CloudOk");
1687 assert!(claude.requires_cloud_consent);
1688 assert_eq!(claude.fit, FitStatus::ServerProvided);
1689 }
1690
1691 #[test]
1692 fn metal_only_model_excluded_on_cpu_host() {
1693 let mut cat = catalog();
1694 let mut mlx = local_model("mlx/qwen3-4b", "Qwen3-4B-MLX", "4B", 2400);
1695 mlx.source = ModelSource::Mlx {
1696 hf_repo: "mlx-community/x".into(),
1697 hf_weight_file: None,
1698 };
1699 cat.push(mlx);
1700 let recs = recommend(
1702 &refs(&cat),
1703 &hw(GpuBackend::Cpu, 32 * 1024, None),
1704 UseCase::Coding,
1705 QualityTier::Balanced,
1706 Privacy::OnDevice,
1707 &CheckpointGeometry::default(),
1708 )
1709 .picks;
1710 assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B-MLX"));
1711 }
1712
1713 #[test]
1714 fn ranking_is_deterministic() {
1715 let cat = mac_catalog();
1716 let a = recommend(
1717 &refs(&cat),
1718 &mac(16),
1719 UseCase::Assistant,
1720 QualityTier::Balanced,
1721 Privacy::OnDevice,
1722 &CheckpointGeometry::default(),
1723 );
1724 let b = recommend(
1725 &refs(&cat),
1726 &mac(16),
1727 UseCase::Assistant,
1728 QualityTier::Balanced,
1729 Privacy::OnDevice,
1730 &CheckpointGeometry::default(),
1731 );
1732 let ids_a: Vec<&str> = a.picks.iter().map(|r| r.model_id.as_str()).collect();
1733 let ids_b: Vec<&str> = b.picks.iter().map(|r| r.model_id.as_str()).collect();
1734 assert_eq!(ids_a, ids_b);
1735 }
1736
1737 #[test]
1738 fn rationale_is_plain_language_no_jargon() {
1739 let cat = mac_catalog();
1740 let recs = recommend(
1741 &refs(&cat),
1742 &mac(36),
1743 UseCase::Coding,
1744 QualityTier::Balanced,
1745 Privacy::OnDevice,
1746 &CheckpointGeometry::default(),
1747 )
1748 .picks;
1749 let r = &recs[0].rationale;
1750 assert!(!r.contains("Q4_K_M"), "no quantization jargon");
1751 assert!(!r.contains("gguf") && !r.contains("hf_repo"));
1752 assert!(r.contains("coding"), "states the purpose");
1753 }
1754
1755 #[test]
1756 fn all_too_big_surfaces_needs_more_ram_with_a_note() {
1757 let cat = catalog();
1759 let set = recommend(
1760 &refs(&cat),
1761 &hw(GpuBackend::Cpu, 2 * 1024, None),
1762 UseCase::Coding,
1763 QualityTier::Balanced,
1764 Privacy::OnDevice,
1765 &CheckpointGeometry::default(),
1766 );
1767 assert!(set.picks.is_empty(), "nothing should fit 2 GB");
1768 assert!(
1769 !set.not_enough_memory.is_empty(),
1770 "too-big models surfaced, not dropped"
1771 );
1772 let note = set.note.expect("empty picks must carry a note");
1773 assert!(note.contains("fits"), "note explains the no-fit: {note}");
1774 assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1776 }
1777
1778 #[test]
1779 fn all_deprecated_gives_generic_note_not_a_memory_note() {
1780 let mut cat = mac_catalog();
1784 for m in &mut cat {
1785 m.deprecated = true;
1786 }
1787 let set = recommend(
1788 &refs(&cat),
1789 &mac(36), UseCase::Coding,
1791 QualityTier::Balanced,
1792 Privacy::OnDevice,
1793 &CheckpointGeometry::default(),
1794 );
1795 assert!(set.picks.is_empty());
1796 assert!(set.not_enough_memory.is_empty());
1797 let note = set.note.expect("must explain");
1798 assert!(
1799 !note.contains("fits") && !note.contains("memory"),
1800 "deprecated-only must not claim a memory problem: {note}"
1801 );
1802 }
1803
1804 #[test]
1805 fn not_enough_memory_is_ordered_deterministically() {
1806 let cat = catalog();
1807 let mk = || {
1808 recommend(
1809 &refs(&cat),
1810 &hw(GpuBackend::Cpu, 3 * 1024, None), UseCase::Coding,
1812 QualityTier::Balanced,
1813 Privacy::OnDevice,
1814 &CheckpointGeometry::default(),
1815 )
1816 .not_enough_memory
1817 .into_iter()
1818 .map(|r| r.model_id)
1819 .collect::<Vec<_>>()
1820 };
1821 assert!(mk().len() >= 2, "several models should be too big for 3 GB");
1822 assert_eq!(mk(), mk(), "too-big ordering must be deterministic");
1823 }
1824
1825 #[test]
1826 fn empty_registry_returns_empty_with_a_note() {
1827 let set = recommend(
1828 &[],
1829 &mac(16),
1830 UseCase::Assistant,
1831 QualityTier::Balanced,
1832 Privacy::OnDevice,
1833 &CheckpointGeometry::default(),
1834 );
1835 assert!(set.picks.is_empty());
1836 assert!(set.not_enough_memory.is_empty());
1837 assert!(set.note.is_some(), "no-model case must explain itself");
1838 }
1839
1840 #[test]
1841 fn cuda_box_sizes_against_vram() {
1842 let cat = catalog();
1844 let h = hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024));
1845 let recs = recommend(
1846 &refs(&cat),
1847 &h,
1848 UseCase::Coding,
1849 QualityTier::MostCapable,
1850 Privacy::OnDevice,
1851 &CheckpointGeometry::default(),
1852 )
1853 .picks;
1854 assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1855 }
1856
1857 #[test]
1858 fn unsupported_discrete_gpu_uses_system_ram_not_vram() {
1859 let cat = catalog();
1862 let mut h = hw(GpuBackend::Cpu, 16 * 1024, None);
1863 h.gpu_devices = vec![GpuDevice {
1864 vendor: GpuVendor::Nvidia,
1865 name: "GeForce RTX 4090".into(),
1866 memory_mb: Some(24_000),
1867 }];
1868 assert!(matches!(
1870 h.supported_acceleration(),
1871 crate::hardware::SupportedAcceleration::UnsupportedDiscreteGpu { .. }
1872 ));
1873 let recs = recommend(
1874 &refs(&cat),
1875 &h,
1876 UseCase::Coding,
1877 QualityTier::MostCapable,
1878 Privacy::OnDevice,
1879 &CheckpointGeometry::default(),
1880 )
1881 .picks;
1882 assert!(
1883 recs.iter().all(|r| r.display_name != "Qwen3-30B-A3B"),
1884 "17 GB model must not fit a 16 GB-RAM CPU host"
1885 );
1886 assert!(!recs.is_empty(), "smaller models still fit");
1887 }
1888
1889 #[test]
1890 fn recommendation_set_wire_shape_is_snake_case_and_stable() {
1891 let cat = mac_catalog();
1893 let set = recommend(
1894 &refs(&cat),
1895 &mac(36),
1896 UseCase::Coding,
1897 QualityTier::Balanced,
1898 Privacy::OnDevice,
1899 &CheckpointGeometry::default(),
1900 );
1901 let json = serde_json::to_string(&set).unwrap();
1902 assert!(json.contains("\"picks\""));
1903 assert!(json.contains("\"not_enough_memory\""));
1904 assert!(json.contains("\"model_id\""));
1905 assert!(json.contains("\"already_installed\""));
1906 assert!(json.contains("\"requires_cloud_consent\""));
1907 assert!(json.contains("\"within_recommendation_target\""));
1908 assert!(json.contains("\"fit\""));
1909
1910 let mut legacy = serde_json::to_value(&set.picks[0]).unwrap();
1911 legacy
1912 .as_object_mut()
1913 .unwrap()
1914 .remove("within_recommendation_target");
1915 let decoded: Recommendation = serde_json::from_value(legacy).unwrap();
1916 assert!(decoded.within_recommendation_target);
1917 }
1918
1919 #[test]
1920 fn blank_param_count_estimates_from_size_not_zero() {
1921 let mut m = local_model("x/unknown", "Unknown-Model", "", 4900);
1924 m.param_count = String::new();
1925 assert!(
1926 param_billions_total(&m) > 5.0,
1927 "4.9 GB ⇒ roughly an 8B model, not 0B"
1928 );
1929 }
1930
1931 fn cloud_row(id: &str) -> ModelSchema {
1934 let mut cloud = local_model(id, "Cloud", "", 0);
1935 cloud.source = ModelSource::RemoteApi {
1936 endpoint: "https://example.invalid".into(),
1937 api_key_env: "TEST_KEY".into(),
1938 api_key_envs: vec![],
1939 api_version: None,
1940 protocol: crate::schema::ApiProtocol::OpenAiCompat,
1941 };
1942 cloud.cost.ram_mb = None;
1943 cloud.cost.size_mb = None;
1944 cloud
1945 }
1946
1947 #[test]
1953 fn unified_fit_is_the_recommenders_verdict_on_every_machine_size() {
1954 let policy = ResourcePolicy::everyday();
1955 let cat = qwen_mlx_policy_catalog();
1956 let four = &cat[0];
1957 let eight = &cat[1];
1958 let small = local_model("mlx/qwen3-0.6b:6bit", "Qwen3-0.6B", "0.6B", 500);
1959 let thirty = local_model(
1960 "mlx/qwen3-30b-a3b:4bit",
1961 "Qwen3-30B-A3B",
1962 "30B (3B active)",
1963 16_500,
1964 );
1965 let at = |m: &ModelSchema, gb: u64| {
1966 model_fit(m, &mac(gb), Some(&policy), &CheckpointGeometry::default())
1967 };
1968
1969 assert_eq!(at(eight, 8).fit, ModelFitStatus::TooBig);
1970 assert_eq!(at(eight, 16).fit, ModelFitStatus::TooBig);
1971 assert_eq!(at(eight, 32).fit, ModelFitStatus::Fits);
1972 assert_eq!(at(four, 8).fit, ModelFitStatus::TooBig);
1973 assert_eq!(at(four, 16).fit, ModelFitStatus::Fits);
1974 assert_eq!(at(&small, 8).fit, ModelFitStatus::Fits);
1975 assert_eq!(at(&thirty, 16).fit, ModelFitStatus::TooBig);
1976 assert_eq!(at(&thirty, 32).fit, ModelFitStatus::TooBig);
1977
1978 let eight_at_32 = at(eight, 32);
1981 assert_eq!(
1982 eight_at_32.estimated_peak_mb,
1983 Some(
1984 estimate_model_memory(eight, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
1985 .estimated_peak_mb
1986 )
1987 );
1988 assert!(eight_at_32.platform_compatible);
1989
1990 let all = vec![four.clone(), eight.clone(), small, thirty];
1994 let by_id = |id: &str| all.iter().find(|m| m.id == id).unwrap();
1995 for gb in [8u64, 16, 32] {
1996 let set = recommend_with_policy(
1997 &refs(&all),
1998 &mac(gb),
1999 &policy,
2000 UseCase::Assistant,
2001 QualityTier::Balanced,
2002 Privacy::OnDevice,
2003 &CheckpointGeometry::default(),
2004 );
2005 for pick in &set.picks {
2006 assert_eq!(
2007 model_fit(
2008 by_id(&pick.model_id),
2009 &mac(gb),
2010 Some(&policy),
2011 &CheckpointGeometry::default()
2012 )
2013 .fit,
2014 ModelFitStatus::Fits,
2015 "{gb} GB pick {}",
2016 pick.model_id
2017 );
2018 }
2019 for miss in &set.not_enough_memory {
2020 assert_eq!(
2021 model_fit(
2022 by_id(&miss.model_id),
2023 &mac(gb),
2024 Some(&policy),
2025 &CheckpointGeometry::default()
2026 )
2027 .fit,
2028 ModelFitStatus::TooBig,
2029 "{gb} GB miss {}",
2030 miss.model_id
2031 );
2032 }
2033 }
2034 }
2035
2036 #[test]
2043 fn an_installed_checkpoints_kv_geometry_decides_the_verdict() {
2044 let policy = ResourcePolicy::everyday();
2045 let cat = qwen_mlx_policy_catalog();
2046 let eight = &cat[1];
2047 let root = tempfile::tempdir().unwrap();
2048 let dir = root.path().join(&eight.name);
2049 std::fs::create_dir_all(&dir).unwrap();
2050 std::fs::write(
2051 dir.join("config.json"),
2052 r#"{"num_hidden_layers":36,"num_key_value_heads":32,"head_dim":128,"hidden_size":4096,"num_attention_heads":32,"torch_dtype":"bfloat16"}"#,
2053 )
2054 .unwrap();
2055 let installed = CheckpointGeometry::read([eight], root.path());
2056 let absent = CheckpointGeometry::default();
2057
2058 let fallback = model_fit(eight, &mac(24), Some(&policy), &absent);
2059 let real = model_fit(eight, &mac(24), Some(&policy), &installed);
2060 assert_eq!(fallback.fit, ModelFitStatus::Fits, "{fallback:?}");
2061 assert_eq!(real.fit, ModelFitStatus::TooBig, "{real:?}");
2062 assert!(real.estimated_peak_mb > fallback.estimated_peak_mb);
2063
2064 let partition = |geometry: &CheckpointGeometry| {
2065 let set = recommend_with_policy(
2066 &[eight],
2067 &mac(24),
2068 &policy,
2069 UseCase::Assistant,
2070 QualityTier::Balanced,
2071 Privacy::OnDevice,
2072 geometry,
2073 );
2074 (
2075 set.picks.iter().any(|r| r.model_id == eight.id),
2076 set.not_enough_memory.iter().any(|r| r.model_id == eight.id),
2077 )
2078 };
2079 assert_eq!(partition(&absent), (true, false));
2080 assert_eq!(partition(&installed), (false, true));
2081
2082 let nested_root = tempfile::tempdir().unwrap();
2085 let nested_dir = nested_root.path().join(&eight.name);
2086 std::fs::create_dir_all(&nested_dir).unwrap();
2087 std::fs::write(
2088 nested_dir.join("config.json"),
2089 r#"{"model_type":"gemma4_unified","torch_dtype":"bfloat16","vision_config":{},"text_config":{"num_hidden_layers":36,"num_key_value_heads":32,"head_dim":128,"hidden_size":4096,"num_attention_heads":32}}"#,
2090 )
2091 .unwrap();
2092 let nested = CheckpointGeometry::read([eight], nested_root.path());
2093 assert_eq!(
2094 model_fit(eight, &mac(24), Some(&policy), &nested),
2095 real,
2096 "nested text_config is sized as the flat config is"
2097 );
2098 assert_eq!(partition(&nested), (false, true));
2099 }
2100
2101 #[test]
2113 fn a_model_larger_than_any_machine_is_too_big_on_every_machine() {
2114 let policy = ResourcePolicy::everyday();
2115 let enormous = local_model("test/enormous-model:q4", "Enormous", "9000B", 900_000_000);
2117 let machines = [
2118 ("apple 8 GB", mac(8)),
2119 ("apple 128 GB", mac(128)),
2120 ("cpu 32 GB", hw(GpuBackend::Cpu, 32 * 1024, None)),
2121 ("cuda build, no card", hw(GpuBackend::Cuda, 64 * 1024, None)),
2125 (
2126 "cuda 24 GB card",
2127 hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024)),
2128 ),
2129 ];
2130 for (label, machine) in machines {
2131 assert_eq!(
2132 model_fit(
2133 &enormous,
2134 &machine,
2135 Some(&policy),
2136 &CheckpointGeometry::default()
2137 )
2138 .fit,
2139 ModelFitStatus::TooBig,
2140 "{label} must not claim to hold a 900 TB model"
2141 );
2142 assert_eq!(
2143 model_fit(&enormous, &machine, None, &CheckpointGeometry::default()).fit,
2144 ModelFitStatus::TooBig,
2145 "{label} without a policy must not claim to hold a 900 TB model"
2146 );
2147 }
2148 }
2149
2150 #[test]
2153 fn cuda_build_without_a_card_still_fits_models_that_fit_system_ram() {
2154 let policy = ResourcePolicy::everyday();
2155 let small = local_model("qwen/qwen3-0.6b:q4_k_m", "Qwen3-0.6B", "0.6B", 500);
2156 let no_card = hw(GpuBackend::Cuda, 64 * 1024, None);
2157 assert_eq!(
2158 model_fit(
2159 &small,
2160 &no_card,
2161 Some(&policy),
2162 &CheckpointGeometry::default()
2163 )
2164 .fit,
2165 ModelFitStatus::Fits
2166 );
2167 }
2168
2169 #[test]
2170 fn unified_fit_platform_check_is_the_base_filters() {
2171 let cat = qwen_mlx_policy_catalog();
2172 let mlx = &cat[0];
2173 let cpu_box = hw(GpuBackend::Cpu, 32 * 1024, None);
2174 let fit = model_fit(
2175 mlx,
2176 &cpu_box,
2177 Some(&ResourcePolicy::everyday()),
2178 &CheckpointGeometry::default(),
2179 );
2180 assert!(!fit.platform_compatible, "MLX needs Apple Silicon");
2181 assert!(!passes_base_filter(
2182 mlx,
2183 &cpu_box,
2184 UseCase::Coding,
2185 Privacy::OnDevice
2186 ));
2187 assert!(platform_compatible(mlx, &mac(32)));
2188
2189 let gguf = local_model("qwen/qwen3-4b:q4_k_m", "Qwen3-4B", "4B", 2_500);
2190 assert!(
2191 model_fit(&gguf, &cpu_box, None, &CheckpointGeometry::default()).platform_compatible
2192 );
2193 assert!(passes_base_filter(
2194 &gguf,
2195 &cpu_box,
2196 UseCase::Coding,
2197 Privacy::OnDevice
2198 ));
2199 }
2200
2201 #[test]
2202 fn unified_fit_for_rows_whose_memory_is_not_this_machines() {
2203 let cloud = cloud_row("remote/cloud");
2205 let fit = model_fit(
2206 &cloud,
2207 &mac(8),
2208 Some(&ResourcePolicy::everyday()),
2209 &CheckpointGeometry::default(),
2210 );
2211 assert_eq!(fit.fit, ModelFitStatus::Fits);
2212 assert_eq!(fit.estimated_peak_mb, None);
2213 assert!(fit.platform_compatible);
2214 assert!(
2215 model_fit(
2216 &cloud,
2217 &hw(GpuBackend::Cpu, 8 * 1024, None),
2218 None,
2219 &CheckpointGeometry::default()
2220 )
2221 .platform_compatible
2222 );
2223
2224 let mut undeclared = local_model("local/undeclared", "Undeclared", "4B", 0);
2226 undeclared.cost.ram_mb = None;
2227 undeclared.cost.size_mb = None;
2228 let fit = model_fit(
2229 &undeclared,
2230 &mac(32),
2231 Some(&ResourcePolicy::everyday()),
2232 &CheckpointGeometry::default(),
2233 );
2234 assert_eq!(fit.fit, ModelFitStatus::Unknown);
2235 assert_eq!(fit.estimated_peak_mb, None);
2236
2237 let mut foundation = local_model("apple/foundation:default", "Apple", "", 0);
2240 foundation.source = ModelSource::AppleFoundationModels { use_case: None };
2241 foundation.cost.ram_mb = None;
2242 foundation.cost.size_mb = None;
2243 let on_mac = model_fit(
2244 &foundation,
2245 &mac(8),
2246 Some(&ResourcePolicy::everyday()),
2247 &CheckpointGeometry::default(),
2248 );
2249 assert_eq!(on_mac.fit, ModelFitStatus::Fits);
2250 assert_eq!(on_mac.estimated_peak_mb, None);
2251 assert!(on_mac.platform_compatible);
2252 assert!(
2253 !model_fit(
2254 &foundation,
2255 &hw(GpuBackend::Cpu, 64 * 1024, None),
2256 None,
2257 &CheckpointGeometry::default()
2258 )
2259 .platform_compatible
2260 );
2261
2262 let mut windows = local_model("windows/speech-synthesis:os", "Windows", "", 0);
2263 windows.source = ModelSource::WindowsSpeech {};
2264 windows.cost.ram_mb = None;
2265 windows.cost.size_mb = None;
2266 let on_mac = model_fit(
2267 &windows,
2268 &mac(8),
2269 Some(&ResourcePolicy::everyday()),
2270 &CheckpointGeometry::default(),
2271 );
2272 assert_eq!(on_mac.fit, ModelFitStatus::Fits);
2273 assert_eq!(on_mac.estimated_peak_mb, None);
2274 assert!(!on_mac.platform_compatible);
2275 let mut windows_host = hw(GpuBackend::Cpu, 8 * 1024, None);
2276 windows_host.os = "windows".into();
2277 assert!(
2278 model_fit(
2279 &windows,
2280 &windows_host,
2281 None,
2282 &CheckpointGeometry::default()
2283 )
2284 .platform_compatible
2285 );
2286
2287 let mut linux = undeclared.clone();
2290 linux.tags.push("linux-only".into());
2291 let mut linux_host = hw(GpuBackend::Cpu, 8 * 1024, None);
2292 linux_host.os = "linux".into();
2293 assert!(platform_compatible(&linux, &linux_host));
2294 assert!(!platform_compatible(&linux, &windows_host));
2295 let mut tagged_windows = undeclared.clone();
2296 tagged_windows.tags.push("windows-only".into());
2297 assert!(platform_compatible(&tagged_windows, &windows_host));
2298 assert!(!platform_compatible(&tagged_windows, &linux_host));
2299
2300 let cat = qwen_mlx_policy_catalog();
2305 assert_eq!(
2306 model_fit(&cat[0], &mac(8), None, &CheckpointGeometry::default()).fit,
2307 ModelFitStatus::Fits
2308 );
2309 assert_eq!(
2310 model_fit(&cat[1], &mac(8), None, &CheckpointGeometry::default()).fit,
2311 ModelFitStatus::TooBig
2312 );
2313 let legacy = recommend(
2314 &refs(&cat),
2315 &mac(8),
2316 UseCase::Coding,
2317 QualityTier::Balanced,
2318 Privacy::OnDevice,
2319 &CheckpointGeometry::default(),
2320 );
2321 fn ids(set: &[Recommendation]) -> Vec<&str> {
2322 set.iter().map(|pick| pick.model_id.as_str()).collect()
2323 }
2324 assert_eq!(ids(&legacy.picks), vec!["mlx/qwen3-4b:4bit"]);
2325 assert_eq!(ids(&legacy.not_enough_memory), vec!["mlx/qwen3-8b:4bit"]);
2326 }
2327}
2328
2329#[cfg(test)]
2330mod local_server_fit_tests {
2331 use super::*;
2332 use crate::schema::{ModelCapability, ModelSource};
2333
2334 fn managed_vllm_model(id: &str, size_mb: u64) -> ModelSchema {
2335 let mut m = super::tests::local_model(id, id, "12B", size_mb);
2336 m.capabilities.push(ModelCapability::ToolUse);
2337 m.cost.ram_mb = Some(size_mb + size_mb / 4);
2338 m.source = ModelSource::ManagedVllmMlx {
2339 hf_repo: "mlx-community/whatever-4bit".into(),
2340 hf_weight_file: None,
2341 draft_hf_repo: None,
2342 };
2343 m
2344 }
2345
2346 fn external_vllm_model(id: &str, endpoint: &str, size_mb: u64) -> ModelSchema {
2347 let mut m = super::tests::local_model(id, id, "12B", size_mb);
2348 m.capabilities.push(ModelCapability::ToolUse);
2349 m.cost.ram_mb = Some(size_mb + size_mb / 4);
2350 m.source = ModelSource::VllmMlx {
2351 endpoint: endpoint.to_string(),
2352 model_name: "externally-managed-model".into(),
2353 };
2354 m
2355 }
2356
2357 fn small_mac() -> HardwareInfo {
2358 super::tests::hw(crate::hardware::GpuBackend::Metal, 16384, Some(12288))
2359 }
2360
2361 #[test]
2365 fn managed_vllm_mlx_is_memory_checked_and_rejected_when_over_budget() {
2366 let big = managed_vllm_model("vllm-mlx/huge:4bit", 20_000);
2367 let set = recommend_with_policy(
2368 &[&big],
2369 &small_mac(),
2370 &ResourcePolicy::everyday(),
2371 UseCase::Assistant,
2372 QualityTier::Balanced,
2373 Privacy::OnDevice,
2374 &CheckpointGeometry::default(),
2375 );
2376
2377 assert!(set.picks.is_empty());
2378 assert_eq!(set.not_enough_memory.len(), 1);
2379 assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
2380 assert_eq!(
2381 set.not_enough_memory[0].download_mb, 20_000,
2382 "CAR-managed vllm weights must retain their declared download size"
2383 );
2384 }
2385
2386 #[test]
2389 fn external_vllm_mlx_requires_cloud_consent_and_is_cross_platform() {
2390 let machines = [
2391 small_mac(),
2392 super::tests::hw(crate::hardware::GpuBackend::Cpu, 16_384, None),
2393 super::tests::hw(crate::hardware::GpuBackend::Cuda, 16_384, Some(12_288)),
2394 ];
2395 for endpoint in [
2396 "http://localhost:8000",
2397 "http://127.0.0.1:8000",
2398 "https://gpu-owner.example/v1",
2399 ] {
2400 let external = external_vllm_model("external/vllm", endpoint, 20_000);
2401 for machine in &machines {
2402 let on_device = recommend_with_policy(
2403 &[&external],
2404 machine,
2405 &ResourcePolicy::everyday(),
2406 UseCase::Assistant,
2407 QualityTier::Balanced,
2408 Privacy::OnDevice,
2409 &CheckpointGeometry::default(),
2410 );
2411 assert!(
2412 on_device.picks.is_empty(),
2413 "external endpoint {endpoint} must require cloud consent on {:?}",
2414 machine.gpu_backend
2415 );
2416
2417 let cloud_ok = recommend_with_policy(
2418 &[&external],
2419 machine,
2420 &ResourcePolicy::everyday(),
2421 UseCase::Assistant,
2422 QualityTier::Balanced,
2423 Privacy::CloudOk,
2424 &CheckpointGeometry::default(),
2425 );
2426 assert_eq!(
2427 cloud_ok.picks.len(),
2428 1,
2429 "external endpoint {endpoint} on {:?}",
2430 machine.gpu_backend
2431 );
2432 assert_eq!(cloud_ok.picks[0].fit, FitStatus::ServerProvided);
2433 assert_eq!(
2434 cloud_ok.picks[0].download_mb, 0,
2435 "external vllm owns its weights, so CAR has no download to report"
2436 );
2437 assert!(
2438 cloud_ok.picks[0].rationale.contains("external server"),
2439 "external vllm rationale must describe its actual owner: {}",
2440 cloud_ok.picks[0].rationale
2441 );
2442 assert!(
2443 !cloud_ok.picks[0].rationale.contains("Parslee's servers"),
2444 "external vllm must not be attributed to Parslee: {}",
2445 cloud_ok.picks[0].rationale
2446 );
2447 }
2448 }
2449 }
2450}
2451
2452#[cfg(test)]
2453mod catalog_capability_gap_tests {
2454 use super::*;
2455 use crate::schema::{ModelCapability, ModelSchema};
2456
2457 fn builtin() -> Vec<ModelSchema> {
2458 serde_json::from_str(include_str!("builtin_catalog.json")).unwrap()
2459 }
2460
2461 fn cuda_box(vram_gb: u64, ram_gb: u64) -> crate::hardware::HardwareInfo {
2462 super::tests::hw(
2463 crate::hardware::GpuBackend::Cuda,
2464 ram_gb * 1024,
2465 Some(vram_gb * 1024),
2466 )
2467 }
2468
2469 #[test]
2472 fn a_mac_is_never_offered_a_gguf_row_from_the_builtin_catalog() {
2473 let catalog = builtin();
2474 let refs: Vec<&ModelSchema> = catalog.iter().collect();
2475 for use_case in [
2476 UseCase::Assistant,
2477 UseCase::Coding,
2478 UseCase::Summarize,
2479 UseCase::Vision,
2480 UseCase::Transcription,
2481 UseCase::Search,
2482 ] {
2483 for tier in [
2484 QualityTier::Fastest,
2485 QualityTier::Balanced,
2486 QualityTier::MostCapable,
2487 ] {
2488 let set = recommend(
2489 &refs,
2490 &super::tests::mac(64),
2491 use_case,
2492 tier,
2493 Privacy::OnDevice,
2494 &CheckpointGeometry::default(),
2495 );
2496 for pick in set.picks.iter().chain(&set.not_enough_memory) {
2497 let row = catalog.iter().find(|m| m.id == pick.model_id).unwrap();
2498 assert!(
2499 !matches!(row.source, crate::schema::ModelSource::Local { .. }),
2500 "{use_case:?}/{tier:?} offered GGUF row {}",
2501 row.id
2502 );
2503 }
2504 }
2505 }
2506 }
2507
2508 fn most_capable_on(ram_gb: u64) -> RecommendationSet {
2509 let catalog: &'static Vec<ModelSchema> = Box::leak(Box::new(builtin()));
2510 let refs: Vec<&ModelSchema> = catalog.iter().collect();
2511 recommend(
2512 &refs,
2513 &super::tests::mac(ram_gb),
2514 UseCase::Assistant,
2515 QualityTier::MostCapable,
2516 Privacy::OnDevice,
2517 &CheckpointGeometry::default(),
2518 )
2519 }
2520
2521 #[test]
2530 fn most_capable_returns_the_best_model_the_machine_can_run() {
2531 let catalog = builtin();
2532 let set = most_capable_on(64);
2533 let top = set.picks.first().expect("a 64 GB machine has picks");
2534
2535 let top_score = catalog
2536 .iter()
2537 .find(|m| m.id == top.model_id)
2538 .and_then(|m| m.public_benchmarks.first())
2539 .map(|b| b.score)
2540 .unwrap_or(0.0);
2541
2542 for m in catalog
2543 .iter()
2544 .filter(|m| m.is_local() && m.size_mb() < 24_000)
2545 {
2546 if let Some(s) = m.public_benchmarks.first().map(|b| b.score) {
2547 assert!(
2548 s <= top_score,
2549 "{} scores {s} but {} ({top_score}) was recommended as most capable",
2550 m.id,
2551 top.model_id
2552 );
2553 }
2554 }
2555 assert!(
2556 top.download_mb > 10_000,
2557 "a 64 GB machine should be offered a large model, got {} at {} MB",
2558 top.model_id,
2559 top.download_mb
2560 );
2561 }
2562
2563 #[test]
2566 fn a_small_machine_is_not_offered_a_model_it_cannot_hold() {
2567 let set = most_capable_on(8);
2568 let top = set
2569 .picks
2570 .first()
2571 .expect("an 8 GB machine still gets a pick");
2572 assert!(
2573 top.fit != FitStatus::TooBig,
2574 "{} does not fit an 8 GB machine",
2575 top.model_id
2576 );
2577 }
2578
2579 #[test]
2582 fn the_disclosure_is_accurate_when_it_appears() {
2583 let set = most_capable_on(64);
2584 if let Some(note) = set.note.as_deref() {
2585 if note.contains("unscored") {
2586 assert!(note.contains("bench-contribute"), "must say how: {note}");
2587 assert!(
2588 !note.contains("installed"),
2589 "availability is not installation: {note}"
2590 );
2591 }
2592 }
2593 }
2594
2595 #[test]
2597 fn other_tiers_do_not_carry_the_disclosure() {
2598 let catalog = builtin();
2599 let refs: Vec<&ModelSchema> = catalog.iter().collect();
2600 for tier in [QualityTier::Fastest, QualityTier::Balanced] {
2601 let set = recommend(
2602 &refs,
2603 &super::tests::mac(64),
2604 UseCase::Assistant,
2605 tier,
2606 Privacy::OnDevice,
2607 &CheckpointGeometry::default(),
2608 );
2609 let carries = set.note.as_deref().is_some_and(|n| n.contains("unscored"));
2610 assert!(!carries, "{tier:?} should not carry the disclosure");
2611 }
2612 }
2613
2614 #[test]
2623 fn a_cuda_machine_is_offered_a_local_model() {
2624 let catalog = builtin();
2625 let refs: Vec<&ModelSchema> = catalog.iter().collect();
2626 let set = recommend(
2627 &refs,
2628 &cuda_box(24, 64),
2629 UseCase::Assistant,
2630 QualityTier::MostCapable,
2631 Privacy::OnDevice,
2632 &CheckpointGeometry::default(),
2633 );
2634 assert!(
2635 set.picks.iter().any(|p| p.is_local),
2636 "a 24 GB CUDA GPU must be offered something local, got {:?}",
2637 set.picks.iter().map(|p| &p.model_id).collect::<Vec<_>>()
2638 );
2639 }
2640
2641 #[test]
2645 fn local_generate_models_declare_their_parameter_count() {
2646 let blank: Vec<String> = builtin()
2647 .iter()
2648 .filter(|m| {
2649 m.capabilities.contains(&ModelCapability::Generate)
2650 && m.is_local()
2651 && m.param_count.trim().is_empty()
2652 })
2653 .map(|m| m.id.clone())
2654 .collect();
2655 assert!(
2656 blank.is_empty(),
2657 "local generate models with no param_count: {blank:?}"
2658 );
2659 }
2660
2661 #[test]
2666 fn glm_flash_is_offered_at_8bit_only() {
2667 let catalog = builtin();
2668 let row = |id: &str| {
2669 catalog
2670 .iter()
2671 .find(|m| m.id == id)
2672 .unwrap_or_else(|| panic!("{id} missing from the builtin catalog"))
2673 };
2674 let base = |m: &ModelSchema| {
2675 passes_base_filter(
2676 m,
2677 &super::tests::mac(64),
2678 UseCase::Coding,
2679 Privacy::OnDevice,
2680 )
2681 };
2682 assert!(!base(row("vllm-mlx/glm-4.7-flash:4bit")));
2683 assert!(base(row("vllm-mlx/glm-4.7-flash:8bit")));
2684 }
2685
2686 #[test]
2688 fn an_moe_is_scored_on_its_active_parameters() {
2689 let catalog = builtin();
2690 let glm = catalog
2691 .iter()
2692 .find(|m| m.id == "vllm-mlx/glm-4.7-flash:8bit")
2693 .expect("catalog entry");
2694 let active = crate::resource_policy::model_parameter_billions_active(glm);
2695 let total = crate::resource_policy::model_parameter_billions_total(glm);
2696 assert!(
2697 active < 6.0,
2698 "top-4-of-64 MoE runs at a few B active, got {active}"
2699 );
2700 assert!(total > 20.0, "and carries 30B-class knowledge, got {total}");
2701 }
2702}