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}
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
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
216pub 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}
225pub 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 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 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}
405pub 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 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}