use serde::Serialize;
use crate::catalog::{Catalog, CatalogEntry, CatalogOrigin};
use crate::hw::Hardware;
use crate::registry::{ModelEntry, ModelId};
pub const RECOMMEND_APP_ID: &str = "sh.modelshelf.recommend";
pub const KNOWN_TASKS: [&str; 7] = [
"chat",
"code",
"reasoning",
"embedding",
"stt",
"tts",
"vision",
];
pub fn recommend_app_id(task: &str) -> String {
format!("{RECOMMEND_APP_ID}.{task}")
}
pub fn is_recommend_ref(app_id: &str, task: &str) -> bool {
app_id == recommend_app_id(task) || (task == "chat" && app_id == RECOMMEND_APP_ID)
}
pub fn provisioned_for_task<'a>(installed: &'a [ModelEntry], task: &str) -> Option<&'a ModelEntry> {
installed.iter().find(|m| {
m.refs.iter().any(|r| {
is_recommend_ref(&r.app_id, task)
&& !r.alias.as_deref().unwrap_or_default().contains('#')
})
})
}
const GIB: u64 = 1024 * 1024 * 1024;
pub const DISCRETE_GPU_FACTOR: f64 = 0.90;
pub const UNIFIED_MEMORY_FACTOR: f64 = 0.70;
pub const CPU_FACTOR: f64 = 0.60;
pub const GPU_PREFERENCE: f64 = 2.0;
const WEIGHTS_OVERHEAD: f64 = 1.15;
const RUNTIME_OVERHEAD_BYTES: u64 = 3 * GIB / 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Backend {
DiscreteGpu,
UnifiedMemory,
CpuOnly,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct InstalledRef {
pub id: ModelId,
pub exact: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Recommendation {
pub entry: CatalogEntry,
pub required_bytes: u64,
pub fits: bool,
pub installed: Option<InstalledRef>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Upgrade {
pub from: ModelId,
pub from_name: String,
pub to: CatalogEntry,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RecommendReport {
pub task: String,
pub hardware: Hardware,
pub catalog_version: u64,
pub catalog_updated: String,
pub catalog_origin: CatalogOrigin,
pub backend: Backend,
pub budget_bytes: u64,
pub items: Vec<Recommendation>,
pub best: Option<String>,
pub upgrade: Option<Upgrade>,
}
impl RecommendReport {
pub fn best_item(&self) -> Option<&Recommendation> {
let best = self.best.as_deref()?;
self.items.iter().find(|r| r.entry.name == best)
}
}
pub fn budget(hw: &Hardware) -> (Backend, u64) {
let mut best = (Backend::CpuOnly, (hw.ram_bytes as f64 * CPU_FACTOR) as u64);
if hw.unified_memory {
let unified = (hw.ram_bytes as f64 * UNIFIED_MEMORY_FACTOR) as u64;
if unified > best.1 {
best = (Backend::UnifiedMemory, unified);
}
}
if let Some(vram) = hw.gpus.iter().map(|g| g.vram_bytes).max() {
let gpu = (vram as f64 * DISCRETE_GPU_FACTOR) as u64;
if gpu as f64 * GPU_PREFERENCE >= best.1 as f64 {
best = (Backend::DiscreteGpu, gpu);
}
}
best
}
pub fn required_bytes(file_bytes: u64) -> u64 {
(file_bytes as f64 * WEIGHTS_OVERHEAD) as u64 + RUNTIME_OVERHEAD_BYTES
}
pub fn recommend(
catalog: &Catalog,
origin: CatalogOrigin,
hw: &Hardware,
installed: &[ModelEntry],
task: &str,
) -> RecommendReport {
let (backend, budget_bytes) = budget(hw);
let items: Vec<Recommendation> = catalog
.entries
.iter()
.filter(|entry| entry.task == task)
.map(|entry| {
let required = required_bytes(entry.total_bytes());
Recommendation {
required_bytes: required,
fits: required <= budget_bytes,
installed: find_installed(entry, installed),
entry: entry.clone(),
}
})
.collect();
let best = items
.iter()
.filter(|r| r.fits && !r.entry.deprecated)
.max_by_key(|r| r.entry.total_bytes())
.map(|r| r.entry.name.clone());
let provisioned = provisioned_for_task(installed, task);
let upgrade = match (provisioned, &best) {
(Some(current), Some(best_name)) => {
let best_item = items
.iter()
.find(|r| &r.entry.name == best_name)
.expect("best names an item");
let current_is_best = best_item
.installed
.as_ref()
.is_some_and(|i| i.exact && i.id == current.id);
(!current_is_best).then(|| Upgrade {
from: current.id.clone(),
from_name: current.display_name.clone(),
to: best_item.entry.clone(),
})
}
_ => None,
};
RecommendReport {
task: task.to_owned(),
hardware: hw.clone(),
catalog_version: catalog.catalog_version,
catalog_updated: catalog.updated.clone(),
catalog_origin: origin,
backend,
budget_bytes,
items,
best,
upgrade,
}
}
pub fn all_upgrades(
catalog: &Catalog,
origin: CatalogOrigin,
hw: &Hardware,
installed: &[ModelEntry],
) -> Vec<Upgrade> {
let mut tasks: Vec<String> = installed
.iter()
.flat_map(|m| m.refs.iter())
.filter_map(|r| {
let app_id = r.app_id.as_str();
if app_id == RECOMMEND_APP_ID {
return Some("chat".to_owned()); }
app_id
.strip_prefix(RECOMMEND_APP_ID)
.and_then(|rest| rest.strip_prefix('.'))
.map(str::to_owned)
})
.collect();
tasks.sort();
tasks.dedup();
tasks
.iter()
.filter_map(|task| recommend(catalog, origin, hw, installed, task).upgrade)
.collect()
}
fn find_installed(entry: &CatalogEntry, installed: &[ModelEntry]) -> Option<InstalledRef> {
if let Some(m) = installed.iter().find(|m| {
m.source
.as_ref()
.is_some_and(|s| s.repo == entry.repo && s.filename == entry.filename)
}) {
return Some(InstalledRef {
id: m.id.clone(),
exact: true,
});
}
installed
.iter()
.find(|m| is_similar(entry, m))
.map(|m| InstalledRef {
id: m.id.clone(),
exact: false,
})
}
fn is_similar(entry: &CatalogEntry, model: &ModelEntry) -> bool {
let by_size_class = model.gguf.as_ref().is_some_and(|g| {
let same_params = g.parameter_count.is_some_and(|count| {
let target = entry.params_b * 1e9;
(count as f64 - target).abs() <= target * 0.10
});
let same_quant = g
.quantization
.as_ref()
.is_some_and(|q| q.eq_ignore_ascii_case(&entry.quant));
same_params && same_quant
});
by_size_class
|| crate::shelf::normalize(&model.display_name)
.contains(&crate::shelf::normalize(&entry.name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Catalog;
use crate::registry::{AppRef, GgufMeta};
fn hw(ram_gib: u64, vram_gib: Option<u64>, unified: bool) -> Hardware {
Hardware {
ram_bytes: ram_gib * GIB,
ram_assumed: false,
gpus: vram_gib
.map(|v| {
vec![crate::hw::Gpu {
name: "Test GPU".into(),
vram_bytes: v * GIB,
}]
})
.unwrap_or_default(),
unified_memory: unified,
}
}
fn catalog(entries: Vec<CatalogEntry>) -> Catalog {
Catalog {
catalog_version: 7,
updated: "2026-07-10T00:00:00Z".into(),
entries,
}
}
fn centry(name: &str, file_gib: f64) -> CatalogEntry {
CatalogEntry {
name: name.into(),
task: "chat".into(),
repo: format!("org/{name}"),
filename: format!("{name}.gguf"),
format: "gguf".into(),
quant: "Q4_K_M".into(),
file_bytes: (file_gib * GIB as f64) as u64,
params_b: 8.0,
japanese: false,
notes: String::new(),
deprecated: false,
extra_files: Vec::new(),
}
}
fn centry_task(name: &str, file_gib: f64, task: &str) -> CatalogEntry {
CatalogEntry {
task: task.into(),
..centry(name, file_gib)
}
}
fn model(id_seed: &str, display_name: &str) -> ModelEntry {
ModelEntry {
id: ModelId::from_sha256_hex(&format!("{id_seed:0>64}")),
format: "gguf".into(),
size_bytes: 1,
display_name: display_name.into(),
gguf: None,
source: None,
store_path: None,
locations: Vec::new(),
refs: Vec::new(),
first_seen: "2026-01-01T00:00:00Z".into(),
last_verified: None,
extra: Default::default(),
}
}
fn with_source(mut m: ModelEntry, repo: &str, filename: &str) -> ModelEntry {
m.source = Some(crate::registry::Source {
kind: "huggingface".into(),
repo: repo.into(),
filename: filename.into(),
revision: None,
resolved_at: "2026-01-01T00:00:00Z".into(),
extra: Default::default(),
});
m
}
fn with_recommend_ref(mut m: ModelEntry, task: &str, alias: &str) -> ModelEntry {
m.refs.push(AppRef {
app_id: recommend_app_id(task),
alias: Some(alias.into()),
added_at: "2026-01-01T00:00:00Z".into(),
extra: Default::default(),
});
m
}
#[test]
fn budget_prefers_gpu_unless_ram_dwarfs_it() {
assert_eq!(
budget(&hw(64, None, false)),
(Backend::CpuOnly, (64.0 * GIB as f64 * 0.60) as u64)
);
assert_eq!(
budget(&hw(32, Some(24), false)),
(Backend::DiscreteGpu, (24.0 * GIB as f64 * 0.90) as u64)
);
assert_eq!(budget(&hw(16, Some(8), false)).0, Backend::DiscreteGpu);
assert_eq!(budget(&hw(64, Some(4), false)).0, Backend::CpuOnly);
assert_eq!(
budget(&hw(32, None, true)),
(Backend::UnifiedMemory, (32.0 * GIB as f64 * 0.70) as u64)
);
}
#[test]
fn required_bytes_is_documented_formula() {
let five_gib = 5 * GIB;
assert_eq!(
required_bytes(five_gib),
(five_gib as f64 * 1.15) as u64 + 3 * GIB / 2
);
}
#[test]
fn best_is_largest_fitting_non_deprecated() {
let mut old = centry("old-flagship", 6.0);
old.deprecated = true;
let cat = catalog(vec![centry("small", 1.0), centry("mid", 4.0), old]);
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[],
"chat",
);
assert_eq!(report.best.as_deref(), Some("mid"));
let items: Vec<(&str, bool)> = report
.items
.iter()
.map(|r| (r.entry.name.as_str(), r.fits))
.collect();
assert_eq!(
items,
vec![("small", true), ("mid", true), ("old-flagship", true)]
);
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(1, None, false),
&[],
"chat",
);
assert_eq!(report.best, None);
}
#[test]
fn tasks_are_scoped_and_independent() {
let cat = catalog(vec![
centry("chat-model", 1.0),
centry_task("coder-small", 1.0, "code"),
centry_task("coder-big", 4.0, "code"),
centry_task("speech", 0.2, "tts"),
]);
let machine = hw(16, None, false);
let chat = recommend(&cat, CatalogOrigin::Embedded, &machine, &[], "chat");
assert_eq!(chat.items.len(), 1);
assert_eq!(chat.best.as_deref(), Some("chat-model"));
let code = recommend(&cat, CatalogOrigin::Embedded, &machine, &[], "code");
assert_eq!(code.items.len(), 2);
assert_eq!(code.best.as_deref(), Some("coder-big"));
let unknown = recommend(&cat, CatalogOrigin::Embedded, &machine, &[], "nonexistent");
assert!(unknown.items.is_empty());
assert_eq!(unknown.best, None);
let chat_provisioned = with_recommend_ref(
with_source(
model("aa", "Chat Model"),
"org/chat-model",
"chat-model.gguf",
),
"chat",
"chat-model",
);
let code_provisioned = with_recommend_ref(
with_source(
model("bb", "Coder Small"),
"org/coder-small",
"coder-small.gguf",
),
"code",
"coder-small",
);
let installed = [chat_provisioned, code_provisioned];
let chat = recommend(&cat, CatalogOrigin::Embedded, &machine, &installed, "chat");
assert!(chat.upgrade.is_none(), "chat pick is already the best");
let code = recommend(&cat, CatalogOrigin::Embedded, &machine, &installed, "code");
let up = code.upgrade.expect("coder-big supersedes coder-small");
assert_eq!(up.to.name, "coder-big");
let ups = all_upgrades(&cat, CatalogOrigin::Embedded, &machine, &installed);
assert_eq!(ups.len(), 1);
assert_eq!(ups[0].to.name, "coder-big");
}
#[test]
fn extra_files_count_toward_required_memory() {
let mut entry = centry_task("speech", 1.0, "tts");
entry.extra_files.push(crate::catalog::ExtraFile {
repo: Some("org/decoder".into()),
filename: "decoder.gguf".into(),
file_bytes: GIB,
});
assert_eq!(entry.total_bytes(), 2 * GIB);
let cat = catalog(vec![entry]);
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[],
"tts",
);
assert_eq!(report.items[0].required_bytes, required_bytes(2 * GIB));
}
#[test]
fn legacy_bare_ref_counts_as_chat() {
let cat = catalog(vec![centry("small", 1.0), centry("big", 4.0)]);
let mut legacy = with_source(model("aa", "Small Model"), "org/small", "small.gguf");
legacy.refs.push(AppRef {
app_id: RECOMMEND_APP_ID.into(), alias: Some("small".into()),
added_at: "2026-01-01T00:00:00Z".into(),
extra: Default::default(),
});
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
std::slice::from_ref(&legacy),
"chat",
);
assert_eq!(report.upgrade.expect("legacy ref upgrades").to.name, "big");
let ups = all_upgrades(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[legacy],
);
assert_eq!(ups.len(), 1);
}
#[test]
fn embedded_catalog_covers_the_fallback_machine() {
let mut fallback = hw(8, None, false);
fallback.ram_assumed = true;
let report = recommend(
&Catalog::embedded(),
CatalogOrigin::Embedded,
&fallback,
&[],
"chat",
);
assert!(report.best.is_some(), "8 GiB machine got no recommendation");
let embedded = Catalog::embedded();
let mut tasks: Vec<&str> = embedded.entries.iter().map(|e| e.task.as_str()).collect();
tasks.sort();
tasks.dedup();
assert_eq!(tasks.len(), KNOWN_TASKS.len(), "catalog/KNOWN_TASKS drift");
for task in tasks {
let report = recommend(
&embedded,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[],
task,
);
assert!(report.best.is_some(), "no {task} pick for a 16 GiB machine");
}
}
#[test]
fn installed_exact_beats_similar() {
let cat = catalog(vec![centry("alpha", 1.0)]);
let exact = with_source(model("aa", "Anything"), "org/alpha", "alpha.gguf");
let similar = model("bb", "Alpha 8B Instruct");
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[similar, exact],
"chat",
);
let inst = report.items[0].installed.as_ref().unwrap();
assert!(inst.exact);
assert_eq!(inst.id, ModelId::from_sha256_hex(&format!("{:0>64}", "aa")));
}
#[test]
fn similar_matches_by_size_class_or_name() {
let entry = centry("qwen3-8b", 5.0); let mut by_params = model("cc", "totally different name");
by_params.gguf = Some(GgufMeta {
general_name: None,
architecture: None,
quantization: Some("q4_k_m".into()),
parameter_count: Some(8_200_000_000), context_length: None,
extra: Default::default(),
});
assert!(is_similar(&entry, &by_params));
let mut wrong_quant = by_params.clone();
wrong_quant.gguf.as_mut().unwrap().quantization = Some("Q8_0".into());
assert!(!is_similar(&entry, &wrong_quant));
assert!(is_similar(&entry, &model("dd", "Qwen3 8B Instruct")));
assert!(is_similar(&entry, &model("ee", "qwen3_8b-q4")));
assert!(!is_similar(&entry, &model("ff", "Llama 3.1 8B")));
}
#[test]
fn upgrade_fires_only_for_provisioned_machines() {
let cat = catalog(vec![centry("small", 1.0), centry("big", 4.0)]);
let small_installed = with_recommend_ref(
with_source(model("aa", "Small Model"), "org/small", "small.gguf"),
"chat",
"small",
);
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
std::slice::from_ref(&small_installed),
"chat",
);
let up = report.upgrade.expect("upgrade expected");
assert_eq!(up.from, small_installed.id);
assert_eq!(up.to.name, "big");
let unprovisioned = with_source(model("aa", "Small Model"), "org/small", "small.gguf");
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[unprovisioned],
"chat",
);
assert!(report.upgrade.is_none());
let big_installed = with_recommend_ref(
with_source(model("bb", "Big Model"), "org/big", "big.gguf"),
"chat",
"big",
);
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(16, None, false),
&[big_installed],
"chat",
);
assert!(report.upgrade.is_none());
let extra_only = with_recommend_ref(
with_source(model("cc", "Decoder"), "org/decoder", "decoder.gguf"),
"chat",
"small#extra",
);
assert!(provisioned_for_task(&[extra_only], "chat").is_none());
let report = recommend(
&cat,
CatalogOrigin::Embedded,
&hw(1, None, false),
&[small_installed],
"chat",
);
assert!(report.upgrade.is_none());
}
#[test]
fn report_serializes_for_json_consumers() {
let cat = catalog(vec![centry("small", 1.0)]);
let report = recommend(
&cat,
CatalogOrigin::Cached,
&hw(16, Some(8), false),
&[],
"chat",
);
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["task"], "chat");
assert_eq!(json["catalog_origin"], "cached");
assert_eq!(json["backend"], "discrete_gpu");
assert!(json["items"][0]["required_bytes"].is_u64());
}
}