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; 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    /// Re-sizes the sourced f16 KV rate for a cache type; unknown geometry stays unknown.
190    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}