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