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}
52fn 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
144fn 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}
158fn 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}
171fn 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 }
253 let source = &self.source;
254 require(
255 source.ollama.is_some()
256 || source.hf.is_some()
257 || source.gguf.is_some()
258 || source.mlx.is_some(),
259 "missing model source",
260 )?;
261 for text in [&source.ollama, &source.hf].into_iter().flatten() {
262 nonempty(text)?;
263 }
264 if let Some(gguf) = &source.gguf {
265 coordinates(&gguf.repo, &gguf.revision)?;
266 model_file(&gguf.file, 255)?;
267 digest(&gguf.sha256)?;
268 }
269 if let Some(mlx) = &source.mlx {
270 coordinates(&mlx.repo, &mlx.revision)?;
271 require(
272 (3..=256).contains(&mlx.files.len()) && self.quantizations.len() == 1,
273 "invalid MLX manifest cardinality",
274 )?;
275 let mut paths = HashSet::new();
276 let mut total = 0.0;
277 for file in &mlx.files {
278 model_file(&file.file, 512)?;
279 digest(&file.sha256)?;
280 require(
281 !matches(&file.file, r"(?i)\.(py|pyc|pyo|so|dylib|dll|bundle)$"),
282 "executable MLX file forbidden",
283 )?;
284 require(
285 integer(file.bytes, 1.0) && paths.insert(file.file.as_str()),
286 "invalid or duplicate MLX file",
287 )?;
288 total += file.bytes;
289 require(integer(total, 1.0), "MLX total overflows")?;
290 }
291 require(
292 paths.contains("config.json")
293 && paths.contains("tokenizer_config.json")
294 && paths.iter().any(|path| path.ends_with(".safetensors")),
295 "incomplete MLX manifest",
296 )?;
297 require(
298 total == self.quantizations[0].disk_bytes,
299 "MLX manifest size mismatch",
300 )?;
301 }
302 Ok(())
303 }
304}
305impl Catalog {
306 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
307 let mut result: Self = parse_document(raw)?;
308 require(
309 result.schema_version == 2 && !result.models.is_empty(),
310 "unsupported or empty catalog",
311 )?;
312 timestamp(&result.generated_at)?;
313 for model in &result.models {
314 model.validate()?;
315 }
316 for model in &mut result.models {
317 use crate::reports::strip_control;
318 model.id = strip_control(&model.id);
319 model.family = strip_control(&model.family);
320 for value in [&mut model.source.ollama, &mut model.source.hf]
321 .into_iter()
322 .flatten()
323 {
324 *value = strip_control(value);
325 }
326 if let Some(source) = &mut model.source.gguf {
327 source.file = strip_control(&source.file);
328 }
329 if let Some(source) = &mut model.source.mlx {
330 for file in &mut source.files {
331 file.file = strip_control(&file.file);
332 }
333 }
334 for quant in &mut model.quantizations {
335 quant.name = strip_control(&quant.name);
336 }
337 }
338 let mut ids = HashSet::new();
339 for model in &result.models {
340 require(ids.insert(&model.id), "duplicate catalog model id")?;
341 }
342 Ok(result)
343 }
344}
345
346#[derive(Debug, Clone, Deserialize, Serialize)]
347#[serde(rename_all = "camelCase", deny_unknown_fields)]
348pub struct Provenance {
349 pub value: f64,
350 pub trust_tier: String,
351 pub basis_bytes_per_token: f64,
352 pub url: String,
353}
354#[derive(Debug, Clone, Deserialize, Serialize)]
355#[serde(rename_all = "camelCase", deny_unknown_fields)]
356pub struct PerfSources {
357 pub bandwidth: String,
358 pub efficiency: String,
359 #[serde(skip_serializing_if = "Option::is_none")]
360 pub efficiency_by_backend: Option<BTreeMap<String, Provenance>>,
361}
362#[derive(Debug, Clone, Deserialize, Serialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364pub struct PerfClass {
365 pub id: String,
366 pub label: String,
367 pub vendor: String,
368 pub kind: String,
369 #[serde(rename = "memBandwidthGBps")]
370 pub mem_bandwidth_gbps: f64,
371 pub efficiency: f64,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub efficiency_by_backend: Option<BTreeMap<String, f64>>,
374 pub min_bytes: f64,
375 pub max_bytes: f64,
376 pub sources: PerfSources,
377}
378#[derive(Debug, Clone, Deserialize, Serialize)]
379#[serde(rename_all = "camelCase", deny_unknown_fields)]
380pub struct PerfDataset {
381 pub schema_version: u8,
382 pub generated_at: String,
383 pub classes: Vec<PerfClass>,
384}
385impl PerfDataset {
386 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
387 let mut result: Self = parse_document(raw)?;
388 require(
389 result.schema_version == 1 && !result.classes.is_empty(),
390 "unsupported or empty performance data",
391 )?;
392 timestamp(&result.generated_at)?;
393 for (index, class) in result.classes.iter().enumerate() {
394 require(
395 matches(&class.id, r"^[a-z0-9-]+$")
396 && !class.label.is_empty()
397 && VENDORS.contains(&class.vendor.as_str())
398 && ["discrete", "unified", "cpu"].contains(&class.kind.as_str()),
399 "invalid performance class",
400 )?;
401 require(
402 class.mem_bandwidth_gbps > 0.0
403 && class.efficiency > 0.0
404 && class.efficiency <= 1.0
405 && integer(class.min_bytes, 0.0)
406 && integer(class.max_bytes, 1.0)
407 && class.max_bytes > class.min_bytes,
408 "invalid performance figures",
409 )?;
410 nonempty(&class.sources.bandwidth)?;
411 nonempty(&class.sources.efficiency)?;
412 let scalars = class.efficiency_by_backend.clone().unwrap_or_default();
413 let provenance = class
414 .sources
415 .efficiency_by_backend
416 .clone()
417 .unwrap_or_default();
418 require(
419 scalars.keys().eq(provenance.keys()),
420 "efficiency provenance mismatch",
421 )?;
422 for (backend, scalar) in scalars {
423 let source = &provenance[&backend];
424 require(
425 BACKENDS.contains(&backend.as_str())
426 && scalar > 0.0
427 && scalar <= 1.0
428 && source.value == scalar
429 && ["session-verified", "spec-grade"].contains(&source.trust_tier.as_str())
430 && source.basis_bytes_per_token > 0.0
431 && url::Url::parse(&source.url).is_ok(),
432 "invalid efficiency provenance",
433 )?;
434 }
435 for prior in &result.classes[..index] {
436 require(prior.id != class.id, "duplicate performance id")?;
437 require(
438 prior.vendor != class.vendor
439 || prior.kind != class.kind
440 || prior.min_bytes >= class.max_bytes
441 || class.min_bytes >= prior.max_bytes,
442 "overlapping performance ranges",
443 )?;
444 }
445 }
446 for class in &mut result.classes {
447 use crate::reports::strip_control;
448 class.label = strip_control(&class.label);
449 class.sources.bandwidth = strip_control(&class.sources.bandwidth);
450 class.sources.efficiency = strip_control(&class.sources.efficiency);
451 if let Some(sources) = &mut class.sources.efficiency_by_backend {
452 for source in sources.values_mut() {
453 source.url = strip_control(&source.url);
454 }
455 }
456 }
457 Ok(result)
458 }
459}
460
461#[derive(Debug, Serialize)]
462pub struct ResolutionError {
463 pub code: &'static str,
464 pub message: String,
465 pub candidates: Vec<String>,
466}
467#[derive(Debug, Serialize)]
468pub struct Resolved<'a> {
469 pub model: &'a CatalogModel,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub quant: Option<&'a Quantization>,
472}
473fn resolution_error(message: String, candidates: Vec<String>, validation: bool) -> ResolutionError {
474 ResolutionError {
475 code: if validation {
476 "VALIDATION_ERROR"
477 } else {
478 "MODEL_RESOLUTION_ERROR"
479 },
480 message,
481 candidates,
482 }
483}
484fn finalize(resolved: Resolved<'_>) -> Result<Resolved<'_>, ResolutionError> {
485 for id in [
486 Some(&resolved.model.id),
487 resolved.model.source.ollama.as_ref(),
488 ]
489 .into_iter()
490 .flatten()
491 {
492 if id.contains("..") || !matches(id, r"^[a-z0-9._:/][a-z0-9._:/-]*$") {
493 return Err(resolution_error(
494 "unsafe catalog model id".into(),
495 vec![],
496 true,
497 ));
498 }
499 }
500 Ok(resolved)
501}
502pub fn resolve<'a>(catalog: &'a Catalog, input: &str) -> Result<Resolved<'a>, ResolutionError> {
503 let query = input.trim().to_lowercase();
504 if query.is_empty() || query.contains("..") || !matches(&query, r"^[a-z0-9._:/-]+$") {
505 return Err(resolution_error("invalid model name".into(), vec![], true));
506 }
507 if let Some(model) = catalog
508 .models
509 .iter()
510 .find(|model| model.id.to_lowercase() == query)
511 {
512 return finalize(Resolved { model, quant: None });
513 }
514 let quant_matches: Vec<Resolved<'_>> = catalog
515 .models
516 .iter()
517 .flat_map(|model| {
518 model.quantizations.iter().filter_map(|quant| {
519 (format!("{}-{}", model.id, quant.name).to_lowercase() == query).then_some(
520 Resolved {
521 model,
522 quant: Some(quant),
523 },
524 )
525 })
526 })
527 .collect();
528 let family: Vec<Resolved<'_>> = catalog
529 .models
530 .iter()
531 .filter(|model| model.family.to_lowercase() == query)
532 .map(|model| Resolved { model, quant: None })
533 .collect();
534 let prefix: Vec<Resolved<'_>> = catalog
535 .models
536 .iter()
537 .filter(|model| {
538 model.id.to_lowercase().starts_with(&query)
539 || model.family.to_lowercase().starts_with(&query)
540 })
541 .map(|model| Resolved { model, quant: None })
542 .collect();
543 for mut tier in [quant_matches, family, prefix] {
544 if tier.len() == 1 {
545 return finalize(tier.remove(0));
546 }
547 if !tier.is_empty() {
548 let mut candidates: Vec<String> =
549 tier.iter().map(|entry| entry.model.id.clone()).collect();
550 candidates.sort();
551 return Err(resolution_error(
552 format!(
553 "\"{query}\" is ambiguous; candidates: {}",
554 candidates.join(", ")
555 ),
556 candidates,
557 false,
558 ));
559 }
560 }
561 Err(resolution_error(
562 format!("no model matches \"{query}\""),
563 vec![],
564 false,
565 ))
566}