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}
40fn 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
233pub 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}
242pub 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 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 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}
424pub 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 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}