Skip to main content

rigspark_core/
catalog.rs

1use crate::sizing::{
2    Architecture, KvCacheType, Model, Quantization, ValidationError, typed_kv_bytes_per_token,
3};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashSet};
7use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
8
9pub const BACKENDS: [&str; 4] = ["ollama", "llamacpp", "mlx", "lmstudio"];
10pub const CAPABILITIES: [&str; 6] = ["chat", "code", "vision", "reasoning", "tools", "embedding"];
11pub const VENDORS: [&str; 5] = ["apple", "nvidia", "amd", "intel", "none"];
12pub(crate) const LICENSES: [&str; 18] = [
13    "apache-2.0",
14    "mit",
15    "modified-mit",
16    "bsd-3-clause",
17    "llama-2-community",
18    "llama-3-community",
19    "llama-3.1-community",
20    "llama-3.2-community",
21    "llama-3.3-community",
22    "gemma",
23    "qwen",
24    "qwen-research",
25    "tongyi-qianwen",
26    "deepseek",
27    "yi-license",
28    "cc-by-4.0",
29    "cc-by-sa-4.0",
30    "openrail",
31];
32
33pub fn require(ok: bool, message: &str) -> Result<(), ValidationError> {
34    if ok {
35        Ok(())
36    } else {
37        Err(ValidationError(message.into()))
38    }
39}
40/// Patterns are compiled once per process: catalog validation runs them per model at startup.
41fn matches(value: &str, pattern: &'static str) -> bool {
42    static COMPILED: std::sync::LazyLock<
43        std::sync::Mutex<std::collections::HashMap<&'static str, Option<Regex>>>,
44    > = std::sync::LazyLock::new(Default::default);
45    let Ok(mut compiled) = COMPILED.lock() else {
46        return Regex::new(pattern).is_ok_and(|regex| regex.is_match(value));
47    };
48    compiled
49        .entry(pattern)
50        .or_insert_with(|| Regex::new(pattern).ok())
51        .as_ref()
52        .is_some_and(|regex| regex.is_match(value))
53}
54pub(crate) fn parameter_label(value: &str) -> bool {
55    matches(value, r"^\d+(\.\d+)?[BMT]$")
56}
57fn integer(value: f64, minimum: f64) -> bool {
58    value.is_finite()
59        && value.fract() == 0.0
60        && value >= minimum
61        && value <= 9_007_199_254_740_991.0
62}
63fn nonempty(value: &str) -> Result<(), ValidationError> {
64    require(!value.is_empty(), "empty text field")
65}
66pub(crate) fn timestamp(value: &str) -> Result<(), ValidationError> {
67    require(
68        value.ends_with('Z') && OffsetDateTime::parse(value, &Rfc3339).is_ok(),
69        "invalid generatedAt timestamp",
70    )
71}
72pub fn date(value: &str) -> Result<Date, ValidationError> {
73    Date::parse(
74        value,
75        time::macros::format_description!("[year]-[month]-[day]"),
76    )
77    .map_err(|_| ValidationError("invalid calendar date".into()))
78}
79fn no_nulls(value: &serde_json::Value) -> bool {
80    match value {
81        serde_json::Value::Null => false,
82        serde_json::Value::Array(values) => values.iter().all(no_nulls),
83        serde_json::Value::Object(values) => values.values().all(no_nulls),
84        _ => true,
85    }
86}
87pub fn parse_document<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, ValidationError> {
88    require(raw.len() <= 16 * 1024 * 1024, "dataset exceeds 16 MiB")?;
89    let value: serde_json::Value =
90        serde_json::from_str(raw).map_err(|error| ValidationError(error.to_string()))?;
91    require(no_nulls(&value), "null fields are not allowed")?;
92    serde_json::from_value(value).map_err(|error| ValidationError(error.to_string()))
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub struct GgufSource {
98    pub repo: String,
99    pub revision: String,
100    pub file: String,
101    pub sha256: String,
102}
103#[derive(Debug, Clone, Deserialize, Serialize)]
104#[serde(deny_unknown_fields)]
105pub struct MlxFile {
106    pub file: String,
107    pub sha256: String,
108    pub bytes: f64,
109}
110#[derive(Debug, Clone, Deserialize, Serialize)]
111#[serde(deny_unknown_fields)]
112pub struct MlxSource {
113    pub repo: String,
114    pub revision: String,
115    pub files: Vec<MlxFile>,
116}
117#[derive(Debug, Clone, Default, Deserialize, Serialize)]
118#[serde(deny_unknown_fields)]
119pub struct Source {
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub ollama: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub hf: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub gguf: Option<GgufSource>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub mlx: Option<MlxSource>,
128}
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
130#[serde(rename_all = "lowercase")]
131pub enum EntryProvenance {
132    #[default]
133    Curated,
134    Auto,
135}
136impl EntryProvenance {
137    pub(crate) fn is_curated(&self) -> bool {
138        matches!(self, Self::Curated)
139    }
140}
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[serde(rename_all = "lowercase")]
143pub enum RecencyBasis {
144    Released,
145    Added,
146}
147pub const SCHEMA_VERSION: u8 = 3;
148#[derive(Debug, Clone, Deserialize, Serialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct CatalogModel {
151    pub id: String,
152    pub family: String,
153    pub params: String,
154    pub architecture: Architecture,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub active_params: Option<String>,
157    pub license: String,
158    pub open_weight: bool,
159    pub context_length: f64,
160    pub capabilities: Vec<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub release_date: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub added_at: Option<String>,
165    #[serde(default, skip_serializing_if = "EntryProvenance::is_curated")]
166    pub provenance: EntryProvenance,
167    pub source: Source,
168    pub quantizations: Vec<Quantization>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub kv_bytes_per_token: Option<f64>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub benchmark_proxy: Option<f64>,
173}
174#[derive(Debug, Clone, Deserialize, Serialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct Catalog {
177    pub schema_version: u8,
178    pub generated_at: String,
179    pub models: Vec<CatalogModel>,
180}
181
182pub(crate) fn coordinates(repo: &str, revision: &str) -> Result<(), ValidationError> {
183    require(
184        repo.len() <= 200
185            && matches(
186                repo,
187                r"^[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*$",
188            ),
189        "invalid source repository",
190    )?;
191    require(
192        matches(revision, r"^[0-9a-fA-F]{40}$"),
193        "invalid pinned source revision",
194    )
195}
196pub(crate) fn model_file(file: &str, max: usize) -> Result<(), ValidationError> {
197    require(
198        !file.is_empty()
199            && file.len() <= max
200            && !file
201                .chars()
202                .any(|ch| ch.is_control() || "*?[]{}\\%".contains(ch))
203            && file
204                .split('/')
205                .all(|segment| !segment.is_empty() && segment != "." && segment != ".."),
206        "unsafe model file path",
207    )
208}
209pub(crate) fn digest(value: &str) -> Result<(), ValidationError> {
210    require(
211        matches(value, r"^[0-9a-fA-F]{64}$"),
212        "invalid SHA-256 digest",
213    )
214}
215
216/// The date recency is measured from: the sourced release date, else the admission date.
217pub fn recency<'a>(
218    release_date: Option<&'a str>,
219    added_at: Option<&'a str>,
220) -> Option<(&'a str, RecencyBasis)> {
221    release_date
222        .map(|day| (day, RecencyBasis::Released))
223        .or_else(|| added_at.map(|day| (day, RecencyBasis::Added)))
224}
225/// Display form of [`recency`]: the release date, `added <date>`, or `unknown`.
226pub fn recency_label(recency: Option<(&str, RecencyBasis)>) -> String {
227    match recency {
228        Some((day, RecencyBasis::Released)) => day.to_string(),
229        Some((day, RecencyBasis::Added)) => format!("added {day}"),
230        None => "unknown".into(),
231    }
232}
233
234impl CatalogModel {
235    pub fn recency(&self) -> Option<(&str, RecencyBasis)> {
236        recency(self.release_date.as_deref(), self.added_at.as_deref())
237    }
238    pub fn recency_label(&self) -> String {
239        recency_label(self.recency())
240    }
241    pub fn sizing(&self) -> Model {
242        Model {
243            id: self.id.clone(),
244            params: self.params.clone(),
245            architecture: self.architecture.clone(),
246            context_length: self.context_length,
247            kv_bytes_per_token: self.kv_bytes_per_token,
248            quantizations: self.quantizations.clone(),
249        }
250    }
251    /// Re-sizes the sourced f16 KV rate for a cache type; unknown geometry stays unknown.
252    pub fn with_kv_cache(&self, kind: KvCacheType) -> Result<Self, ValidationError> {
253        let mut model = self.clone();
254        if let Some(rate) = self.kv_bytes_per_token {
255            model.kv_bytes_per_token =
256                Some(typed_kv_bytes_per_token(rate as u64, kind, kind)? as f64);
257        }
258        Ok(model)
259    }
260    pub(crate) fn validate(&self) -> Result<(), ValidationError> {
261        nonempty(&self.id)?;
262        nonempty(&self.family)?;
263        require(parameter_label(&self.params), "invalid parameter label")?;
264        require(
265            self.open_weight
266                && !self.license.is_empty()
267                && LICENSES.contains(&self.license.as_str()),
268            "catalog requires open-weight license",
269        )?;
270        require(
271            integer(self.context_length, 1.0)
272                && !self.capabilities.is_empty()
273                && self
274                    .capabilities
275                    .iter()
276                    .all(|cap| CAPABILITIES.contains(&cap.as_str())),
277            "invalid context or capabilities",
278        )?;
279        if let Some(released) = &self.release_date {
280            date(released)?;
281        }
282        if let Some(added) = &self.added_at {
283            date(added)?;
284        }
285        match self.provenance {
286            EntryProvenance::Curated => require(
287                self.release_date.is_some(),
288                "curated entry needs releaseDate",
289            )?,
290            // Auto entries carry only sourced facts and must stay integrity-pinned.
291            EntryProvenance::Auto => require(
292                self.added_at.is_some()
293                    && self.benchmark_proxy.is_none()
294                    && self.source.ollama.is_some()
295                    && self
296                        .quantizations
297                        .iter()
298                        .all(|quant| quant.sha256.is_some()),
299                "auto entry needs addedAt, a pinned Ollama digest and no benchmarkProxy",
300            )?,
301        }
302        require(
303            matches!(self.architecture, Architecture::Moe) == self.active_params.is_some(),
304            "activeParams must exist only for MoE",
305        )?;
306        if let Some(active) = &self.active_params {
307            require(parameter_label(active), "invalid activeParams")?;
308        }
309        if let Some(rate) = self.kv_bytes_per_token {
310            require(integer(rate, 1.0), "invalid KV geometry")?;
311        }
312        if let Some(proxy) = self.benchmark_proxy {
313            require((0.0..=1.0).contains(&proxy), "invalid benchmark proxy")?;
314        }
315        require(
316            !self.quantizations.is_empty(),
317            "model must have quantizations",
318        )?;
319        for quant in &self.quantizations {
320            nonempty(&quant.name)?;
321            require(
322                integer(quant.disk_bytes, 1.0)
323                    && integer(quant.min_ram_bytes, 1.0)
324                    && integer(quant.min_vram_bytes, 0.0),
325                "invalid quantization sizes",
326            )?;
327            if let Some(sha) = &quant.sha256 {
328                digest(sha)?;
329            }
330            require(quant.projectors.len() <= 16, "too many projector artifacts")?;
331            let mut projector_digests = std::collections::HashSet::new();
332            let mut projector_bytes = 0_u64;
333            for projector in &quant.projectors {
334                digest(&projector.sha256)?;
335                require(
336                    projector.bytes > 0
337                        && projector.bytes <= 9_007_199_254_740_991
338                        && projector_digests.insert(&projector.sha256),
339                    "invalid projector artifact",
340                )?;
341                projector_bytes = projector_bytes
342                    .checked_add(projector.bytes)
343                    .ok_or_else(|| ValidationError("projector size overflow".into()))?;
344            }
345            require(
346                quant.projectors.is_empty()
347                    || (self.source.ollama.is_some()
348                        && quant.sha256.is_some()
349                        && (projector_bytes as f64) < quant.disk_bytes),
350                "projector artifacts require pinned Ollama weights and aggregate size",
351            )?;
352        }
353        let source = &self.source;
354        require(
355            source.ollama.is_some()
356                || source.hf.is_some()
357                || source.gguf.is_some()
358                || source.mlx.is_some(),
359            "missing model source",
360        )?;
361        for text in [&source.ollama, &source.hf].into_iter().flatten() {
362            nonempty(text)?;
363        }
364        if let Some(gguf) = &source.gguf {
365            coordinates(&gguf.repo, &gguf.revision)?;
366            model_file(&gguf.file, 255)?;
367            digest(&gguf.sha256)?;
368        }
369        if let Some(mlx) = &source.mlx {
370            coordinates(&mlx.repo, &mlx.revision)?;
371            require(
372                (3..=256).contains(&mlx.files.len()) && self.quantizations.len() == 1,
373                "invalid MLX manifest cardinality",
374            )?;
375            let mut paths = HashSet::new();
376            let mut total = 0.0;
377            for file in &mlx.files {
378                model_file(&file.file, 512)?;
379                digest(&file.sha256)?;
380                require(
381                    !matches(&file.file, r"(?i)\.(py|pyc|pyo|so|dylib|dll|bundle)$"),
382                    "executable MLX file forbidden",
383                )?;
384                require(
385                    integer(file.bytes, 1.0) && paths.insert(file.file.as_str()),
386                    "invalid or duplicate MLX file",
387                )?;
388                total += file.bytes;
389                require(integer(total, 1.0), "MLX total overflows")?;
390            }
391            require(
392                paths.contains("config.json")
393                    && paths.contains("tokenizer_config.json")
394                    && paths.iter().any(|path| path.ends_with(".safetensors")),
395                "incomplete MLX manifest",
396            )?;
397            require(
398                total == self.quantizations[0].disk_bytes,
399                "MLX manifest size mismatch",
400            )?;
401        }
402        Ok(())
403    }
404}
405/// The calendar date `months` (1–3) before `today`, clamped to the end of shorter months.
406pub fn months_before(today: &str, months: u8) -> Result<Date, ValidationError> {
407    require(
408        (1..=3).contains(&months),
409        "recency window must be 1, 2 or 3 months",
410    )?;
411    let today = date(today)?;
412    let mut year = today.year();
413    let mut month = u8::from(today.month()) as i32 - i32::from(months);
414    if month < 1 {
415        month += 12;
416        year -= 1;
417    }
418    let month =
419        time::Month::try_from(month as u8).map_err(|_| ValidationError("invalid month".into()))?;
420    let day = today.day().min(month.length(year));
421    Date::from_calendar_date(year, month, day).map_err(|_| ValidationError("invalid date".into()))
422}
423
424impl Catalog {
425    /// Keeps entries whose recency date (released, else added) is within `months` of `today`.
426    pub fn retain_recent(&mut self, today: &str, months: u8) -> Result<(), ValidationError> {
427        let cutoff = months_before(today, months)?;
428        self.models.retain(|model| {
429            model
430                .recency()
431                .and_then(|(day, _)| date(day).ok())
432                .is_some_and(|day| day >= cutoff)
433        });
434        Ok(())
435    }
436    pub fn parse(raw: &str) -> Result<Self, ValidationError> {
437        let mut result: Self = parse_document(raw)?;
438        require(
439            (2..=SCHEMA_VERSION).contains(&result.schema_version) && !result.models.is_empty(),
440            "unsupported or empty catalog",
441        )?;
442        timestamp(&result.generated_at)?;
443        for model in &result.models {
444            require(
445                result.schema_version >= 3
446                    || (model.provenance.is_curated() && model.added_at.is_none()),
447                "schema v2 catalogs cannot carry v3 fields",
448            )?;
449            model.validate()?;
450        }
451        for model in &mut result.models {
452            use crate::reports::strip_control;
453            model.id = strip_control(&model.id);
454            model.family = strip_control(&model.family);
455            for value in [&mut model.source.ollama, &mut model.source.hf]
456                .into_iter()
457                .flatten()
458            {
459                *value = strip_control(value);
460            }
461            if let Some(source) = &mut model.source.gguf {
462                source.file = strip_control(&source.file);
463            }
464            if let Some(source) = &mut model.source.mlx {
465                for file in &mut source.files {
466                    file.file = strip_control(&file.file);
467                }
468            }
469            for quant in &mut model.quantizations {
470                quant.name = strip_control(&quant.name);
471            }
472        }
473        let mut ids = HashSet::new();
474        for model in &result.models {
475            require(ids.insert(&model.id), "duplicate catalog model id")?;
476        }
477        Ok(result)
478    }
479}
480
481#[derive(Debug, Clone, Deserialize, Serialize)]
482#[serde(rename_all = "camelCase", deny_unknown_fields)]
483pub struct Provenance {
484    pub value: f64,
485    pub trust_tier: String,
486    pub basis_bytes_per_token: f64,
487    pub url: String,
488}
489#[derive(Debug, Clone, Deserialize, Serialize)]
490#[serde(rename_all = "camelCase", deny_unknown_fields)]
491pub struct PerfSources {
492    pub bandwidth: String,
493    pub efficiency: String,
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub efficiency_by_backend: Option<BTreeMap<String, Provenance>>,
496}
497#[derive(Debug, Clone, Deserialize, Serialize)]
498#[serde(rename_all = "camelCase", deny_unknown_fields)]
499pub struct PerfClass {
500    pub id: String,
501    pub label: String,
502    pub vendor: String,
503    pub kind: String,
504    #[serde(rename = "memBandwidthGBps")]
505    pub mem_bandwidth_gbps: f64,
506    pub efficiency: f64,
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub efficiency_by_backend: Option<BTreeMap<String, f64>>,
509    pub min_bytes: f64,
510    pub max_bytes: f64,
511    pub sources: PerfSources,
512}
513#[derive(Debug, Clone, Deserialize, Serialize)]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515pub struct PerfDataset {
516    pub schema_version: u8,
517    pub generated_at: String,
518    pub classes: Vec<PerfClass>,
519}
520impl PerfDataset {
521    pub fn parse(raw: &str) -> Result<Self, ValidationError> {
522        let mut result: Self = parse_document(raw)?;
523        require(
524            result.schema_version == 1 && !result.classes.is_empty(),
525            "unsupported or empty performance data",
526        )?;
527        timestamp(&result.generated_at)?;
528        for (index, class) in result.classes.iter().enumerate() {
529            require(
530                matches(&class.id, r"^[a-z0-9-]+$")
531                    && !class.label.is_empty()
532                    && VENDORS.contains(&class.vendor.as_str())
533                    && ["discrete", "unified", "cpu"].contains(&class.kind.as_str()),
534                "invalid performance class",
535            )?;
536            require(
537                class.mem_bandwidth_gbps > 0.0
538                    && class.efficiency > 0.0
539                    && class.efficiency <= 1.0
540                    && integer(class.min_bytes, 0.0)
541                    && integer(class.max_bytes, 1.0)
542                    && class.max_bytes > class.min_bytes,
543                "invalid performance figures",
544            )?;
545            nonempty(&class.sources.bandwidth)?;
546            nonempty(&class.sources.efficiency)?;
547            let scalars = class.efficiency_by_backend.clone().unwrap_or_default();
548            let provenance = class
549                .sources
550                .efficiency_by_backend
551                .clone()
552                .unwrap_or_default();
553            require(
554                scalars.keys().eq(provenance.keys()),
555                "efficiency provenance mismatch",
556            )?;
557            for (backend, scalar) in scalars {
558                let source = &provenance[&backend];
559                require(
560                    BACKENDS.contains(&backend.as_str())
561                        && scalar > 0.0
562                        && scalar <= 1.0
563                        && source.value == scalar
564                        && ["session-verified", "spec-grade"].contains(&source.trust_tier.as_str())
565                        && source.basis_bytes_per_token > 0.0
566                        && url::Url::parse(&source.url).is_ok(),
567                    "invalid efficiency provenance",
568                )?;
569            }
570            for prior in &result.classes[..index] {
571                require(prior.id != class.id, "duplicate performance id")?;
572                require(
573                    prior.vendor != class.vendor
574                        || prior.kind != class.kind
575                        || prior.min_bytes >= class.max_bytes
576                        || class.min_bytes >= prior.max_bytes,
577                    "overlapping performance ranges",
578                )?;
579            }
580        }
581        for class in &mut result.classes {
582            use crate::reports::strip_control;
583            class.label = strip_control(&class.label);
584            class.sources.bandwidth = strip_control(&class.sources.bandwidth);
585            class.sources.efficiency = strip_control(&class.sources.efficiency);
586            if let Some(sources) = &mut class.sources.efficiency_by_backend {
587                for source in sources.values_mut() {
588                    source.url = strip_control(&source.url);
589                }
590            }
591        }
592        Ok(result)
593    }
594}
595
596#[derive(Debug, Serialize)]
597pub struct ResolutionError {
598    pub code: &'static str,
599    pub message: String,
600    pub candidates: Vec<String>,
601}
602#[derive(Debug, Serialize)]
603pub struct Resolved<'a> {
604    pub model: &'a CatalogModel,
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub quant: Option<&'a Quantization>,
607}
608fn resolution_error(message: String, candidates: Vec<String>, validation: bool) -> ResolutionError {
609    ResolutionError {
610        code: if validation {
611            "VALIDATION_ERROR"
612        } else {
613            "MODEL_RESOLUTION_ERROR"
614        },
615        message,
616        candidates,
617    }
618}
619fn finalize(resolved: Resolved<'_>) -> Result<Resolved<'_>, ResolutionError> {
620    for id in [
621        Some(&resolved.model.id),
622        resolved.model.source.ollama.as_ref(),
623    ]
624    .into_iter()
625    .flatten()
626    {
627        if id.contains("..") || !matches(id, r"^[a-z0-9._:/][a-z0-9._:/-]*$") {
628            return Err(resolution_error(
629                "unsafe catalog model id".into(),
630                vec![],
631                true,
632            ));
633        }
634    }
635    Ok(resolved)
636}
637pub fn resolve<'a>(catalog: &'a Catalog, input: &str) -> Result<Resolved<'a>, ResolutionError> {
638    let query = input.trim().to_lowercase();
639    if query.is_empty() || query.contains("..") || !matches(&query, r"^[a-z0-9._:/-]+$") {
640        return Err(resolution_error("invalid model name".into(), vec![], true));
641    }
642    if let Some(model) = catalog
643        .models
644        .iter()
645        .find(|model| model.id.to_lowercase() == query)
646    {
647        return finalize(Resolved { model, quant: None });
648    }
649    let quant_matches: Vec<Resolved<'_>> = catalog
650        .models
651        .iter()
652        .flat_map(|model| {
653            model.quantizations.iter().filter_map(|quant| {
654                (format!("{}-{}", model.id, quant.name).to_lowercase() == query).then_some(
655                    Resolved {
656                        model,
657                        quant: Some(quant),
658                    },
659                )
660            })
661        })
662        .collect();
663    let family: Vec<Resolved<'_>> = catalog
664        .models
665        .iter()
666        .filter(|model| model.family.to_lowercase() == query)
667        .map(|model| Resolved { model, quant: None })
668        .collect();
669    let prefix: Vec<Resolved<'_>> = catalog
670        .models
671        .iter()
672        .filter(|model| {
673            model.id.to_lowercase().starts_with(&query)
674                || model.family.to_lowercase().starts_with(&query)
675        })
676        .map(|model| Resolved { model, quant: None })
677        .collect();
678    for mut tier in [quant_matches, family, prefix] {
679        if tier.len() == 1 {
680            return finalize(tier.remove(0));
681        }
682        if !tier.is_empty() {
683            let mut candidates: Vec<String> =
684                tier.iter().map(|entry| entry.model.id.clone()).collect();
685            candidates.sort();
686            return Err(resolution_error(
687                format!(
688                    "\"{query}\" is ambiguous; candidates: {}",
689                    candidates.join(", ")
690                ),
691                candidates,
692                false,
693            ));
694        }
695    }
696    Err(resolution_error(
697        format!("no model matches \"{query}\""),
698        vec![],
699        false,
700    ))
701}