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}
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    /// 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            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}