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; 5] = ["ollama", "llamacpp", "mlx", "lmstudio", "bitnet"];
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
182impl CatalogModel {
183    pub fn supports_backend(&self, backend: &str) -> bool {
184        if self.quantizations.iter().any(Quantization::is_bitnet) {
185            return backend == "bitnet"
186                && self.source.gguf.is_some()
187                && self.quantizations.len() == 1;
188        }
189        match backend {
190            "ollama" => self.source.ollama.is_some(),
191            "llamacpp" => self.source.gguf.is_some(),
192            "mlx" => self.source.mlx.is_some(),
193            "lmstudio" => self.source.gguf.is_some() || self.source.mlx.is_some(),
194            _ => false,
195        }
196    }
197}
198
199pub(crate) fn coordinates(repo: &str, revision: &str) -> Result<(), ValidationError> {
200    require(
201        repo.len() <= 200
202            && matches(
203                repo,
204                r"^[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*$",
205            ),
206        "invalid source repository",
207    )?;
208    require(
209        matches(revision, r"^[0-9a-fA-F]{40}$"),
210        "invalid pinned source revision",
211    )
212}
213pub(crate) fn model_file(file: &str, max: usize) -> Result<(), ValidationError> {
214    require(
215        !file.is_empty()
216            && file.len() <= max
217            && !file
218                .chars()
219                .any(|ch| ch.is_control() || "*?[]{}\\%".contains(ch))
220            && file
221                .split('/')
222                .all(|segment| !segment.is_empty() && segment != "." && segment != ".."),
223        "unsafe model file path",
224    )
225}
226pub(crate) fn digest(value: &str) -> Result<(), ValidationError> {
227    require(
228        matches(value, r"^[0-9a-fA-F]{64}$"),
229        "invalid SHA-256 digest",
230    )
231}
232
233/// The date recency is measured from: the sourced release date, else the admission date.
234pub fn recency<'a>(
235    release_date: Option<&'a str>,
236    added_at: Option<&'a str>,
237) -> Option<(&'a str, RecencyBasis)> {
238    release_date
239        .map(|day| (day, RecencyBasis::Released))
240        .or_else(|| added_at.map(|day| (day, RecencyBasis::Added)))
241}
242/// Display form of [`recency`]: the release date, `added <date>`, or `unknown`.
243pub fn recency_label(recency: Option<(&str, RecencyBasis)>) -> String {
244    match recency {
245        Some((day, RecencyBasis::Released)) => day.to_string(),
246        Some((day, RecencyBasis::Added)) => format!("added {day}"),
247        None => "unknown".into(),
248    }
249}
250
251impl CatalogModel {
252    pub fn recency(&self) -> Option<(&str, RecencyBasis)> {
253        recency(self.release_date.as_deref(), self.added_at.as_deref())
254    }
255    pub fn recency_label(&self) -> String {
256        recency_label(self.recency())
257    }
258    pub fn sizing(&self) -> Model {
259        Model {
260            id: self.id.clone(),
261            params: self.params.clone(),
262            architecture: self.architecture.clone(),
263            context_length: self.context_length,
264            kv_bytes_per_token: self.kv_bytes_per_token,
265            quantizations: self.quantizations.clone(),
266        }
267    }
268    /// Re-sizes the sourced f16 KV rate for a cache type; unknown geometry stays unknown.
269    pub fn with_kv_cache(&self, kind: KvCacheType) -> Result<Self, ValidationError> {
270        let mut model = self.clone();
271        if let Some(rate) = self.kv_bytes_per_token {
272            model.kv_bytes_per_token =
273                Some(typed_kv_bytes_per_token(rate as u64, kind, kind)? as f64);
274        }
275        Ok(model)
276    }
277    pub(crate) fn validate(&self) -> Result<(), ValidationError> {
278        nonempty(&self.id)?;
279        nonempty(&self.family)?;
280        require(parameter_label(&self.params), "invalid parameter label")?;
281        require(
282            self.open_weight
283                && !self.license.is_empty()
284                && LICENSES.contains(&self.license.as_str()),
285            "catalog requires open-weight license",
286        )?;
287        require(
288            integer(self.context_length, 1.0)
289                && !self.capabilities.is_empty()
290                && self
291                    .capabilities
292                    .iter()
293                    .all(|cap| CAPABILITIES.contains(&cap.as_str())),
294            "invalid context or capabilities",
295        )?;
296        if let Some(released) = &self.release_date {
297            date(released)?;
298        }
299        if let Some(added) = &self.added_at {
300            date(added)?;
301        }
302        match self.provenance {
303            EntryProvenance::Curated => require(
304                self.release_date.is_some(),
305                "curated entry needs releaseDate",
306            )?,
307            // Auto entries carry only sourced facts and must stay integrity-pinned.
308            EntryProvenance::Auto => require(
309                self.added_at.is_some()
310                    && self.benchmark_proxy.is_none()
311                    && (self.source.ollama.is_some()
312                        || self.source.gguf.is_some()
313                        || self.source.mlx.is_some())
314                    && self
315                        .quantizations
316                        .iter()
317                        .all(|quant| quant.sha256.is_some()),
318                "auto entry needs addedAt, a pinned runtime artifact and no benchmarkProxy",
319            )?,
320        }
321        require(
322            matches!(self.architecture, Architecture::Moe) == self.active_params.is_some(),
323            "activeParams must exist only for MoE",
324        )?;
325        if let Some(active) = &self.active_params {
326            require(parameter_label(active), "invalid activeParams")?;
327        }
328        if let Some(rate) = self.kv_bytes_per_token {
329            require(integer(rate, 1.0), "invalid KV geometry")?;
330        }
331        if let Some(proxy) = self.benchmark_proxy {
332            require((0.0..=1.0).contains(&proxy), "invalid benchmark proxy")?;
333        }
334        require(
335            !self.quantizations.is_empty(),
336            "model must have quantizations",
337        )?;
338        for quant in &self.quantizations {
339            nonempty(&quant.name)?;
340            require(
341                integer(quant.disk_bytes, 1.0)
342                    && integer(quant.min_ram_bytes, 1.0)
343                    && integer(quant.min_vram_bytes, 0.0),
344                "invalid quantization sizes",
345            )?;
346            if let Some(sha) = &quant.sha256 {
347                digest(sha)?;
348            }
349            require(quant.projectors.len() <= 16, "too many projector artifacts")?;
350            let mut projector_digests = std::collections::HashSet::new();
351            let mut projector_bytes = 0_u64;
352            for projector in &quant.projectors {
353                digest(&projector.sha256)?;
354                require(
355                    projector.bytes > 0
356                        && projector.bytes <= 9_007_199_254_740_991
357                        && projector_digests.insert(&projector.sha256),
358                    "invalid projector artifact",
359                )?;
360                projector_bytes = projector_bytes
361                    .checked_add(projector.bytes)
362                    .ok_or_else(|| ValidationError("projector size overflow".into()))?;
363            }
364            require(
365                quant.projectors.is_empty()
366                    || (self.source.ollama.is_some()
367                        && quant.sha256.is_some()
368                        && (projector_bytes as f64) < quant.disk_bytes),
369                "projector artifacts require pinned Ollama weights and aggregate size",
370            )?;
371        }
372        let source = &self.source;
373        require(
374            source.ollama.is_some()
375                || source.hf.is_some()
376                || source.gguf.is_some()
377                || source.mlx.is_some(),
378            "missing model source",
379        )?;
380        for text in [&source.ollama, &source.hf].into_iter().flatten() {
381            nonempty(text)?;
382        }
383        if let Some(gguf) = &source.gguf {
384            coordinates(&gguf.repo, &gguf.revision)?;
385            model_file(&gguf.file, 255)?;
386            digest(&gguf.sha256)?;
387        }
388        if let Some(mlx) = &source.mlx {
389            coordinates(&mlx.repo, &mlx.revision)?;
390            require(
391                (3..=256).contains(&mlx.files.len()) && self.quantizations.len() == 1,
392                "invalid MLX manifest cardinality",
393            )?;
394            let mut paths = HashSet::new();
395            let mut total = 0.0;
396            for file in &mlx.files {
397                model_file(&file.file, 512)?;
398                digest(&file.sha256)?;
399                require(
400                    !matches(&file.file, r"(?i)\.(py|pyc|pyo|so|dylib|dll|bundle)$"),
401                    "executable MLX file forbidden",
402                )?;
403                require(
404                    integer(file.bytes, 1.0) && paths.insert(file.file.as_str()),
405                    "invalid or duplicate MLX file",
406                )?;
407                total += file.bytes;
408                require(integer(total, 1.0), "MLX total overflows")?;
409            }
410            require(
411                paths.contains("config.json")
412                    && paths.contains("tokenizer_config.json")
413                    && paths.iter().any(|path| path.ends_with(".safetensors")),
414                "incomplete MLX manifest",
415            )?;
416            require(
417                total == self.quantizations[0].disk_bytes,
418                "MLX manifest size mismatch",
419            )?;
420        }
421        Ok(())
422    }
423}
424/// The calendar date `months` (1–3) before `today`, clamped to the end of shorter months.
425pub fn months_before(today: &str, months: u8) -> Result<Date, ValidationError> {
426    require(
427        (1..=3).contains(&months),
428        "recency window must be 1, 2 or 3 months",
429    )?;
430    let today = date(today)?;
431    let mut year = today.year();
432    let mut month = u8::from(today.month()) as i32 - i32::from(months);
433    if month < 1 {
434        month += 12;
435        year -= 1;
436    }
437    let month =
438        time::Month::try_from(month as u8).map_err(|_| ValidationError("invalid month".into()))?;
439    let day = today.day().min(month.length(year));
440    Date::from_calendar_date(year, month, day).map_err(|_| ValidationError("invalid date".into()))
441}
442
443impl Catalog {
444    /// Keeps entries whose recency date (released, else added) is within `months` of `today`.
445    pub fn retain_recent(&mut self, today: &str, months: u8) -> Result<(), ValidationError> {
446        let cutoff = months_before(today, months)?;
447        self.models.retain(|model| {
448            model
449                .recency()
450                .and_then(|(day, _)| date(day).ok())
451                .is_some_and(|day| day >= cutoff)
452        });
453        Ok(())
454    }
455    pub fn parse(raw: &str) -> Result<Self, ValidationError> {
456        let mut result: Self = parse_document(raw)?;
457        require(
458            (2..=SCHEMA_VERSION).contains(&result.schema_version) && !result.models.is_empty(),
459            "unsupported or empty catalog",
460        )?;
461        timestamp(&result.generated_at)?;
462        for model in &result.models {
463            require(
464                result.schema_version >= 3
465                    || (model.provenance.is_curated() && model.added_at.is_none()),
466                "schema v2 catalogs cannot carry v3 fields",
467            )?;
468            model.validate()?;
469        }
470        for model in &mut result.models {
471            use crate::reports::strip_control;
472            model.id = strip_control(&model.id);
473            model.family = strip_control(&model.family);
474            for value in [&mut model.source.ollama, &mut model.source.hf]
475                .into_iter()
476                .flatten()
477            {
478                *value = strip_control(value);
479            }
480            if let Some(source) = &mut model.source.gguf {
481                source.file = strip_control(&source.file);
482            }
483            if let Some(source) = &mut model.source.mlx {
484                for file in &mut source.files {
485                    file.file = strip_control(&file.file);
486                }
487            }
488            for quant in &mut model.quantizations {
489                quant.name = strip_control(&quant.name);
490            }
491        }
492        let mut ids = HashSet::new();
493        for model in &result.models {
494            require(ids.insert(&model.id), "duplicate catalog model id")?;
495        }
496        Ok(result)
497    }
498}
499
500#[derive(Debug, Clone, Deserialize, Serialize)]
501#[serde(rename_all = "camelCase", deny_unknown_fields)]
502pub struct Provenance {
503    pub value: f64,
504    pub trust_tier: String,
505    pub basis_bytes_per_token: f64,
506    pub url: String,
507}
508#[derive(Debug, Clone, Deserialize, Serialize)]
509#[serde(rename_all = "camelCase", deny_unknown_fields)]
510pub struct PerfSources {
511    pub bandwidth: String,
512    pub efficiency: String,
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub efficiency_by_backend: Option<BTreeMap<String, Provenance>>,
515}
516#[derive(Debug, Clone, Deserialize, Serialize)]
517#[serde(rename_all = "camelCase", deny_unknown_fields)]
518pub struct PerfClass {
519    pub id: String,
520    pub label: String,
521    pub vendor: String,
522    pub kind: String,
523    #[serde(rename = "memBandwidthGBps")]
524    pub mem_bandwidth_gbps: f64,
525    pub efficiency: f64,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub efficiency_by_backend: Option<BTreeMap<String, f64>>,
528    pub min_bytes: f64,
529    pub max_bytes: f64,
530    pub sources: PerfSources,
531}
532#[derive(Debug, Clone, Deserialize, Serialize)]
533#[serde(rename_all = "camelCase", deny_unknown_fields)]
534pub struct PerfDataset {
535    pub schema_version: u8,
536    pub generated_at: String,
537    pub classes: Vec<PerfClass>,
538}
539impl PerfDataset {
540    pub fn parse(raw: &str) -> Result<Self, ValidationError> {
541        let mut result: Self = parse_document(raw)?;
542        require(
543            result.schema_version == 1 && !result.classes.is_empty(),
544            "unsupported or empty performance data",
545        )?;
546        timestamp(&result.generated_at)?;
547        for (index, class) in result.classes.iter().enumerate() {
548            require(
549                matches(&class.id, r"^[a-z0-9-]+$")
550                    && !class.label.is_empty()
551                    && VENDORS.contains(&class.vendor.as_str())
552                    && ["discrete", "unified", "cpu"].contains(&class.kind.as_str()),
553                "invalid performance class",
554            )?;
555            require(
556                class.mem_bandwidth_gbps > 0.0
557                    && class.efficiency > 0.0
558                    && class.efficiency <= 1.0
559                    && integer(class.min_bytes, 0.0)
560                    && integer(class.max_bytes, 1.0)
561                    && class.max_bytes > class.min_bytes,
562                "invalid performance figures",
563            )?;
564            nonempty(&class.sources.bandwidth)?;
565            nonempty(&class.sources.efficiency)?;
566            let scalars = class.efficiency_by_backend.clone().unwrap_or_default();
567            let provenance = class
568                .sources
569                .efficiency_by_backend
570                .clone()
571                .unwrap_or_default();
572            require(
573                scalars.keys().eq(provenance.keys()),
574                "efficiency provenance mismatch",
575            )?;
576            for (backend, scalar) in scalars {
577                let source = &provenance[&backend];
578                require(
579                    BACKENDS.contains(&backend.as_str())
580                        && scalar > 0.0
581                        && scalar <= 1.0
582                        && source.value == scalar
583                        && ["session-verified", "spec-grade"].contains(&source.trust_tier.as_str())
584                        && source.basis_bytes_per_token > 0.0
585                        && url::Url::parse(&source.url).is_ok(),
586                    "invalid efficiency provenance",
587                )?;
588            }
589            for prior in &result.classes[..index] {
590                require(prior.id != class.id, "duplicate performance id")?;
591                require(
592                    prior.vendor != class.vendor
593                        || prior.kind != class.kind
594                        || prior.min_bytes >= class.max_bytes
595                        || class.min_bytes >= prior.max_bytes,
596                    "overlapping performance ranges",
597                )?;
598            }
599        }
600        for class in &mut result.classes {
601            use crate::reports::strip_control;
602            class.label = strip_control(&class.label);
603            class.sources.bandwidth = strip_control(&class.sources.bandwidth);
604            class.sources.efficiency = strip_control(&class.sources.efficiency);
605            if let Some(sources) = &mut class.sources.efficiency_by_backend {
606                for source in sources.values_mut() {
607                    source.url = strip_control(&source.url);
608                }
609            }
610        }
611        Ok(result)
612    }
613}
614
615#[derive(Debug, Serialize)]
616pub struct ResolutionError {
617    pub code: &'static str,
618    pub message: String,
619    pub candidates: Vec<String>,
620}
621#[derive(Debug, Serialize)]
622pub struct Resolved<'a> {
623    pub model: &'a CatalogModel,
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub quant: Option<&'a Quantization>,
626}
627fn resolution_error(message: String, candidates: Vec<String>, validation: bool) -> ResolutionError {
628    ResolutionError {
629        code: if validation {
630            "VALIDATION_ERROR"
631        } else {
632            "MODEL_RESOLUTION_ERROR"
633        },
634        message,
635        candidates,
636    }
637}
638fn finalize(resolved: Resolved<'_>) -> Result<Resolved<'_>, ResolutionError> {
639    for id in [
640        Some(&resolved.model.id),
641        resolved.model.source.ollama.as_ref(),
642    ]
643    .into_iter()
644    .flatten()
645    {
646        if id.contains("..") || !matches(id, r"^[a-z0-9._:/][a-z0-9._:/-]*$") {
647            return Err(resolution_error(
648                "unsafe catalog model id".into(),
649                vec![],
650                true,
651            ));
652        }
653    }
654    Ok(resolved)
655}
656pub fn resolve<'a>(catalog: &'a Catalog, input: &str) -> Result<Resolved<'a>, ResolutionError> {
657    let query = input.trim().to_lowercase();
658    if query.is_empty() || query.contains("..") || !matches(&query, r"^[a-z0-9._:/-]+$") {
659        return Err(resolution_error("invalid model name".into(), vec![], true));
660    }
661    if let Some(model) = catalog
662        .models
663        .iter()
664        .find(|model| model.id.to_lowercase() == query)
665    {
666        return finalize(Resolved { model, quant: None });
667    }
668    let quant_matches: Vec<Resolved<'_>> = catalog
669        .models
670        .iter()
671        .flat_map(|model| {
672            model.quantizations.iter().filter_map(|quant| {
673                (format!("{}-{}", model.id, quant.name).to_lowercase() == query).then_some(
674                    Resolved {
675                        model,
676                        quant: Some(quant),
677                    },
678                )
679            })
680        })
681        .collect();
682    let family: Vec<Resolved<'_>> = catalog
683        .models
684        .iter()
685        .filter(|model| model.family.to_lowercase() == query)
686        .map(|model| Resolved { model, quant: None })
687        .collect();
688    let prefix: Vec<Resolved<'_>> = catalog
689        .models
690        .iter()
691        .filter(|model| {
692            model.id.to_lowercase().starts_with(&query)
693                || model.family.to_lowercase().starts_with(&query)
694        })
695        .map(|model| Resolved { model, quant: None })
696        .collect();
697    for mut tier in [quant_matches, family, prefix] {
698        if tier.len() == 1 {
699            return finalize(tier.remove(0));
700        }
701        if !tier.is_empty() {
702            let mut candidates: Vec<String> =
703                tier.iter().map(|entry| entry.model.id.clone()).collect();
704            candidates.sort();
705            return Err(resolution_error(
706                format!(
707                    "\"{query}\" is ambiguous; candidates: {}",
708                    candidates.join(", ")
709                ),
710                candidates,
711                false,
712            ));
713        }
714    }
715    Err(resolution_error(
716        format!("no model matches \"{query}\""),
717        vec![],
718        false,
719    ))
720}