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: &str) -> bool {
41 Regex::new(pattern).is_ok_and(|regex| regex.is_match(value))
42}
43fn integer(value: f64, minimum: f64) -> bool {
44 value.is_finite()
45 && value.fract() == 0.0
46 && value >= minimum
47 && value <= 9_007_199_254_740_991.0
48}
49fn nonempty(value: &str) -> Result<(), ValidationError> {
50 require(!value.is_empty(), "empty text field")
51}
52pub(crate) fn timestamp(value: &str) -> Result<(), ValidationError> {
53 require(
54 value.ends_with('Z') && OffsetDateTime::parse(value, &Rfc3339).is_ok(),
55 "invalid generatedAt timestamp",
56 )
57}
58pub fn date(value: &str) -> Result<Date, ValidationError> {
59 Date::parse(
60 value,
61 time::macros::format_description!("[year]-[month]-[day]"),
62 )
63 .map_err(|_| ValidationError("invalid calendar date".into()))
64}
65fn no_nulls(value: &serde_json::Value) -> bool {
66 match value {
67 serde_json::Value::Null => false,
68 serde_json::Value::Array(values) => values.iter().all(no_nulls),
69 serde_json::Value::Object(values) => values.values().all(no_nulls),
70 _ => true,
71 }
72}
73pub fn parse_document<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, ValidationError> {
74 require(raw.len() <= 16 * 1024 * 1024, "dataset exceeds 16 MiB")?;
75 let value: serde_json::Value =
76 serde_json::from_str(raw).map_err(|error| ValidationError(error.to_string()))?;
77 require(no_nulls(&value), "null fields are not allowed")?;
78 serde_json::from_value(value).map_err(|error| ValidationError(error.to_string()))
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
82#[serde(rename_all = "camelCase", deny_unknown_fields)]
83pub struct GgufSource {
84 pub repo: String,
85 pub revision: String,
86 pub file: String,
87 pub sha256: String,
88}
89#[derive(Debug, Clone, Deserialize, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct MlxFile {
92 pub file: String,
93 pub sha256: String,
94 pub bytes: f64,
95}
96#[derive(Debug, Clone, Deserialize, Serialize)]
97#[serde(deny_unknown_fields)]
98pub struct MlxSource {
99 pub repo: String,
100 pub revision: String,
101 pub files: Vec<MlxFile>,
102}
103#[derive(Debug, Clone, Default, Deserialize, Serialize)]
104#[serde(deny_unknown_fields)]
105pub struct Source {
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub ollama: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub hf: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub gguf: Option<GgufSource>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub mlx: Option<MlxSource>,
114}
115#[derive(Debug, Clone, Deserialize, Serialize)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct CatalogModel {
118 pub id: String,
119 pub family: String,
120 pub params: String,
121 pub architecture: Architecture,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub active_params: Option<String>,
124 pub license: String,
125 pub open_weight: bool,
126 pub context_length: f64,
127 pub capabilities: Vec<String>,
128 pub release_date: String,
129 pub source: Source,
130 pub quantizations: Vec<Quantization>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub kv_bytes_per_token: Option<f64>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub benchmark_proxy: Option<f64>,
135}
136#[derive(Debug, Clone, Deserialize, Serialize)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct Catalog {
139 pub schema_version: u8,
140 pub generated_at: String,
141 pub models: Vec<CatalogModel>,
142}
143
144pub(crate) fn coordinates(repo: &str, revision: &str) -> Result<(), ValidationError> {
145 require(
146 repo.len() <= 200
147 && matches(
148 repo,
149 r"^[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*$",
150 ),
151 "invalid source repository",
152 )?;
153 require(
154 matches(revision, r"^[0-9a-fA-F]{40}$"),
155 "invalid pinned source revision",
156 )
157}
158pub(crate) fn model_file(file: &str, max: usize) -> Result<(), ValidationError> {
159 require(
160 !file.is_empty()
161 && file.len() <= max
162 && !file
163 .chars()
164 .any(|ch| ch.is_control() || "*?[]{}\\%".contains(ch))
165 && file
166 .split('/')
167 .all(|segment| !segment.is_empty() && segment != "." && segment != ".."),
168 "unsafe model file path",
169 )
170}
171pub(crate) fn digest(value: &str) -> Result<(), ValidationError> {
172 require(
173 matches(value, r"^[0-9a-fA-F]{64}$"),
174 "invalid SHA-256 digest",
175 )
176}
177
178impl CatalogModel {
179 pub fn sizing(&self) -> Model {
180 Model {
181 id: self.id.clone(),
182 params: self.params.clone(),
183 architecture: self.architecture.clone(),
184 context_length: self.context_length,
185 kv_bytes_per_token: self.kv_bytes_per_token,
186 quantizations: self.quantizations.clone(),
187 }
188 }
189 pub fn with_kv_cache(&self, kind: KvCacheType) -> Result<Self, ValidationError> {
191 let mut model = self.clone();
192 if let Some(rate) = self.kv_bytes_per_token {
193 model.kv_bytes_per_token =
194 Some(typed_kv_bytes_per_token(rate as u64, kind, kind)? as f64);
195 }
196 Ok(model)
197 }
198 pub(crate) fn validate(&self) -> Result<(), ValidationError> {
199 nonempty(&self.id)?;
200 nonempty(&self.family)?;
201 require(
202 matches(&self.params, r"^\d+(\.\d+)?[BMT]$"),
203 "invalid parameter label",
204 )?;
205 require(
206 self.open_weight
207 && !self.license.is_empty()
208 && LICENSES.contains(&self.license.as_str()),
209 "catalog requires open-weight license",
210 )?;
211 require(
212 integer(self.context_length, 1.0)
213 && !self.capabilities.is_empty()
214 && self
215 .capabilities
216 .iter()
217 .all(|cap| CAPABILITIES.contains(&cap.as_str())),
218 "invalid context or capabilities",
219 )?;
220 date(&self.release_date)?;
221 require(
222 matches!(self.architecture, Architecture::Moe) == self.active_params.is_some(),
223 "activeParams must exist only for MoE",
224 )?;
225 if let Some(active) = &self.active_params {
226 require(
227 matches(active, r"^\d+(\.\d+)?[BMT]$"),
228 "invalid activeParams",
229 )?;
230 }
231 if let Some(rate) = self.kv_bytes_per_token {
232 require(integer(rate, 1.0), "invalid KV geometry")?;
233 }
234 if let Some(proxy) = self.benchmark_proxy {
235 require((0.0..=1.0).contains(&proxy), "invalid benchmark proxy")?;
236 }
237 require(
238 !self.quantizations.is_empty(),
239 "model must have quantizations",
240 )?;
241 for quant in &self.quantizations {
242 nonempty(&quant.name)?;
243 require(
244 integer(quant.disk_bytes, 1.0)
245 && integer(quant.min_ram_bytes, 1.0)
246 && integer(quant.min_vram_bytes, 0.0),
247 "invalid quantization sizes",
248 )?;
249 if let Some(sha) = &quant.sha256 {
250 digest(sha)?;
251 }
252 require(quant.projectors.len() <= 16, "too many projector artifacts")?;
253 let mut projector_digests = std::collections::HashSet::new();
254 let mut projector_bytes = 0_u64;
255 for projector in &quant.projectors {
256 digest(&projector.sha256)?;
257 require(
258 projector.bytes > 0
259 && projector.bytes <= 9_007_199_254_740_991
260 && projector_digests.insert(&projector.sha256),
261 "invalid projector artifact",
262 )?;
263 projector_bytes = projector_bytes
264 .checked_add(projector.bytes)
265 .ok_or_else(|| ValidationError("projector size overflow".into()))?;
266 }
267 require(
268 quant.projectors.is_empty()
269 || (self.source.ollama.is_some()
270 && quant.sha256.is_some()
271 && (projector_bytes as f64) < quant.disk_bytes),
272 "projector artifacts require pinned Ollama weights and aggregate size",
273 )?;
274 }
275 let source = &self.source;
276 require(
277 source.ollama.is_some()
278 || source.hf.is_some()
279 || source.gguf.is_some()
280 || source.mlx.is_some(),
281 "missing model source",
282 )?;
283 for text in [&source.ollama, &source.hf].into_iter().flatten() {
284 nonempty(text)?;
285 }
286 if let Some(gguf) = &source.gguf {
287 coordinates(&gguf.repo, &gguf.revision)?;
288 model_file(&gguf.file, 255)?;
289 digest(&gguf.sha256)?;
290 }
291 if let Some(mlx) = &source.mlx {
292 coordinates(&mlx.repo, &mlx.revision)?;
293 require(
294 (3..=256).contains(&mlx.files.len()) && self.quantizations.len() == 1,
295 "invalid MLX manifest cardinality",
296 )?;
297 let mut paths = HashSet::new();
298 let mut total = 0.0;
299 for file in &mlx.files {
300 model_file(&file.file, 512)?;
301 digest(&file.sha256)?;
302 require(
303 !matches(&file.file, r"(?i)\.(py|pyc|pyo|so|dylib|dll|bundle)$"),
304 "executable MLX file forbidden",
305 )?;
306 require(
307 integer(file.bytes, 1.0) && paths.insert(file.file.as_str()),
308 "invalid or duplicate MLX file",
309 )?;
310 total += file.bytes;
311 require(integer(total, 1.0), "MLX total overflows")?;
312 }
313 require(
314 paths.contains("config.json")
315 && paths.contains("tokenizer_config.json")
316 && paths.iter().any(|path| path.ends_with(".safetensors")),
317 "incomplete MLX manifest",
318 )?;
319 require(
320 total == self.quantizations[0].disk_bytes,
321 "MLX manifest size mismatch",
322 )?;
323 }
324 Ok(())
325 }
326}
327impl Catalog {
328 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
329 let mut result: Self = parse_document(raw)?;
330 require(
331 result.schema_version == 2 && !result.models.is_empty(),
332 "unsupported or empty catalog",
333 )?;
334 timestamp(&result.generated_at)?;
335 for model in &result.models {
336 model.validate()?;
337 }
338 for model in &mut result.models {
339 use crate::reports::strip_control;
340 model.id = strip_control(&model.id);
341 model.family = strip_control(&model.family);
342 for value in [&mut model.source.ollama, &mut model.source.hf]
343 .into_iter()
344 .flatten()
345 {
346 *value = strip_control(value);
347 }
348 if let Some(source) = &mut model.source.gguf {
349 source.file = strip_control(&source.file);
350 }
351 if let Some(source) = &mut model.source.mlx {
352 for file in &mut source.files {
353 file.file = strip_control(&file.file);
354 }
355 }
356 for quant in &mut model.quantizations {
357 quant.name = strip_control(&quant.name);
358 }
359 }
360 let mut ids = HashSet::new();
361 for model in &result.models {
362 require(ids.insert(&model.id), "duplicate catalog model id")?;
363 }
364 Ok(result)
365 }
366}
367
368#[derive(Debug, Clone, Deserialize, Serialize)]
369#[serde(rename_all = "camelCase", deny_unknown_fields)]
370pub struct Provenance {
371 pub value: f64,
372 pub trust_tier: String,
373 pub basis_bytes_per_token: f64,
374 pub url: String,
375}
376#[derive(Debug, Clone, Deserialize, Serialize)]
377#[serde(rename_all = "camelCase", deny_unknown_fields)]
378pub struct PerfSources {
379 pub bandwidth: String,
380 pub efficiency: String,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub efficiency_by_backend: Option<BTreeMap<String, Provenance>>,
383}
384#[derive(Debug, Clone, Deserialize, Serialize)]
385#[serde(rename_all = "camelCase", deny_unknown_fields)]
386pub struct PerfClass {
387 pub id: String,
388 pub label: String,
389 pub vendor: String,
390 pub kind: String,
391 #[serde(rename = "memBandwidthGBps")]
392 pub mem_bandwidth_gbps: f64,
393 pub efficiency: f64,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 pub efficiency_by_backend: Option<BTreeMap<String, f64>>,
396 pub min_bytes: f64,
397 pub max_bytes: f64,
398 pub sources: PerfSources,
399}
400#[derive(Debug, Clone, Deserialize, Serialize)]
401#[serde(rename_all = "camelCase", deny_unknown_fields)]
402pub struct PerfDataset {
403 pub schema_version: u8,
404 pub generated_at: String,
405 pub classes: Vec<PerfClass>,
406}
407impl PerfDataset {
408 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
409 let mut result: Self = parse_document(raw)?;
410 require(
411 result.schema_version == 1 && !result.classes.is_empty(),
412 "unsupported or empty performance data",
413 )?;
414 timestamp(&result.generated_at)?;
415 for (index, class) in result.classes.iter().enumerate() {
416 require(
417 matches(&class.id, r"^[a-z0-9-]+$")
418 && !class.label.is_empty()
419 && VENDORS.contains(&class.vendor.as_str())
420 && ["discrete", "unified", "cpu"].contains(&class.kind.as_str()),
421 "invalid performance class",
422 )?;
423 require(
424 class.mem_bandwidth_gbps > 0.0
425 && class.efficiency > 0.0
426 && class.efficiency <= 1.0
427 && integer(class.min_bytes, 0.0)
428 && integer(class.max_bytes, 1.0)
429 && class.max_bytes > class.min_bytes,
430 "invalid performance figures",
431 )?;
432 nonempty(&class.sources.bandwidth)?;
433 nonempty(&class.sources.efficiency)?;
434 let scalars = class.efficiency_by_backend.clone().unwrap_or_default();
435 let provenance = class
436 .sources
437 .efficiency_by_backend
438 .clone()
439 .unwrap_or_default();
440 require(
441 scalars.keys().eq(provenance.keys()),
442 "efficiency provenance mismatch",
443 )?;
444 for (backend, scalar) in scalars {
445 let source = &provenance[&backend];
446 require(
447 BACKENDS.contains(&backend.as_str())
448 && scalar > 0.0
449 && scalar <= 1.0
450 && source.value == scalar
451 && ["session-verified", "spec-grade"].contains(&source.trust_tier.as_str())
452 && source.basis_bytes_per_token > 0.0
453 && url::Url::parse(&source.url).is_ok(),
454 "invalid efficiency provenance",
455 )?;
456 }
457 for prior in &result.classes[..index] {
458 require(prior.id != class.id, "duplicate performance id")?;
459 require(
460 prior.vendor != class.vendor
461 || prior.kind != class.kind
462 || prior.min_bytes >= class.max_bytes
463 || class.min_bytes >= prior.max_bytes,
464 "overlapping performance ranges",
465 )?;
466 }
467 }
468 for class in &mut result.classes {
469 use crate::reports::strip_control;
470 class.label = strip_control(&class.label);
471 class.sources.bandwidth = strip_control(&class.sources.bandwidth);
472 class.sources.efficiency = strip_control(&class.sources.efficiency);
473 if let Some(sources) = &mut class.sources.efficiency_by_backend {
474 for source in sources.values_mut() {
475 source.url = strip_control(&source.url);
476 }
477 }
478 }
479 Ok(result)
480 }
481}
482
483#[derive(Debug, Serialize)]
484pub struct ResolutionError {
485 pub code: &'static str,
486 pub message: String,
487 pub candidates: Vec<String>,
488}
489#[derive(Debug, Serialize)]
490pub struct Resolved<'a> {
491 pub model: &'a CatalogModel,
492 #[serde(skip_serializing_if = "Option::is_none")]
493 pub quant: Option<&'a Quantization>,
494}
495fn resolution_error(message: String, candidates: Vec<String>, validation: bool) -> ResolutionError {
496 ResolutionError {
497 code: if validation {
498 "VALIDATION_ERROR"
499 } else {
500 "MODEL_RESOLUTION_ERROR"
501 },
502 message,
503 candidates,
504 }
505}
506fn finalize(resolved: Resolved<'_>) -> Result<Resolved<'_>, ResolutionError> {
507 for id in [
508 Some(&resolved.model.id),
509 resolved.model.source.ollama.as_ref(),
510 ]
511 .into_iter()
512 .flatten()
513 {
514 if id.contains("..") || !matches(id, r"^[a-z0-9._:/][a-z0-9._:/-]*$") {
515 return Err(resolution_error(
516 "unsafe catalog model id".into(),
517 vec![],
518 true,
519 ));
520 }
521 }
522 Ok(resolved)
523}
524pub fn resolve<'a>(catalog: &'a Catalog, input: &str) -> Result<Resolved<'a>, ResolutionError> {
525 let query = input.trim().to_lowercase();
526 if query.is_empty() || query.contains("..") || !matches(&query, r"^[a-z0-9._:/-]+$") {
527 return Err(resolution_error("invalid model name".into(), vec![], true));
528 }
529 if let Some(model) = catalog
530 .models
531 .iter()
532 .find(|model| model.id.to_lowercase() == query)
533 {
534 return finalize(Resolved { model, quant: None });
535 }
536 let quant_matches: Vec<Resolved<'_>> = catalog
537 .models
538 .iter()
539 .flat_map(|model| {
540 model.quantizations.iter().filter_map(|quant| {
541 (format!("{}-{}", model.id, quant.name).to_lowercase() == query).then_some(
542 Resolved {
543 model,
544 quant: Some(quant),
545 },
546 )
547 })
548 })
549 .collect();
550 let family: Vec<Resolved<'_>> = catalog
551 .models
552 .iter()
553 .filter(|model| model.family.to_lowercase() == query)
554 .map(|model| Resolved { model, quant: None })
555 .collect();
556 let prefix: Vec<Resolved<'_>> = catalog
557 .models
558 .iter()
559 .filter(|model| {
560 model.id.to_lowercase().starts_with(&query)
561 || model.family.to_lowercase().starts_with(&query)
562 })
563 .map(|model| Resolved { model, quant: None })
564 .collect();
565 for mut tier in [quant_matches, family, prefix] {
566 if tier.len() == 1 {
567 return finalize(tier.remove(0));
568 }
569 if !tier.is_empty() {
570 let mut candidates: Vec<String> =
571 tier.iter().map(|entry| entry.model.id.clone()).collect();
572 candidates.sort();
573 return Err(resolution_error(
574 format!(
575 "\"{query}\" is ambiguous; candidates: {}",
576 candidates.join(", ")
577 ),
578 candidates,
579 false,
580 ));
581 }
582 }
583 Err(resolution_error(
584 format!("no model matches \"{query}\""),
585 vec![],
586 false,
587 ))
588}