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, model_parameter_billions_active, model_parameter_billions_total,
25 ResourcePolicy, ResourceProfile, RECOMMENDATION_CONTEXT_TOKENS,
26};
27use crate::schema::{ModelSchema, TrustTier};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum FitStatus {
33 Fits,
35 TooBig,
37 ServerProvided,
40 Unknown,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Recommendation {
47 pub model_id: String,
49 pub display_name: String,
51 pub role: UseCaseRole,
53 pub rationale: String,
56 pub download_mb: u64,
58 pub already_installed: bool,
60 pub fit: FitStatus,
62 pub acceleration: SupportedAcceleration,
64 pub is_local: bool,
66 pub requires_cloud_consent: bool,
69 pub trust_tier: TrustTier,
71 pub score: f32,
73 #[serde(default = "default_true")]
80 pub within_recommendation_target: bool,
81}
82
83const fn default_true() -> bool {
84 true
85}
86
87const OS_RESERVE_MB: u64 = 3072;
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RecommendationSet {
96 pub picks: Vec<Recommendation>,
99 pub not_enough_memory: Vec<Recommendation>,
102 pub note: Option<String>,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum ModelFitStatus {
115 Fits,
119 TooBig,
121 #[default]
126 Unknown,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct ModelFit {
133 pub fit: ModelFitStatus,
134 pub estimated_peak_mb: Option<u64>,
139 pub platform_compatible: bool,
141}
142
143pub fn model_fit(m: &ModelSchema, hw: &HardwareInfo, policy: Option<&ResourcePolicy>) -> ModelFit {
150 let memory_limits = RecommendationMemoryLimits::for_policy(hw, policy);
151 let estimate = estimate_model_memory(m, hw, RECOMMENDATION_CONTEXT_TOKENS);
152 let status = fit_status(m, hw, &estimate, &memory_limits);
153 let fit = match status {
154 FitStatus::Fits | FitStatus::ServerProvided => ModelFitStatus::Fits,
155 FitStatus::TooBig => ModelFitStatus::TooBig,
156 FitStatus::Unknown => ModelFitStatus::Unknown,
157 };
158 let estimated_peak_mb = match status {
159 FitStatus::ServerProvided => None,
160 _ if m.size_mb() == 0 && m.ram_mb() == 0 => None,
163 _ => Some(estimate.estimated_peak_mb),
164 };
165 ModelFit {
166 fit,
167 estimated_peak_mb,
168 platform_compatible: platform_compatible(m, hw),
169 }
170}
171
172pub fn recommend(
179 models: &[&ModelSchema],
180 hw: &HardwareInfo,
181 use_case: UseCase,
182 tier: QualityTier,
183 privacy: Privacy,
184) -> RecommendationSet {
185 recommend_inner(models, hw, use_case, tier, privacy, None)
186}
187
188pub fn recommend_with_policy(
192 models: &[&ModelSchema],
193 hw: &HardwareInfo,
194 policy: &ResourcePolicy,
195 use_case: UseCase,
196 tier: QualityTier,
197 privacy: Privacy,
198) -> RecommendationSet {
199 recommend_inner(models, hw, use_case, tier, privacy, Some(policy))
200}
201
202fn recommend_inner(
203 models: &[&ModelSchema],
204 hw: &HardwareInfo,
205 use_case: UseCase,
206 tier: QualityTier,
207 privacy: Privacy,
208 policy: Option<&ResourcePolicy>,
209) -> RecommendationSet {
210 let accel = hw.supported_acceleration();
211 let everyday_assistant_balanced = policy.is_some_and(|policy| {
212 policy.profile == ResourceProfile::Everyday
213 && use_case == UseCase::Assistant
214 && tier == QualityTier::Balanced
215 });
216 let memory_limits = RecommendationMemoryLimits::for_policy(hw, policy);
217 let sort = |v: &mut Vec<RankedRecommendation>| {
218 v.sort_by(|a, b| {
222 if everyday_assistant_balanced {
223 let policy_class = |recommendation: &Recommendation| {
228 if recommendation.fit == FitStatus::Unknown {
229 3
230 } else if recommendation.is_local && recommendation.within_recommendation_target
231 {
232 0
233 } else if !recommendation.is_local
234 && recommendation.within_recommendation_target
235 {
236 1
237 } else {
238 2
239 }
240 };
241 let a_recommendation = &a.recommendation;
242 let b_recommendation = &b.recommendation;
243 return policy_class(a_recommendation)
244 .cmp(&policy_class(b_recommendation))
245 .then(
246 b_recommendation
247 .already_installed
248 .cmp(&a_recommendation.already_installed),
249 )
250 .then(a.estimated_peak_mb.cmp(&b.estimated_peak_mb))
251 .then(a.latency_p50_ms.cmp(&b.latency_p50_ms))
252 .then_with(|| b_recommendation.score.total_cmp(&a_recommendation.score))
253 .then(a_recommendation.model_id.cmp(&b_recommendation.model_id));
254 }
255 b.recommendation
256 .score
257 .total_cmp(&a.recommendation.score)
258 .then(
259 b.recommendation
260 .already_installed
261 .cmp(&a.recommendation.already_installed),
262 )
263 .then(
264 a.recommendation
265 .download_mb
266 .cmp(&b.recommendation.download_mb),
267 )
268 .then(a.recommendation.model_id.cmp(&b.recommendation.model_id))
269 });
270 };
271
272 let (mut picks, mut not_enough_memory): (Vec<_>, Vec<_>) = models
273 .iter()
274 .filter(|m| {
275 passes_base_filter(m, hw, use_case, privacy)
276 && (!everyday_assistant_balanced
277 || m.has_capability(crate::schema::ModelCapability::ToolUse))
278 })
279 .map(|model| build_recommendation(model, hw, &accel, use_case, tier, &memory_limits))
280 .partition(|ranked| ranked.recommendation.fit != FitStatus::TooBig);
281 sort(&mut picks);
282 sort(&mut not_enough_memory);
283 let picks: Vec<_> = picks
284 .into_iter()
285 .map(|ranked| ranked.recommendation)
286 .collect();
287 let not_enough_memory: Vec<_> = not_enough_memory
288 .into_iter()
289 .map(|ranked| ranked.recommendation)
290 .collect();
291
292 let note = explain_if_needed(&picks, ¬_enough_memory, hw, use_case, tier, privacy)
293 .or_else(|| unmeasured_larger_candidates(models, &picks, tier));
294 RecommendationSet {
295 picks,
296 not_enough_memory,
297 note,
298 }
299}
300
301struct RankedRecommendation {
302 recommendation: Recommendation,
303 estimated_peak_mb: u64,
304 latency_p50_ms: u64,
305}
306
307#[derive(Clone, Copy)]
308struct RecommendationMemoryLimits {
309 legacy_budget_mb: u64,
310 policy_host_budget_mb: Option<u64>,
311 recommendation_target_mb: Option<u64>,
312}
313
314impl RecommendationMemoryLimits {
315 fn for_policy(hw: &HardwareInfo, policy: Option<&ResourcePolicy>) -> Self {
320 Self {
321 legacy_budget_mb: memory_budget_mb(hw),
322 policy_host_budget_mb: policy.map(|policy| {
323 policy
324 .effective_budget(hw.total_ram_mb)
325 .configured_model_ceiling_mb
326 }),
327 recommendation_target_mb: policy
328 .map(|policy| policy.recommendation_target_mb(hw.total_ram_mb)),
329 }
330 }
331}
332
333fn passes_base_filter(
337 m: &ModelSchema,
338 hw: &HardwareInfo,
339 use_case: UseCase,
340 privacy: Privacy,
341) -> bool {
342 if m.deprecated {
343 return false;
344 }
345 if !use_case
347 .required_capabilities()
348 .iter()
349 .all(|c| m.has_capability(*c))
350 {
351 return false;
352 }
353 if privacy == Privacy::OnDevice && !m.is_local() {
355 return false;
356 }
357 platform_compatible(m, hw)
359}
360
361pub fn platform_compatible(m: &ModelSchema, hw: &HardwareInfo) -> bool {
368 let windows_only = matches!(m.source, crate::schema::ModelSource::WindowsSpeech { .. })
369 || m.tags.iter().any(|tag| tag == "windows-only");
370 let linux_only = m.tags.iter().any(|tag| tag == "linux-only");
371 let apple_compatible = !m.requires_apple_silicon()
372 || matches!(
373 hw.supported_acceleration(),
374 SupportedAcceleration::Apple { .. }
375 );
376 let windows_compatible = !windows_only || hw.os.eq_ignore_ascii_case("windows");
377 let linux_compatible = !linux_only || hw.os.eq_ignore_ascii_case("linux");
378 apple_compatible && windows_compatible && linux_compatible
379}
380
381fn explain_if_needed(
383 picks: &[Recommendation],
384 too_big: &[Recommendation],
385 hw: &HardwareInfo,
386 use_case: UseCase,
387 tier: QualityTier,
388 privacy: Privacy,
389) -> Option<String> {
390 let purpose = use_case_purpose(use_case);
391 if picks.is_empty() {
392 let ram_gb = hw.total_ram_mb / 1024;
393 return Some(if !too_big.is_empty() {
394 match privacy {
395 Privacy::OnDevice => format!(
396 "No on-device model for {purpose} fits your {ram_gb} GB machine. \
397 Free up memory, pick a smaller tier, or allow cloud models."
398 ),
399 Privacy::CloudOk => format!(
400 "No local model for {purpose} fits your {ram_gb} GB machine, and no \
401 cloud model is configured. Add an API key or free up memory."
402 ),
403 }
404 } else {
405 format!("No model available for {purpose} on this machine.")
406 });
407 }
408 if picks[0].requires_cloud_consent {
411 return Some(format!(
412 "The best {purpose} pick runs in the cloud and needs your OK before first use. \
413 {} fits locally if you prefer on-device.",
414 picks
415 .iter()
416 .find(|p| p.is_local)
417 .map(|p| p.display_name.as_str())
418 .unwrap_or("No local model")
419 ));
420 }
421 let _ = tier;
422 None
423}
424
425fn unmeasured_larger_candidates(
437 models: &[&ModelSchema],
438 picks: &[Recommendation],
439 tier: QualityTier,
440) -> Option<String> {
441 if tier != QualityTier::MostCapable {
442 return None;
443 }
444 let top = picks.first()?;
445 let top_params = models
446 .iter()
447 .find(|m| m.id == top.model_id)
448 .map(|m| param_billions_total(m))?;
449
450 let mut larger: Vec<&str> = models
451 .iter()
452 .filter(|m| {
453 m.is_local()
454 && m.public_benchmarks.is_empty()
455 && param_billions_total(m) > top_params * 1.5
456 })
457 .map(|m| m.name.as_str())
458 .collect();
459 if larger.is_empty() {
460 return None;
461 }
462 larger.sort_unstable();
463 larger.dedup();
464 let shown = larger
465 .iter()
466 .take(3)
467 .copied()
468 .collect::<Vec<_>>()
469 .join(", ");
470 let rest = larger.len().saturating_sub(3);
471 let and_more = if rest > 0 {
472 format!(" and {rest} more")
473 } else {
474 String::new()
475 };
476 Some(format!(
477 "Ranked among models with a measured quality score. Larger ones this \
478 machine can run are unscored, so they cannot be ranked here yet: \
479 {shown}{and_more}. Run `scripts/bench-contribute.sh` to score them."
480 ))
481}
482
483fn use_case_purpose(use_case: UseCase) -> &'static str {
484 match use_case {
485 UseCase::Assistant => "chat & general help",
486 UseCase::Coding => "coding",
487 UseCase::Summarize => "summarizing",
488 UseCase::Vision => "understanding images",
489 UseCase::Transcription => "transcription",
490 UseCase::Search => "semantic search",
491 }
492}
493
494fn build_recommendation(
495 m: &ModelSchema,
496 hw: &HardwareInfo,
497 accel: &SupportedAcceleration,
498 use_case: UseCase,
499 tier: QualityTier,
500 memory_limits: &RecommendationMemoryLimits,
501) -> RankedRecommendation {
502 let estimate = estimate_model_memory(m, hw, RECOMMENDATION_CONTEXT_TOKENS);
503 let fit = fit_status(m, hw, &estimate, memory_limits);
504 let quality = quality_score(m);
505 let latency = latency_score(m, accel);
506 let pressure = memory_pressure(&estimate, memory_limits.legacy_budget_mb);
507 let w = tier.weights();
508 let mut score =
510 w.quality * quality + w.latency * latency + w.memory_pressure * (1.0 - pressure);
511 let pref_hits = use_case
513 .preferred_capabilities()
514 .iter()
515 .filter(|c| m.has_capability(**c))
516 .count();
517 score += 0.05 * pref_hits as f32;
518
519 let is_local = m.is_local();
520 let within_recommendation_target = match memory_limits.recommendation_target_mb {
521 None => true,
522 Some(_) if fit == FitStatus::Unknown => false,
523 Some(target_mb) if is_local => match hw.supported_acceleration() {
524 SupportedAcceleration::Cuda {
525 device_memory_mb: Some(device_memory_mb),
526 } => {
527 let host_required_mb = estimate
528 .estimated_peak_mb
529 .saturating_sub(estimate.weights_mb);
530 fit == FitStatus::Fits
531 && estimate.weights_mb <= device_memory_mb
532 && host_required_mb <= target_mb
533 }
534 SupportedAcceleration::Cuda {
535 device_memory_mb: None,
536 } => false,
537 _ => fit == FitStatus::Fits && estimate.estimated_peak_mb <= target_mb,
538 },
539 Some(_) => true,
540 };
541 RankedRecommendation {
542 estimated_peak_mb: if is_local {
543 estimate.estimated_peak_mb
544 } else {
545 0
546 },
547 latency_p50_ms: m.performance.latency_p50_ms.unwrap_or(u64::MAX),
548 recommendation: Recommendation {
549 model_id: m.id.clone(),
550 display_name: m.name.clone(),
551 role: use_case.role(),
552 rationale: rationale(m, hw, use_case, tier, fit, quality),
553 download_mb: if m.downloads_weights() {
554 m.size_mb()
555 } else {
556 0
557 },
558 already_installed: m.has_installed_weights(),
559 fit,
560 acceleration: accel.clone(),
561 is_local,
562 requires_cloud_consent: !is_local,
563 trust_tier: m.trust_tier,
564 score,
565 within_recommendation_target,
566 },
567 }
568}
569
570fn quality_score(m: &ModelSchema) -> f32 {
574 if !m.public_benchmarks.is_empty() {
575 let sum: f64 = m.public_benchmarks.iter().map(|b| b.score).sum();
576 return (sum / m.public_benchmarks.len() as f64).clamp(0.0, 1.0) as f32;
577 }
578 let b = param_billions_total(m).max(0.1);
581 (b / (b + 7.0)).clamp(0.0, 1.0)
582}
583
584fn latency_score(m: &ModelSchema, accel: &SupportedAcceleration) -> f32 {
587 let b = param_billions_active(m).max(0.1);
588 let size_term = 8.0 / (b + 8.0);
590 let accel_bonus = match accel {
591 SupportedAcceleration::Apple { .. } | SupportedAcceleration::Cuda { .. } => 0.1,
592 _ => 0.0,
593 };
594 (size_term + accel_bonus).clamp(0.0, 1.0)
595}
596
597fn memory_pressure(estimate: &crate::resource_policy::ModelMemoryEstimate, budget: u64) -> f32 {
600 if budget == 0 {
601 return 1.0;
602 }
603 (estimate.estimated_peak_mb as f32 / budget as f32).clamp(0.0, 1.5)
604}
605
606fn fit_status(
608 m: &ModelSchema,
609 hw: &HardwareInfo,
610 estimate: &crate::resource_policy::ModelMemoryEstimate,
611 memory_limits: &RecommendationMemoryLimits,
612) -> FitStatus {
613 if m.is_remote() || m.is_delegated() {
617 return FitStatus::ServerProvided;
618 }
619 if m.is_os_provided() {
623 return FitStatus::Fits;
624 }
625 if m.size_mb() == 0 && m.ram_mb() == 0 {
626 return FitStatus::Unknown;
627 }
628 let required_mb = estimate.estimated_peak_mb;
629 let fits = if let Some(host_budget) = memory_limits.policy_host_budget_mb {
630 match hw.supported_acceleration() {
631 SupportedAcceleration::Cuda {
632 device_memory_mb: Some(device_memory_mb),
633 } => {
634 let host_required_mb = estimate
635 .estimated_peak_mb
636 .saturating_sub(estimate.weights_mb);
637 estimate.weights_mb <= device_memory_mb && host_required_mb <= host_budget
638 }
639 SupportedAcceleration::Cuda {
640 device_memory_mb: None,
641 } => return FitStatus::Unknown,
642 _ => required_mb <= host_budget,
643 }
644 } else {
645 required_mb <= memory_limits.legacy_budget_mb
646 };
647 if fits {
648 FitStatus::Fits
649 } else {
650 FitStatus::TooBig
651 }
652}
653
654fn memory_budget_mb(hw: &HardwareInfo) -> u64 {
658 match hw.supported_acceleration() {
659 SupportedAcceleration::Apple { unified_memory_mb } => {
660 unified_memory_mb.saturating_sub(OS_RESERVE_MB)
661 }
662 SupportedAcceleration::Cuda { device_memory_mb } => {
663 device_memory_mb.unwrap_or(hw.total_ram_mb)
664 }
665 _ => hw.total_ram_mb.saturating_sub(OS_RESERVE_MB),
667 }
668}
669
670fn param_billions_total(m: &ModelSchema) -> f32 {
675 model_parameter_billions_total(m)
676}
677
678fn param_billions_active(m: &ModelSchema) -> f32 {
681 model_parameter_billions_active(m)
682}
683
684fn rationale(
687 m: &ModelSchema,
688 hw: &HardwareInfo,
689 use_case: UseCase,
690 tier: QualityTier,
691 fit: FitStatus,
692 quality: f32,
693) -> String {
694 let purpose = use_case_purpose(use_case);
695 let machine = match hw.supported_acceleration() {
696 SupportedAcceleration::Apple { unified_memory_mb } => {
697 format!(
698 "your {} GB Apple Silicon Mac (Metal)",
699 unified_memory_mb / 1024
700 )
701 }
702 SupportedAcceleration::Cuda { device_memory_mb } => match device_memory_mb {
703 Some(mb) => format!("your {} GB NVIDIA GPU (CUDA)", mb / 1024),
704 None => "your NVIDIA GPU (CUDA)".to_string(),
705 },
706 SupportedAcceleration::UnsupportedDiscreteGpu { .. } | SupportedAcceleration::Cpu => {
707 format!("your {} GB machine (CPU)", hw.total_ram_mb / 1024)
708 }
709 };
710
711 match fit {
712 FitStatus::ServerProvided
713 if matches!(&m.source, crate::schema::ModelSource::VllmMlx { .. }) =>
714 {
715 format!(
716 "{}: external server for {} — its operator runs the model, nothing to download",
717 m.name, purpose
718 )
719 }
720 FitStatus::ServerProvided if m.is_remote() => format!(
721 "{}: cloud model for {} — runs on Parslee's servers, nothing to download",
722 m.name, purpose
723 ),
724 FitStatus::ServerProvided => format!(
725 "{}: served externally for {} — no local memory needed",
726 m.name, purpose
727 ),
728 _ => {
729 let tier_word = match tier {
730 QualityTier::Fastest => "fastest",
731 QualityTier::Balanced => "best-balanced",
732 QualityTier::MostCapable => "most capable",
733 };
734 let quality_note = if quality >= 0.7 { "high-quality " } else { "" };
735 let size = if m.size_mb() >= 1024 {
736 format!("{:.1} GB download", m.size_mb() as f64 / 1024.0)
737 } else {
738 format!("{} MB download", m.size_mb())
739 };
740 format!(
741 "{}: the {} {}{} model that fits {} ({})",
742 m.name, tier_word, quality_note, purpose, machine, size
743 )
744 }
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use crate::hardware::{GpuBackend, GpuDevice, GpuVendor};
752 use crate::schema::{CostModel, ModelCapability, ModelSource, PerformanceEnvelope};
753
754 pub(super) fn hw(accel_backend: GpuBackend, ram_mb: u64, gpu_mb: Option<u64>) -> HardwareInfo {
755 HardwareInfo {
756 os: "test".into(),
757 arch: "test".into(),
758 cpu_cores: 8,
759 total_ram_mb: ram_mb,
760 gpu_backend: accel_backend,
761 gpu_memory_mb: gpu_mb,
762 gpu_devices: vec![],
763 recommended_model: String::new(),
764 recommended_context: 4096,
765 max_model_mb: 0,
766 }
767 }
768
769 pub(super) fn mac(ram_gb: u64) -> HardwareInfo {
770 hw(GpuBackend::Metal, ram_gb * 1024, None)
772 }
773
774 pub(super) fn local_model(id: &str, name: &str, params: &str, size_mb: u64) -> ModelSchema {
775 ModelSchema {
776 id: id.into(),
777 name: name.into(),
778 provider: "qwen".into(),
779 family: "qwen3".into(),
780 version: String::new(),
781 capabilities: vec![ModelCapability::Generate, ModelCapability::Code],
782 context_length: 32768,
783 max_output_tokens: None,
784 param_count: params.into(),
785 quantization: Some(crate::schema::Quantization::parse("Q4_K_M")),
786 performance: PerformanceEnvelope::default(),
787 cost: CostModel {
788 size_mb: Some(size_mb),
789 ram_mb: Some(size_mb),
790 ..Default::default()
791 },
792 source: ModelSource::Local {
793 hf_repo: "x/y".into(),
794 hf_filename: "m.gguf".into(),
795 tokenizer_repo: "x/y".into(),
796 },
797 tags: vec![],
798 supported_params: vec![],
799 public_benchmarks: vec![],
800 trust_tier: TrustTier::Curated,
801 deprecated: false,
802 available: false,
803 weights_ready: false,
804 }
805 }
806
807 fn catalog() -> Vec<ModelSchema> {
808 vec![
809 local_model("qwen/qwen3-0.6b", "Qwen3-0.6B", "0.6B", 650),
810 local_model("qwen/qwen3-4b", "Qwen3-4B", "4B", 2500),
811 local_model("qwen/qwen3-8b", "Qwen3-8B", "8B", 4900),
812 local_model("qwen/qwen3-30b", "Qwen3-30B-A3B", "30B (3B active)", 17000),
813 ]
814 }
815
816 fn qwen_mlx_policy_catalog() -> Vec<ModelSchema> {
817 let mut four = local_model("mlx/qwen3-4b:4bit", "Qwen3-4B-MLX", "4B", 2400);
818 four.source = ModelSource::Mlx {
819 hf_repo: "mlx-community/Qwen3-4B-4bit".into(),
820 hf_weight_file: None,
821 };
822 four.capabilities = vec![
823 ModelCapability::Generate,
824 ModelCapability::Code,
825 ModelCapability::ToolUse,
826 ];
827 four.performance.latency_p50_ms = Some(294);
828
829 let mut eight = local_model("mlx/qwen3-8b:4bit", "Qwen3-8B-MLX", "8B", 4800);
830 eight.source = ModelSource::Mlx {
831 hf_repo: "mlx-community/Qwen3-8B-4bit".into(),
832 hf_weight_file: None,
833 };
834 eight.capabilities = vec![
835 ModelCapability::Generate,
836 ModelCapability::Code,
837 ModelCapability::ToolUse,
838 ];
839 eight.performance.latency_p50_ms = Some(451);
840 vec![four, eight]
841 }
842
843 fn refs(v: &[ModelSchema]) -> Vec<&ModelSchema> {
844 v.iter().collect()
845 }
846
847 #[test]
848 fn fastest_prefers_the_small_model() {
849 let cat = catalog();
850 let recs = recommend(
851 &refs(&cat),
852 &mac(36),
853 UseCase::Coding,
854 QualityTier::Fastest,
855 Privacy::OnDevice,
856 )
857 .picks;
858 assert_eq!(recs[0].display_name, "Qwen3-0.6B");
859 }
860
861 #[test]
862 fn downloadable_catalog_entry_is_not_installed_until_weights_are_ready() {
863 let mut model = qwen_mlx_policy_catalog().remove(0);
864 model.available = true;
865 model.weights_ready = false;
866
867 let set = recommend(
868 &[&model],
869 &mac(32),
870 UseCase::Assistant,
871 QualityTier::Balanced,
872 Privacy::OnDevice,
873 );
874
875 assert_eq!(set.picks.len(), 1);
876 assert!(!set.picks[0].already_installed);
877 }
878
879 #[test]
880 fn everyday_32gb_apple_assistant_balanced_prefers_four_b_and_keeps_eight_b() {
881 let mut catalog = qwen_mlx_policy_catalog();
882 let mut no_tools = local_model("mlx/qwen3-1.7b:3bit", "Qwen3-1.7B-MLX", "1.7B", 900);
883 no_tools.source = ModelSource::Mlx {
884 hf_repo: "mlx-community/Qwen3-1.7B-3bit".into(),
885 hf_weight_file: None,
886 };
887 no_tools.capabilities = vec![ModelCapability::Generate];
888 catalog.push(no_tools);
889 let set = recommend_with_policy(
890 &refs(&catalog),
891 &mac(32),
892 &crate::resource_policy::ResourcePolicy::everyday(),
893 UseCase::Assistant,
894 QualityTier::Balanced,
895 Privacy::OnDevice,
896 );
897
898 let ids: Vec<&str> = set
899 .picks
900 .iter()
901 .map(|pick| pick.model_id.as_str())
902 .collect();
903 assert_eq!(ids, ["mlx/qwen3-4b:4bit", "mlx/qwen3-8b:4bit"]);
904 let budget = crate::resource_policy::ResourcePolicy::everyday().effective_budget(32 * 1024);
905 for model in &catalog[..2] {
906 assert!(
907 estimate_model_memory(model, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
908 .estimated_peak_mb
909 < budget.configured_model_ceiling_mb
910 );
911 }
912 }
913
914 #[test]
915 fn everyday_target_prefers_under_half_ceiling_but_keeps_heavier_fit_visible() {
916 let mut four = qwen_mlx_policy_catalog().remove(0);
917 four.cost.ram_mb = Some(3_500);
918 four.cost.size_mb = Some(3_500);
919 let mut nine = four.clone();
920 nine.id = "mlx/qwen3-9b:4bit".into();
921 nine.name = "Qwen3-9B-MLX".into();
922 nine.param_count = "9B".into();
923 nine.cost.ram_mb = Some(9_000);
924 nine.cost.size_mb = Some(9_000);
925 nine.weights_ready = true;
926 nine.public_benchmarks = vec![crate::schema::BenchmarkScore {
927 name: "quality".into(),
928 score: 0.99,
929 harness: None,
930 source_url: None,
931 measured_at: None,
932 }];
933 let catalog = vec![nine, four];
934
935 let set = recommend_with_policy(
936 &refs(&catalog),
937 &mac(32),
938 &ResourcePolicy::everyday(),
939 UseCase::Assistant,
940 QualityTier::Balanced,
941 Privacy::OnDevice,
942 );
943
944 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
945 assert_eq!(set.picks[1].model_id, "mlx/qwen3-9b:4bit");
946 assert!(set.picks[0].within_recommendation_target);
947 assert!(!set.picks[1].within_recommendation_target);
948
949 let only_heavy = recommend_with_policy(
950 &[&catalog[0]],
951 &mac(32),
952 &ResourcePolicy::everyday(),
953 UseCase::Assistant,
954 QualityTier::Balanced,
955 Privacy::OnDevice,
956 );
957 assert_eq!(only_heavy.picks[0].fit, FitStatus::Fits);
958 assert!(!only_heavy.picks[0].within_recommendation_target);
959 }
960
961 #[test]
962 fn everyday_ranking_is_permutation_stable_across_local_and_cloud_candidates() {
963 let mut four = qwen_mlx_policy_catalog().remove(0);
964 four.cost.ram_mb = Some(3_500);
965 four.cost.size_mb = Some(3_500);
966
967 let mut nine = four.clone();
968 nine.id = "mlx/qwen3-9b:4bit".into();
969 nine.name = "Qwen3-9B-MLX".into();
970 nine.param_count = "9B".into();
971 nine.cost.ram_mb = Some(9_000);
972 nine.cost.size_mb = Some(9_000);
973 nine.public_benchmarks = vec![crate::schema::BenchmarkScore {
974 name: "quality".into(),
975 score: 0.99,
976 harness: None,
977 source_url: None,
978 measured_at: None,
979 }];
980
981 let mut cloud = four.clone();
982 cloud.id = "remote/tool-use".into();
983 cloud.name = "ToolUse Cloud".into();
984 cloud.source = ModelSource::RemoteApi {
985 endpoint: "https://example.invalid".into(),
986 api_key_env: "TEST_KEY".into(),
987 api_key_envs: vec![],
988 api_version: None,
989 protocol: crate::schema::ApiProtocol::OpenAiCompat,
990 };
991 cloud.cost.ram_mb = None;
992 cloud.cost.size_mb = None;
993 cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
994 name: "quality".into(),
995 score: 1.0,
996 harness: None,
997 source_url: None,
998 measured_at: None,
999 }];
1000
1001 let candidates = [four, nine, cloud];
1002 let permutations = [
1003 [0, 1, 2],
1004 [0, 2, 1],
1005 [1, 0, 2],
1006 [1, 2, 0],
1007 [2, 0, 1],
1008 [2, 1, 0],
1009 ];
1010 let expected = ["mlx/qwen3-4b:4bit", "remote/tool-use", "mlx/qwen3-9b:4bit"];
1011 for permutation in permutations {
1012 let catalog: Vec<ModelSchema> = permutation
1013 .into_iter()
1014 .map(|index| candidates[index].clone())
1015 .collect();
1016 let set = recommend_with_policy(
1017 &refs(&catalog),
1018 &mac(32),
1019 &ResourcePolicy::everyday(),
1020 UseCase::Assistant,
1021 QualityTier::Balanced,
1022 Privacy::CloudOk,
1023 );
1024 let actual: Vec<&str> = set
1025 .picks
1026 .iter()
1027 .map(|pick| pick.model_id.as_str())
1028 .collect();
1029 assert_eq!(actual, expected, "permutation {permutation:?}");
1030 }
1031 }
1032
1033 #[test]
1034 fn cuda_policy_checks_gpu_weights_and_host_overhead_as_separate_pools() {
1035 let mut model = qwen_mlx_policy_catalog().remove(0);
1036 model.source = ModelSource::Local {
1037 hf_repo: "x/y".into(),
1038 hf_filename: "m.gguf".into(),
1039 tokenizer_repo: "x/y".into(),
1040 };
1041
1042 model.cost.ram_mb = Some(1_000);
1043 model.cost.size_mb = Some(1_000);
1044 let vram_too_small = recommend_with_policy(
1045 &[&model],
1046 &hw(GpuBackend::Cuda, 64 * 1024, Some(900)),
1047 &ResourcePolicy::everyday(),
1048 UseCase::Assistant,
1049 QualityTier::Balanced,
1050 Privacy::OnDevice,
1051 );
1052 assert_eq!(vram_too_small.not_enough_memory[0].fit, FitStatus::TooBig);
1053
1054 model.cost.ram_mb = Some(5_000);
1055 model.cost.size_mb = Some(5_000);
1056 let separate_pools_fit = recommend_with_policy(
1060 &[&model],
1061 &hw(GpuBackend::Cuda, 16 * 1024, Some(16 * 1024)),
1062 &ResourcePolicy::everyday(),
1063 UseCase::Assistant,
1064 QualityTier::Balanced,
1065 Privacy::OnDevice,
1066 );
1067 assert_eq!(separate_pools_fit.picks[0].fit, FitStatus::Fits);
1068 assert!(separate_pools_fit.picks[0].within_recommendation_target);
1069 }
1070
1071 #[test]
1072 fn custom_zero_blocks_automatic_and_explicit_local_fit() {
1073 let model = qwen_mlx_policy_catalog().remove(0);
1074 let set = recommend_with_policy(
1075 &[&model],
1076 &mac(32),
1077 &ResourcePolicy::custom_gb(0.0).unwrap(),
1078 UseCase::Assistant,
1079 QualityTier::Balanced,
1080 Privacy::OnDevice,
1081 );
1082
1083 assert!(set.picks.is_empty());
1084 assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1085 assert!(!set.not_enough_memory[0].within_recommendation_target);
1086 }
1087
1088 #[test]
1089 fn local_focused_uses_its_full_configured_ceiling() {
1090 let mut model = qwen_mlx_policy_catalog().remove(0);
1091 model.cost.ram_mb = Some(10_400);
1095 model.cost.size_mb = Some(10_400);
1096 let set = recommend_with_policy(
1097 &[&model],
1098 &mac(16),
1099 &ResourcePolicy::local_focused(),
1100 UseCase::Assistant,
1101 QualityTier::Balanced,
1102 Privacy::OnDevice,
1103 );
1104
1105 assert_eq!(set.picks[0].fit, FitStatus::Fits);
1106 assert!(set.picks[0].within_recommendation_target);
1107 }
1108
1109 #[test]
1110 fn unknown_memory_never_outranks_a_known_fit() {
1111 let mut known = qwen_mlx_policy_catalog().remove(0);
1112 known.public_benchmarks.clear();
1113 let mut unknown = known.clone();
1114 unknown.id = "local/unknown-memory".into();
1115 unknown.name = "Unknown Memory".into();
1116 unknown.param_count.clear();
1117 unknown.cost.ram_mb = None;
1118 unknown.cost.size_mb = None;
1119 unknown.public_benchmarks = vec![crate::schema::BenchmarkScore {
1120 name: "quality".into(),
1121 score: 1.0,
1122 harness: None,
1123 source_url: None,
1124 measured_at: None,
1125 }];
1126
1127 let set = recommend_with_policy(
1128 &refs(&[unknown, known]),
1129 &mac(32),
1130 &ResourcePolicy::everyday(),
1131 UseCase::Assistant,
1132 QualityTier::Balanced,
1133 Privacy::OnDevice,
1134 );
1135 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1136 assert_eq!(set.picks[1].fit, FitStatus::Unknown);
1137 assert!(!set.picks[1].within_recommendation_target);
1138 }
1139
1140 #[test]
1141 fn everyday_assistant_tool_floor_excludes_generate_only_cloud_models() {
1142 let mut local = qwen_mlx_policy_catalog().remove(0);
1143 local.public_benchmarks.clear();
1144 let mut cloud = local.clone();
1145 cloud.id = "remote/high-score-generate-only".into();
1146 cloud.name = "Remote Generate Only".into();
1147 cloud.source = ModelSource::RemoteApi {
1148 endpoint: "https://api".into(),
1149 api_key_env: "K".into(),
1150 api_key_envs: vec![],
1151 api_version: None,
1152 protocol: crate::schema::ApiProtocol::OpenAiCompat,
1153 };
1154 cloud.capabilities = vec![ModelCapability::Generate];
1155 cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1156 name: "quality".into(),
1157 score: 1.0,
1158 harness: None,
1159 source_url: None,
1160 measured_at: None,
1161 }];
1162
1163 let set = recommend_with_policy(
1164 &refs(&[cloud, local]),
1165 &mac(32),
1166 &ResourcePolicy::everyday(),
1167 UseCase::Assistant,
1168 QualityTier::Balanced,
1169 Privacy::CloudOk,
1170 );
1171 assert_eq!(set.picks.len(), 1);
1172 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
1173 }
1174
1175 #[test]
1176 fn policy_entry_point_preserves_legacy_order_among_policy_eligible_candidates() {
1177 let catalog = catalog();
1178 for (policy, use_case, tier) in [
1179 (
1180 ResourcePolicy::everyday(),
1181 UseCase::Assistant,
1182 QualityTier::Fastest,
1183 ),
1184 (
1185 ResourcePolicy::everyday(),
1186 UseCase::Assistant,
1187 QualityTier::MostCapable,
1188 ),
1189 (
1190 ResourcePolicy::everyday(),
1191 UseCase::Coding,
1192 QualityTier::Balanced,
1193 ),
1194 (
1195 ResourcePolicy::local_focused(),
1196 UseCase::Assistant,
1197 QualityTier::Balanced,
1198 ),
1199 ] {
1200 let legacy = recommend(&refs(&catalog), &mac(36), use_case, tier, Privacy::OnDevice);
1201 let policy_aware = recommend_with_policy(
1202 &refs(&catalog),
1203 &mac(36),
1204 &policy,
1205 use_case,
1206 tier,
1207 Privacy::OnDevice,
1208 );
1209 let legacy_common: Vec<&str> = legacy
1210 .picks
1211 .iter()
1212 .filter(|pick| {
1213 policy_aware
1214 .picks
1215 .iter()
1216 .any(|candidate| candidate.model_id == pick.model_id)
1217 })
1218 .map(|pick| pick.model_id.as_str())
1219 .collect();
1220 let policy_common: Vec<&str> = policy_aware
1221 .picks
1222 .iter()
1223 .filter(|pick| {
1224 legacy
1225 .picks
1226 .iter()
1227 .any(|candidate| candidate.model_id == pick.model_id)
1228 })
1229 .map(|pick| pick.model_id.as_str())
1230 .collect();
1231 assert_eq!(
1232 policy_common, legacy_common,
1233 "{policy:?} {use_case:?} {tier:?}"
1234 );
1235 }
1236 }
1237
1238 #[test]
1239 fn most_capable_prefers_the_big_model_when_it_fits() {
1240 let cat = catalog();
1241 let recs = recommend(
1242 &refs(&cat),
1243 &mac(36), UseCase::Coding,
1245 QualityTier::MostCapable,
1246 Privacy::OnDevice,
1247 )
1248 .picks;
1249 assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1250 assert_eq!(recs[0].fit, FitStatus::Fits);
1251 }
1252
1253 #[test]
1254 fn too_big_models_are_excluded_on_small_machines() {
1255 let cat = catalog();
1256 let recs = recommend(
1257 &refs(&cat),
1258 &mac(8), UseCase::Coding,
1260 QualityTier::MostCapable,
1261 Privacy::OnDevice,
1262 )
1263 .picks;
1264 let names: Vec<&str> = recs.iter().map(|r| r.display_name.as_str()).collect();
1265 assert!(!names.contains(&"Qwen3-30B-A3B"), "30B must not fit 8GB");
1266 assert!(recs.iter().all(|r| r.fit == FitStatus::Fits));
1267 assert!(!recs.is_empty(), "the 0.6B model should still be offered");
1268 }
1269
1270 #[test]
1271 fn balanced_picks_a_capable_model_that_fits() {
1272 let cat = catalog();
1273 let recs = recommend(
1274 &refs(&cat),
1275 &mac(16),
1276 UseCase::Coding,
1277 QualityTier::Balanced,
1278 Privacy::OnDevice,
1279 )
1280 .picks;
1281 assert!(matches!(
1284 recs[0].display_name.as_str(),
1285 "Qwen3-4B" | "Qwen3-8B"
1286 ));
1287 }
1288
1289 #[test]
1290 fn search_only_returns_embedding_models() {
1291 let mut cat = catalog();
1292 let mut embed = local_model("qwen/embed", "Qwen3-Embedding", "0.6B", 640);
1293 embed.capabilities = vec![ModelCapability::Embed];
1294 cat.push(embed);
1295 let recs = recommend(
1296 &refs(&cat),
1297 &mac(16),
1298 UseCase::Search,
1299 QualityTier::Balanced,
1300 Privacy::OnDevice,
1301 )
1302 .picks;
1303 assert_eq!(recs.len(), 1, "only the embed model is in the Search lane");
1304 assert_eq!(recs[0].display_name, "Qwen3-Embedding");
1305 assert_eq!(recs[0].role, UseCaseRole::Retrieval);
1306 }
1307
1308 #[test]
1309 fn deprecated_models_are_never_recommended() {
1310 let mut cat = catalog();
1311 cat[1].deprecated = true; let recs = recommend(
1313 &refs(&cat),
1314 &mac(16),
1315 UseCase::Coding,
1316 QualityTier::Balanced,
1317 Privacy::OnDevice,
1318 )
1319 .picks;
1320 assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B"));
1321 }
1322
1323 #[test]
1324 fn on_device_excludes_cloud_but_cloud_ok_includes_it_with_consent() {
1325 let mut cat = catalog();
1326 let mut cloud = local_model("anthropic/sonnet", "Claude Sonnet", "", 0);
1327 cloud.capabilities = vec![ModelCapability::Generate, ModelCapability::Code];
1328 cloud.source = ModelSource::RemoteApi {
1329 endpoint: "https://api".into(),
1330 api_key_env: "K".into(),
1331 api_key_envs: vec![],
1332 api_version: None,
1333 protocol: crate::schema::ApiProtocol::Anthropic,
1334 };
1335 cloud.public_benchmarks = vec![crate::schema::BenchmarkScore {
1336 name: "SWE-bench".into(),
1337 score: 0.7,
1338 harness: None,
1339 source_url: None,
1340 measured_at: None,
1341 }];
1342 cat.push(cloud);
1343
1344 let on_device = recommend(
1345 &refs(&cat),
1346 &mac(16),
1347 UseCase::Coding,
1348 QualityTier::MostCapable,
1349 Privacy::OnDevice,
1350 )
1351 .picks;
1352 assert!(on_device.iter().all(|r| r.is_local));
1353
1354 let cloud_ok = recommend(
1355 &refs(&cat),
1356 &mac(16),
1357 UseCase::Coding,
1358 QualityTier::MostCapable,
1359 Privacy::CloudOk,
1360 )
1361 .picks;
1362 let claude = cloud_ok
1363 .iter()
1364 .find(|r| r.display_name == "Claude Sonnet")
1365 .expect("cloud model eligible under CloudOk");
1366 assert!(claude.requires_cloud_consent);
1367 assert_eq!(claude.fit, FitStatus::ServerProvided);
1368 }
1369
1370 #[test]
1371 fn metal_only_model_excluded_on_cpu_host() {
1372 let mut cat = catalog();
1373 let mut mlx = local_model("mlx/qwen3-4b", "Qwen3-4B-MLX", "4B", 2400);
1374 mlx.source = ModelSource::Mlx {
1375 hf_repo: "mlx-community/x".into(),
1376 hf_weight_file: None,
1377 };
1378 cat.push(mlx);
1379 let recs = recommend(
1381 &refs(&cat),
1382 &hw(GpuBackend::Cpu, 32 * 1024, None),
1383 UseCase::Coding,
1384 QualityTier::Balanced,
1385 Privacy::OnDevice,
1386 )
1387 .picks;
1388 assert!(recs.iter().all(|r| r.display_name != "Qwen3-4B-MLX"));
1389 }
1390
1391 #[test]
1392 fn ranking_is_deterministic() {
1393 let cat = catalog();
1394 let a = recommend(
1395 &refs(&cat),
1396 &mac(16),
1397 UseCase::Assistant,
1398 QualityTier::Balanced,
1399 Privacy::OnDevice,
1400 );
1401 let b = recommend(
1402 &refs(&cat),
1403 &mac(16),
1404 UseCase::Assistant,
1405 QualityTier::Balanced,
1406 Privacy::OnDevice,
1407 );
1408 let ids_a: Vec<&str> = a.picks.iter().map(|r| r.model_id.as_str()).collect();
1409 let ids_b: Vec<&str> = b.picks.iter().map(|r| r.model_id.as_str()).collect();
1410 assert_eq!(ids_a, ids_b);
1411 }
1412
1413 #[test]
1414 fn rationale_is_plain_language_no_jargon() {
1415 let cat = catalog();
1416 let recs = recommend(
1417 &refs(&cat),
1418 &mac(36),
1419 UseCase::Coding,
1420 QualityTier::Balanced,
1421 Privacy::OnDevice,
1422 )
1423 .picks;
1424 let r = &recs[0].rationale;
1425 assert!(!r.contains("Q4_K_M"), "no quantization jargon");
1426 assert!(!r.contains("gguf") && !r.contains("hf_repo"));
1427 assert!(r.contains("coding"), "states the purpose");
1428 }
1429
1430 #[test]
1431 fn all_too_big_surfaces_needs_more_ram_with_a_note() {
1432 let cat = catalog();
1434 let set = recommend(
1435 &refs(&cat),
1436 &hw(GpuBackend::Cpu, 2 * 1024, None),
1437 UseCase::Coding,
1438 QualityTier::Balanced,
1439 Privacy::OnDevice,
1440 );
1441 assert!(set.picks.is_empty(), "nothing should fit 2 GB");
1442 assert!(
1443 !set.not_enough_memory.is_empty(),
1444 "too-big models surfaced, not dropped"
1445 );
1446 let note = set.note.expect("empty picks must carry a note");
1447 assert!(note.contains("fits"), "note explains the no-fit: {note}");
1448 assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1450 }
1451
1452 #[test]
1453 fn all_deprecated_gives_generic_note_not_a_memory_note() {
1454 let mut cat = catalog();
1458 for m in &mut cat {
1459 m.deprecated = true;
1460 }
1461 let set = recommend(
1462 &refs(&cat),
1463 &mac(36), UseCase::Coding,
1465 QualityTier::Balanced,
1466 Privacy::OnDevice,
1467 );
1468 assert!(set.picks.is_empty());
1469 assert!(set.not_enough_memory.is_empty());
1470 let note = set.note.expect("must explain");
1471 assert!(
1472 !note.contains("fits") && !note.contains("memory"),
1473 "deprecated-only must not claim a memory problem: {note}"
1474 );
1475 }
1476
1477 #[test]
1478 fn not_enough_memory_is_ordered_deterministically() {
1479 let cat = catalog();
1480 let mk = || {
1481 recommend(
1482 &refs(&cat),
1483 &hw(GpuBackend::Cpu, 3 * 1024, None), UseCase::Coding,
1485 QualityTier::Balanced,
1486 Privacy::OnDevice,
1487 )
1488 .not_enough_memory
1489 .into_iter()
1490 .map(|r| r.model_id)
1491 .collect::<Vec<_>>()
1492 };
1493 assert!(mk().len() >= 2, "several models should be too big for 3 GB");
1494 assert_eq!(mk(), mk(), "too-big ordering must be deterministic");
1495 }
1496
1497 #[test]
1498 fn empty_registry_returns_empty_with_a_note() {
1499 let set = recommend(
1500 &[],
1501 &mac(16),
1502 UseCase::Assistant,
1503 QualityTier::Balanced,
1504 Privacy::OnDevice,
1505 );
1506 assert!(set.picks.is_empty());
1507 assert!(set.not_enough_memory.is_empty());
1508 assert!(set.note.is_some(), "no-model case must explain itself");
1509 }
1510
1511 #[test]
1512 fn cuda_box_sizes_against_vram() {
1513 let cat = catalog();
1515 let h = hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024));
1516 let recs = recommend(
1517 &refs(&cat),
1518 &h,
1519 UseCase::Coding,
1520 QualityTier::MostCapable,
1521 Privacy::OnDevice,
1522 )
1523 .picks;
1524 assert_eq!(recs[0].display_name, "Qwen3-30B-A3B");
1525 }
1526
1527 #[test]
1528 fn unsupported_discrete_gpu_uses_system_ram_not_vram() {
1529 let cat = catalog();
1532 let mut h = hw(GpuBackend::Cpu, 16 * 1024, None);
1533 h.gpu_devices = vec![GpuDevice {
1534 vendor: GpuVendor::Nvidia,
1535 name: "GeForce RTX 4090".into(),
1536 memory_mb: Some(24_000),
1537 }];
1538 assert!(matches!(
1540 h.supported_acceleration(),
1541 crate::hardware::SupportedAcceleration::UnsupportedDiscreteGpu { .. }
1542 ));
1543 let recs = recommend(
1544 &refs(&cat),
1545 &h,
1546 UseCase::Coding,
1547 QualityTier::MostCapable,
1548 Privacy::OnDevice,
1549 )
1550 .picks;
1551 assert!(
1552 recs.iter().all(|r| r.display_name != "Qwen3-30B-A3B"),
1553 "17 GB model must not fit a 16 GB-RAM CPU host"
1554 );
1555 assert!(!recs.is_empty(), "smaller models still fit");
1556 }
1557
1558 #[test]
1559 fn recommendation_set_wire_shape_is_snake_case_and_stable() {
1560 let cat = catalog();
1562 let set = recommend(
1563 &refs(&cat),
1564 &mac(36),
1565 UseCase::Coding,
1566 QualityTier::Balanced,
1567 Privacy::OnDevice,
1568 );
1569 let json = serde_json::to_string(&set).unwrap();
1570 assert!(json.contains("\"picks\""));
1571 assert!(json.contains("\"not_enough_memory\""));
1572 assert!(json.contains("\"model_id\""));
1573 assert!(json.contains("\"already_installed\""));
1574 assert!(json.contains("\"requires_cloud_consent\""));
1575 assert!(json.contains("\"within_recommendation_target\""));
1576 assert!(json.contains("\"fit\""));
1577
1578 let mut legacy = serde_json::to_value(&set.picks[0]).unwrap();
1579 legacy
1580 .as_object_mut()
1581 .unwrap()
1582 .remove("within_recommendation_target");
1583 let decoded: Recommendation = serde_json::from_value(legacy).unwrap();
1584 assert!(decoded.within_recommendation_target);
1585 }
1586
1587 #[test]
1588 fn blank_param_count_estimates_from_size_not_zero() {
1589 let mut m = local_model("x/unknown", "Unknown-Model", "", 4900);
1592 m.param_count = String::new();
1593 assert!(
1594 param_billions_total(&m) > 5.0,
1595 "4.9 GB ⇒ roughly an 8B model, not 0B"
1596 );
1597 }
1598
1599 fn cloud_row(id: &str) -> ModelSchema {
1602 let mut cloud = local_model(id, "Cloud", "", 0);
1603 cloud.source = ModelSource::RemoteApi {
1604 endpoint: "https://example.invalid".into(),
1605 api_key_env: "TEST_KEY".into(),
1606 api_key_envs: vec![],
1607 api_version: None,
1608 protocol: crate::schema::ApiProtocol::OpenAiCompat,
1609 };
1610 cloud.cost.ram_mb = None;
1611 cloud.cost.size_mb = None;
1612 cloud
1613 }
1614
1615 #[test]
1621 fn unified_fit_is_the_recommenders_verdict_on_every_machine_size() {
1622 let policy = ResourcePolicy::everyday();
1623 let cat = qwen_mlx_policy_catalog();
1624 let four = &cat[0];
1625 let eight = &cat[1];
1626 let small = local_model("mlx/qwen3-0.6b:6bit", "Qwen3-0.6B", "0.6B", 500);
1627 let thirty = local_model(
1628 "mlx/qwen3-30b-a3b:4bit",
1629 "Qwen3-30B-A3B",
1630 "30B (3B active)",
1631 16_500,
1632 );
1633 let at = |m: &ModelSchema, gb: u64| model_fit(m, &mac(gb), Some(&policy));
1634
1635 assert_eq!(at(eight, 8).fit, ModelFitStatus::TooBig);
1636 assert_eq!(at(eight, 16).fit, ModelFitStatus::TooBig);
1637 assert_eq!(at(eight, 32).fit, ModelFitStatus::Fits);
1638 assert_eq!(at(four, 8).fit, ModelFitStatus::TooBig);
1639 assert_eq!(at(four, 16).fit, ModelFitStatus::Fits);
1640 assert_eq!(at(&small, 8).fit, ModelFitStatus::Fits);
1641 assert_eq!(at(&thirty, 16).fit, ModelFitStatus::TooBig);
1642 assert_eq!(at(&thirty, 32).fit, ModelFitStatus::TooBig);
1643
1644 let eight_at_32 = at(eight, 32);
1647 assert_eq!(
1648 eight_at_32.estimated_peak_mb,
1649 Some(
1650 estimate_model_memory(eight, &mac(32), RECOMMENDATION_CONTEXT_TOKENS)
1651 .estimated_peak_mb
1652 )
1653 );
1654 assert!(eight_at_32.platform_compatible);
1655
1656 let all = vec![four.clone(), eight.clone(), small, thirty];
1660 let by_id = |id: &str| all.iter().find(|m| m.id == id).unwrap();
1661 for gb in [8u64, 16, 32] {
1662 let set = recommend_with_policy(
1663 &refs(&all),
1664 &mac(gb),
1665 &policy,
1666 UseCase::Assistant,
1667 QualityTier::Balanced,
1668 Privacy::OnDevice,
1669 );
1670 for pick in &set.picks {
1671 assert_eq!(
1672 model_fit(by_id(&pick.model_id), &mac(gb), Some(&policy)).fit,
1673 ModelFitStatus::Fits,
1674 "{gb} GB pick {}",
1675 pick.model_id
1676 );
1677 }
1678 for miss in &set.not_enough_memory {
1679 assert_eq!(
1680 model_fit(by_id(&miss.model_id), &mac(gb), Some(&policy)).fit,
1681 ModelFitStatus::TooBig,
1682 "{gb} GB miss {}",
1683 miss.model_id
1684 );
1685 }
1686 }
1687 }
1688
1689 #[test]
1701 fn a_model_larger_than_any_machine_is_too_big_on_every_machine() {
1702 let policy = ResourcePolicy::everyday();
1703 let enormous = local_model("test/enormous-model:q4", "Enormous", "9000B", 900_000_000);
1705 let machines = [
1706 ("apple 8 GB", mac(8)),
1707 ("apple 128 GB", mac(128)),
1708 ("cpu 32 GB", hw(GpuBackend::Cpu, 32 * 1024, None)),
1709 ("cuda build, no card", hw(GpuBackend::Cuda, 64 * 1024, None)),
1713 (
1714 "cuda 24 GB card",
1715 hw(GpuBackend::Cuda, 64 * 1024, Some(24 * 1024)),
1716 ),
1717 ];
1718 for (label, machine) in machines {
1719 assert_eq!(
1720 model_fit(&enormous, &machine, Some(&policy)).fit,
1721 ModelFitStatus::TooBig,
1722 "{label} must not claim to hold a 900 TB model"
1723 );
1724 assert_eq!(
1725 model_fit(&enormous, &machine, None).fit,
1726 ModelFitStatus::TooBig,
1727 "{label} without a policy must not claim to hold a 900 TB model"
1728 );
1729 }
1730 }
1731
1732 #[test]
1735 fn cuda_build_without_a_card_still_fits_models_that_fit_system_ram() {
1736 let policy = ResourcePolicy::everyday();
1737 let small = local_model("qwen/qwen3-0.6b:q4_k_m", "Qwen3-0.6B", "0.6B", 500);
1738 let no_card = hw(GpuBackend::Cuda, 64 * 1024, None);
1739 assert_eq!(
1740 model_fit(&small, &no_card, Some(&policy)).fit,
1741 ModelFitStatus::Fits
1742 );
1743 }
1744
1745 #[test]
1746 fn unified_fit_platform_check_is_the_base_filters() {
1747 let cat = qwen_mlx_policy_catalog();
1748 let mlx = &cat[0];
1749 let cpu_box = hw(GpuBackend::Cpu, 32 * 1024, None);
1750 let fit = model_fit(mlx, &cpu_box, Some(&ResourcePolicy::everyday()));
1751 assert!(!fit.platform_compatible, "MLX needs Apple Silicon");
1752 assert!(!passes_base_filter(
1753 mlx,
1754 &cpu_box,
1755 UseCase::Coding,
1756 Privacy::OnDevice
1757 ));
1758 assert!(platform_compatible(mlx, &mac(32)));
1759
1760 let gguf = local_model("qwen/qwen3-4b:q4_k_m", "Qwen3-4B", "4B", 2_500);
1761 assert!(model_fit(&gguf, &cpu_box, None).platform_compatible);
1762 assert!(passes_base_filter(
1763 &gguf,
1764 &cpu_box,
1765 UseCase::Coding,
1766 Privacy::OnDevice
1767 ));
1768 }
1769
1770 #[test]
1771 fn unified_fit_for_rows_whose_memory_is_not_this_machines() {
1772 let cloud = cloud_row("remote/cloud");
1774 let fit = model_fit(&cloud, &mac(8), Some(&ResourcePolicy::everyday()));
1775 assert_eq!(fit.fit, ModelFitStatus::Fits);
1776 assert_eq!(fit.estimated_peak_mb, None);
1777 assert!(fit.platform_compatible);
1778 assert!(model_fit(&cloud, &hw(GpuBackend::Cpu, 8 * 1024, None), None).platform_compatible);
1779
1780 let mut undeclared = local_model("local/undeclared", "Undeclared", "4B", 0);
1782 undeclared.cost.ram_mb = None;
1783 undeclared.cost.size_mb = None;
1784 let fit = model_fit(&undeclared, &mac(32), Some(&ResourcePolicy::everyday()));
1785 assert_eq!(fit.fit, ModelFitStatus::Unknown);
1786 assert_eq!(fit.estimated_peak_mb, None);
1787
1788 let mut foundation = local_model("apple/foundation:default", "Apple", "", 0);
1791 foundation.source = ModelSource::AppleFoundationModels { use_case: None };
1792 foundation.cost.ram_mb = None;
1793 foundation.cost.size_mb = None;
1794 let on_mac = model_fit(&foundation, &mac(8), Some(&ResourcePolicy::everyday()));
1795 assert_eq!(on_mac.fit, ModelFitStatus::Fits);
1796 assert_eq!(on_mac.estimated_peak_mb, None);
1797 assert!(on_mac.platform_compatible);
1798 assert!(
1799 !model_fit(&foundation, &hw(GpuBackend::Cpu, 64 * 1024, None), None)
1800 .platform_compatible
1801 );
1802
1803 let mut windows = local_model("windows/speech-synthesis:os", "Windows", "", 0);
1804 windows.source = ModelSource::WindowsSpeech {};
1805 windows.cost.ram_mb = None;
1806 windows.cost.size_mb = None;
1807 let on_mac = model_fit(&windows, &mac(8), Some(&ResourcePolicy::everyday()));
1808 assert_eq!(on_mac.fit, ModelFitStatus::Fits);
1809 assert_eq!(on_mac.estimated_peak_mb, None);
1810 assert!(!on_mac.platform_compatible);
1811 let mut windows_host = hw(GpuBackend::Cpu, 8 * 1024, None);
1812 windows_host.os = "windows".into();
1813 assert!(model_fit(&windows, &windows_host, None).platform_compatible);
1814
1815 let mut linux = undeclared.clone();
1818 linux.tags.push("linux-only".into());
1819 let mut linux_host = hw(GpuBackend::Cpu, 8 * 1024, None);
1820 linux_host.os = "linux".into();
1821 assert!(platform_compatible(&linux, &linux_host));
1822 assert!(!platform_compatible(&linux, &windows_host));
1823 let mut tagged_windows = undeclared.clone();
1824 tagged_windows.tags.push("windows-only".into());
1825 assert!(platform_compatible(&tagged_windows, &windows_host));
1826 assert!(!platform_compatible(&tagged_windows, &linux_host));
1827
1828 let cat = qwen_mlx_policy_catalog();
1833 assert_eq!(model_fit(&cat[0], &mac(8), None).fit, ModelFitStatus::Fits);
1834 assert_eq!(
1835 model_fit(&cat[1], &mac(8), None).fit,
1836 ModelFitStatus::TooBig
1837 );
1838 let legacy = recommend(
1839 &refs(&cat),
1840 &mac(8),
1841 UseCase::Coding,
1842 QualityTier::Balanced,
1843 Privacy::OnDevice,
1844 );
1845 fn ids(set: &[Recommendation]) -> Vec<&str> {
1846 set.iter().map(|pick| pick.model_id.as_str()).collect()
1847 }
1848 assert_eq!(ids(&legacy.picks), vec!["mlx/qwen3-4b:4bit"]);
1849 assert_eq!(ids(&legacy.not_enough_memory), vec!["mlx/qwen3-8b:4bit"]);
1850 }
1851}
1852
1853#[cfg(test)]
1854mod local_server_fit_tests {
1855 use super::*;
1856 use crate::schema::{ModelCapability, ModelSource};
1857
1858 fn managed_vllm_model(id: &str, size_mb: u64) -> ModelSchema {
1859 let mut m = super::tests::local_model(id, id, "12B", size_mb);
1860 m.capabilities.push(ModelCapability::ToolUse);
1861 m.cost.ram_mb = Some(size_mb + size_mb / 4);
1862 m.source = ModelSource::ManagedVllmMlx {
1863 hf_repo: "mlx-community/whatever-4bit".into(),
1864 hf_weight_file: None,
1865 };
1866 m
1867 }
1868
1869 fn external_vllm_model(id: &str, endpoint: &str, size_mb: u64) -> ModelSchema {
1870 let mut m = super::tests::local_model(id, id, "12B", size_mb);
1871 m.capabilities.push(ModelCapability::ToolUse);
1872 m.cost.ram_mb = Some(size_mb + size_mb / 4);
1873 m.source = ModelSource::VllmMlx {
1874 endpoint: endpoint.to_string(),
1875 model_name: "externally-managed-model".into(),
1876 };
1877 m
1878 }
1879
1880 fn small_mac() -> HardwareInfo {
1881 super::tests::hw(crate::hardware::GpuBackend::Metal, 16384, Some(12288))
1882 }
1883
1884 #[test]
1888 fn managed_vllm_mlx_is_memory_checked_and_rejected_when_over_budget() {
1889 let big = managed_vllm_model("vllm-mlx/huge:4bit", 20_000);
1890 let set = recommend_with_policy(
1891 &[&big],
1892 &small_mac(),
1893 &ResourcePolicy::everyday(),
1894 UseCase::Assistant,
1895 QualityTier::Balanced,
1896 Privacy::OnDevice,
1897 );
1898
1899 assert!(set.picks.is_empty());
1900 assert_eq!(set.not_enough_memory.len(), 1);
1901 assert_eq!(set.not_enough_memory[0].fit, FitStatus::TooBig);
1902 assert_eq!(
1903 set.not_enough_memory[0].download_mb, 20_000,
1904 "CAR-managed vllm weights must retain their declared download size"
1905 );
1906 }
1907
1908 #[test]
1911 fn external_vllm_mlx_requires_cloud_consent_and_is_cross_platform() {
1912 let machines = [
1913 small_mac(),
1914 super::tests::hw(crate::hardware::GpuBackend::Cpu, 16_384, None),
1915 super::tests::hw(crate::hardware::GpuBackend::Cuda, 16_384, Some(12_288)),
1916 ];
1917 for endpoint in [
1918 "http://localhost:8000",
1919 "http://127.0.0.1:8000",
1920 "https://gpu-owner.example/v1",
1921 ] {
1922 let external = external_vllm_model("external/vllm", endpoint, 20_000);
1923 for machine in &machines {
1924 let on_device = recommend_with_policy(
1925 &[&external],
1926 machine,
1927 &ResourcePolicy::everyday(),
1928 UseCase::Assistant,
1929 QualityTier::Balanced,
1930 Privacy::OnDevice,
1931 );
1932 assert!(
1933 on_device.picks.is_empty(),
1934 "external endpoint {endpoint} must require cloud consent on {:?}",
1935 machine.gpu_backend
1936 );
1937
1938 let cloud_ok = recommend_with_policy(
1939 &[&external],
1940 machine,
1941 &ResourcePolicy::everyday(),
1942 UseCase::Assistant,
1943 QualityTier::Balanced,
1944 Privacy::CloudOk,
1945 );
1946 assert_eq!(
1947 cloud_ok.picks.len(),
1948 1,
1949 "external endpoint {endpoint} on {:?}",
1950 machine.gpu_backend
1951 );
1952 assert_eq!(cloud_ok.picks[0].fit, FitStatus::ServerProvided);
1953 assert_eq!(
1954 cloud_ok.picks[0].download_mb, 0,
1955 "external vllm owns its weights, so CAR has no download to report"
1956 );
1957 assert!(
1958 cloud_ok.picks[0].rationale.contains("external server"),
1959 "external vllm rationale must describe its actual owner: {}",
1960 cloud_ok.picks[0].rationale
1961 );
1962 assert!(
1963 !cloud_ok.picks[0].rationale.contains("Parslee's servers"),
1964 "external vllm must not be attributed to Parslee: {}",
1965 cloud_ok.picks[0].rationale
1966 );
1967 }
1968 }
1969 }
1970}
1971
1972#[cfg(test)]
1973mod catalog_capability_gap_tests {
1974 use super::*;
1975 use crate::schema::{ModelCapability, ModelSchema};
1976
1977 fn builtin() -> Vec<ModelSchema> {
1978 serde_json::from_str(include_str!("builtin_catalog.json")).unwrap()
1979 }
1980
1981 fn cuda_box(vram_gb: u64, ram_gb: u64) -> crate::hardware::HardwareInfo {
1982 super::tests::hw(
1983 crate::hardware::GpuBackend::Cuda,
1984 ram_gb * 1024,
1985 Some(vram_gb * 1024),
1986 )
1987 }
1988
1989 fn most_capable_on(ram_gb: u64) -> RecommendationSet {
1990 let catalog: &'static Vec<ModelSchema> = Box::leak(Box::new(builtin()));
1991 let refs: Vec<&ModelSchema> = catalog.iter().collect();
1992 recommend(
1993 &refs,
1994 &super::tests::mac(ram_gb),
1995 UseCase::Assistant,
1996 QualityTier::MostCapable,
1997 Privacy::OnDevice,
1998 )
1999 }
2000
2001 #[test]
2010 fn most_capable_returns_the_best_model_the_machine_can_run() {
2011 let catalog = builtin();
2012 let set = most_capable_on(64);
2013 let top = set.picks.first().expect("a 64 GB machine has picks");
2014
2015 let top_score = catalog
2016 .iter()
2017 .find(|m| m.id == top.model_id)
2018 .and_then(|m| m.public_benchmarks.first())
2019 .map(|b| b.score)
2020 .unwrap_or(0.0);
2021
2022 for m in catalog
2023 .iter()
2024 .filter(|m| m.is_local() && m.size_mb() < 24_000)
2025 {
2026 if let Some(s) = m.public_benchmarks.first().map(|b| b.score) {
2027 assert!(
2028 s <= top_score,
2029 "{} scores {s} but {} ({top_score}) was recommended as most capable",
2030 m.id,
2031 top.model_id
2032 );
2033 }
2034 }
2035 assert!(
2036 top.download_mb > 10_000,
2037 "a 64 GB machine should be offered a large model, got {} at {} MB",
2038 top.model_id,
2039 top.download_mb
2040 );
2041 }
2042
2043 #[test]
2046 fn a_small_machine_is_not_offered_a_model_it_cannot_hold() {
2047 let set = most_capable_on(8);
2048 if let Some(top) = set.picks.first() {
2049 assert!(
2050 top.fit != FitStatus::TooBig,
2051 "{} does not fit an 8 GB machine",
2052 top.model_id
2053 );
2054 }
2055 }
2056
2057 #[test]
2060 fn the_disclosure_is_accurate_when_it_appears() {
2061 let set = most_capable_on(64);
2062 if let Some(note) = set.note.as_deref() {
2063 if note.contains("unscored") {
2064 assert!(note.contains("bench-contribute"), "must say how: {note}");
2065 assert!(
2066 !note.contains("installed"),
2067 "availability is not installation: {note}"
2068 );
2069 }
2070 }
2071 }
2072
2073 #[test]
2075 fn other_tiers_do_not_carry_the_disclosure() {
2076 let catalog = builtin();
2077 let refs: Vec<&ModelSchema> = catalog.iter().collect();
2078 for tier in [QualityTier::Fastest, QualityTier::Balanced] {
2079 let set = recommend(
2080 &refs,
2081 &super::tests::mac(64),
2082 UseCase::Assistant,
2083 tier,
2084 Privacy::OnDevice,
2085 );
2086 let carries = set.note.as_deref().is_some_and(|n| n.contains("unscored"));
2087 assert!(!carries, "{tier:?} should not carry the disclosure");
2088 }
2089 }
2090
2091 #[test]
2100 fn a_cuda_machine_is_offered_a_local_model() {
2101 let catalog = builtin();
2102 let refs: Vec<&ModelSchema> = catalog.iter().collect();
2103 let set = recommend(
2104 &refs,
2105 &cuda_box(24, 64),
2106 UseCase::Assistant,
2107 QualityTier::MostCapable,
2108 Privacy::OnDevice,
2109 );
2110 assert!(
2111 set.picks.iter().any(|p| p.is_local),
2112 "a 24 GB CUDA GPU must be offered something local, got {:?}",
2113 set.picks.iter().map(|p| &p.model_id).collect::<Vec<_>>()
2114 );
2115 }
2116
2117 #[test]
2121 fn local_generate_models_declare_their_parameter_count() {
2122 let blank: Vec<String> = builtin()
2123 .iter()
2124 .filter(|m| {
2125 m.capabilities.contains(&ModelCapability::Generate)
2126 && m.is_local()
2127 && m.param_count.trim().is_empty()
2128 })
2129 .map(|m| m.id.clone())
2130 .collect();
2131 assert!(
2132 blank.is_empty(),
2133 "local generate models with no param_count: {blank:?}"
2134 );
2135 }
2136
2137 #[test]
2139 fn an_moe_is_scored_on_its_active_parameters() {
2140 let catalog = builtin();
2141 let glm = catalog
2142 .iter()
2143 .find(|m| m.id == "vllm-mlx/glm-4.7-flash:4bit")
2144 .expect("catalog entry");
2145 let active = crate::resource_policy::model_parameter_billions_active(glm);
2146 let total = crate::resource_policy::model_parameter_billions_total(glm);
2147 assert!(
2148 active < 6.0,
2149 "top-4-of-64 MoE runs at a few B active, got {active}"
2150 );
2151 assert!(total > 20.0, "and carries 30B-class knowledge, got {total}");
2152 }
2153}