Skip to main content

rigspark_core/
admission.rs

1//! Pure parsers and builders for automatic catalog admission from the Ollama library.
2//! Network access lives in `rigspark-runtime`; everything here is deterministic.
3
4use crate::{
5    catalog::{CAPABILITIES, CatalogModel, EntryProvenance, LICENSES, Source, require},
6    enrich::sized_quantization,
7    registry_collector::ModelLayer,
8    reports::strip_control,
9    sizing::{Architecture, ValidationError, parse_param_count},
10};
11use regex::Regex;
12use serde::Deserialize;
13use serde_json::{Value, json};
14use std::{collections::BTreeMap, sync::OnceLock};
15
16pub const MAX_HTML_BYTES: usize = 4 * 1024 * 1024;
17pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
18pub const MAX_LICENSE_HEAD_BYTES: usize = 2048;
19pub const MAX_GGUF_HEADER_BYTES: usize = 64 * 1024 * 1024;
20const PARAMETER_TOLERANCE: f64 = 0.02;
21
22fn pattern(cell: &'static OnceLock<Regex>, source: &str) -> &'static Regex {
23    cell.get_or_init(|| Regex::new(source).expect("valid admission pattern"))
24}
25fn repository_name(name: &str) -> bool {
26    !name.is_empty()
27        && name.len() <= 128
28        && name.as_bytes()[0].is_ascii_alphanumeric()
29        && name.bytes().all(|byte| {
30            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
31        })
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LibraryEntry {
36    pub repository: String,
37    /// Catalog capabilities sourced from the listing's chips, in catalog order.
38    pub capabilities: Vec<String>,
39}
40
41fn capability(chip: &str) -> Option<&'static str> {
42    match chip {
43        "tools" => Some("tools"),
44        "vision" => Some("vision"),
45        "thinking" => Some("reasoning"),
46        "embedding" => Some("embedding"),
47        _ => None,
48    }
49}
50
51/// Parses `ollama.com/library?sort=newest`, preserving the page's newest-first order.
52pub fn parse_library(html: &str) -> Result<Vec<LibraryEntry>, ValidationError> {
53    require(
54        html.len() <= MAX_HTML_BYTES,
55        "library listing exceeds 4 MiB",
56    )?;
57    static ANCHOR: OnceLock<Regex> = OnceLock::new();
58    static CHIP: OnceLock<Regex> = OnceLock::new();
59    let anchor = pattern(
60        &ANCHOR,
61        r#"<a\s+href="/library/([a-z0-9][a-z0-9._-]*)"\s+class="group w-full space-y-5"\s*>"#,
62    );
63    let chip = pattern(
64        &CHIP,
65        r#"<span\s+class="inline-flex items-center rounded-md[^"]*"\s*>\s*([a-z0-9.]{1,32})\s*</span>"#,
66    );
67    let starts: Vec<_> = anchor.captures_iter(html).collect();
68    require(
69        starts.len() <= 10000,
70        "library listing has too many entries",
71    )?;
72    let mut entries: Vec<LibraryEntry> = Vec::new();
73    for (index, captures) in starts.iter().enumerate() {
74        let whole = captures.get(0).expect("match");
75        let end = starts
76            .get(index + 1)
77            .map_or(html.len(), |next| next.get(0).expect("match").start());
78        let repository = captures[1].to_string();
79        if !repository_name(&repository)
80            || entries.iter().any(|entry| entry.repository == repository)
81        {
82            continue;
83        }
84        let chips: Vec<&str> = chip
85            .captures_iter(&html[whole.end()..end])
86            .filter_map(|chip| capability(chip.get(1)?.as_str()))
87            .collect();
88        let capabilities = CAPABILITIES
89            .iter()
90            .filter(|known| chips.contains(known))
91            .map(|known| known.to_string())
92            .collect();
93        entries.push(LibraryEntry {
94            repository,
95            capabilities,
96        });
97    }
98    require(!entries.is_empty(), "library listing contains no models")?;
99    Ok(entries)
100}
101
102/// Tags linked from `ollama.com/library/{repository}/tags`, sorted and deduplicated.
103pub fn parse_tags(html: &str, repository: &str) -> Result<Vec<String>, ValidationError> {
104    require(html.len() <= MAX_HTML_BYTES, "tags page exceeds 4 MiB")?;
105    require(repository_name(repository), "invalid repository name")?;
106    static LINK: OnceLock<Regex> = OnceLock::new();
107    let link = pattern(
108        &LINK,
109        r#"href="/library/([a-z0-9][a-z0-9._-]*):([A-Za-z0-9._-]{1,128})""#,
110    );
111    let mut tags: Vec<String> = link
112        .captures_iter(html)
113        .filter(|captures| &captures[1] == repository)
114        .map(|captures| captures[2].to_string())
115        .collect();
116    tags.sort();
117    tags.dedup();
118    require(tags.len() <= 2000, "tags page has too many tags")?;
119    Ok(tags)
120}
121
122/// Per size, Ollama's default tag (`27b`, `30b-a3b`), else that size's `-q4_K_M` tag.
123pub fn select_variants(tags: &[String]) -> Vec<String> {
124    static PLAIN: OnceLock<Regex> = OnceLock::new();
125    static DEFAULT_QUANT: OnceLock<Regex> = OnceLock::new();
126    let plain = pattern(&PLAIN, r"^\d+(\.\d+)?[bm](-a\d+(\.\d+)?b)?$");
127    let default_quant = pattern(
128        &DEFAULT_QUANT,
129        r"(?i)^(\d+(\.\d+)?[bm](-a\d+(\.\d+)?b)?)-q4_k_m$",
130    );
131    let mut chosen: BTreeMap<String, String> = BTreeMap::new();
132    for tag in tags {
133        if plain.is_match(tag) {
134            chosen.insert(tag.clone(), tag.clone());
135        }
136    }
137    for tag in tags {
138        if let Some(captures) = default_quant.captures(tag) {
139            chosen
140                .entry(captures[1].to_string())
141                .or_insert_with(|| tag.clone());
142        }
143    }
144    chosen.into_values().collect()
145}
146
147#[derive(Debug, Clone, Deserialize)]
148pub struct RegistryConfig {
149    pub model_family: String,
150    pub model_type: String,
151    pub file_type: String,
152}
153impl RegistryConfig {
154    pub fn parse(raw: &str) -> Result<Self, ValidationError> {
155        require(raw.len() <= MAX_CONFIG_BYTES, "model config exceeds 64 KiB")?;
156        let config: Self =
157            serde_json::from_str(raw).map_err(|error| ValidationError(error.to_string()))?;
158        require(
159            [&config.model_family, &config.model_type, &config.file_type]
160                .iter()
161                .all(|text| {
162                    !text.is_empty() && text.len() <= 64 && !text.chars().any(char::is_control)
163                }),
164            "model config lacks family, size or file type",
165        )?;
166        Ok(config)
167    }
168}
169
170#[derive(Debug, Clone, PartialEq)]
171pub enum GgufValue {
172    Unsigned(u64),
173    Signed(i64),
174    Float(f64),
175    Bool(bool),
176    Text(String),
177    /// Arrays keep only their length; no admission fact needs their items.
178    Array(u64),
179    /// Text longer than 4 KiB (chat templates and similar), skipped unread.
180    Omitted,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum GgufError {
185    /// The header continues beyond the bytes read; fetch more and retry.
186    Truncated,
187    Invalid(&'static str),
188}
189
190#[derive(Debug, Clone)]
191pub struct Tensor {
192    pub name: String,
193    pub elements: u64,
194}
195
196#[derive(Debug, Clone)]
197pub struct Gguf {
198    metadata: BTreeMap<String, GgufValue>,
199    tensors: Vec<Tensor>,
200}
201
202#[derive(Clone, Copy)]
203enum Text {
204    Name,
205    Value,
206    Skip,
207}
208
209struct Cursor<'a> {
210    bytes: &'a [u8],
211    offset: usize,
212}
213impl Cursor<'_> {
214    fn take(&mut self, count: usize) -> Result<&[u8], GgufError> {
215        let end = self
216            .offset
217            .checked_add(count)
218            .ok_or(GgufError::Invalid("offset overflow"))?;
219        if end > MAX_GGUF_HEADER_BYTES {
220            return Err(GgufError::Invalid("GGUF header exceeds 64 MiB"));
221        }
222        let slice = self
223            .bytes
224            .get(self.offset..end)
225            .ok_or(GgufError::Truncated)?;
226        self.offset = end;
227        Ok(slice)
228    }
229    fn array<const N: usize>(&mut self) -> Result<[u8; N], GgufError> {
230        Ok(self.take(N)?.try_into().expect("exact length"))
231    }
232    fn u32(&mut self) -> Result<u32, GgufError> {
233        Ok(u32::from_le_bytes(self.array()?))
234    }
235    fn u64(&mut self) -> Result<u64, GgufError> {
236        Ok(u64::from_le_bytes(self.array()?))
237    }
238    /// Reads a GGUF string: names are capped at 1 KiB, values over 4 KiB are skipped.
239    fn text(&mut self, kind: Text) -> Result<Option<String>, GgufError> {
240        let length = self.u64()?;
241        if length > 16 * 1024 * 1024 {
242            return Err(GgufError::Invalid("GGUF string too long"));
243        }
244        let bytes = self.take(length as usize)?;
245        let keep = match kind {
246            Text::Name if bytes.len() > 1024 => {
247                return Err(GgufError::Invalid("GGUF name too long"));
248            }
249            Text::Name => true,
250            Text::Value => bytes.len() <= 4096,
251            Text::Skip => false,
252        };
253        if !keep {
254            return Ok(None);
255        }
256        String::from_utf8(bytes.to_vec())
257            .map(Some)
258            .map_err(|_| GgufError::Invalid("GGUF text is not UTF-8"))
259    }
260    fn scalar(&mut self, kind: u32) -> Result<GgufValue, GgufError> {
261        Ok(match kind {
262            0 => GgufValue::Unsigned(u64::from(self.array::<1>()?[0])),
263            1 => GgufValue::Signed(i64::from(i8::from_le_bytes(self.array()?))),
264            2 => GgufValue::Unsigned(u64::from(u16::from_le_bytes(self.array()?))),
265            3 => GgufValue::Signed(i64::from(i16::from_le_bytes(self.array()?))),
266            4 => GgufValue::Unsigned(u64::from(self.u32()?)),
267            5 => GgufValue::Signed(i64::from(i32::from_le_bytes(self.array()?))),
268            6 => GgufValue::Float(f64::from(f32::from_le_bytes(self.array()?))),
269            7 => GgufValue::Bool(self.array::<1>()?[0] != 0),
270            10 => GgufValue::Unsigned(self.u64()?),
271            11 => GgufValue::Signed(i64::from_le_bytes(self.array()?)),
272            12 => GgufValue::Float(f64::from_le_bytes(self.array()?)),
273            _ => return Err(GgufError::Invalid("unknown GGUF value type")),
274        })
275    }
276    fn value(&mut self, kind: u32, depth: u8) -> Result<GgufValue, GgufError> {
277        match kind {
278            8 => Ok(self
279                .text(Text::Value)?
280                .map_or(GgufValue::Omitted, GgufValue::Text)),
281            9 => {
282                if depth > 1 {
283                    return Err(GgufError::Invalid("nested GGUF arrays are too deep"));
284                }
285                let item = self.u32()?;
286                let length = self.u64()?;
287                if length > 64 * 1024 * 1024 {
288                    return Err(GgufError::Invalid("GGUF array too long"));
289                }
290                for _ in 0..length {
291                    match item {
292                        8 => {
293                            self.text(Text::Skip)?;
294                        }
295                        9 => {
296                            self.value(9, depth + 1)?;
297                        }
298                        other => {
299                            self.scalar(other)?;
300                        }
301                    }
302                }
303                Ok(GgufValue::Array(length))
304            }
305            other => self.scalar(other),
306        }
307    }
308}
309
310/// Parses GGUF v2/v3 metadata and the tensor table (names and element counts only).
311pub fn parse_gguf(bytes: &[u8]) -> Result<Gguf, GgufError> {
312    let mut cursor = Cursor { bytes, offset: 0 };
313    if cursor.take(4)? != b"GGUF" {
314        return Err(GgufError::Invalid("not a GGUF file"));
315    }
316    if !matches!(cursor.u32()?, 2 | 3) {
317        return Err(GgufError::Invalid("unsupported GGUF version"));
318    }
319    let tensor_count = cursor.u64()?;
320    let metadata_count = cursor.u64()?;
321    if tensor_count > 100_000 || metadata_count > 65_536 {
322        return Err(GgufError::Invalid("implausible GGUF counts"));
323    }
324    let mut metadata = BTreeMap::new();
325    for _ in 0..metadata_count {
326        let key = cursor.text(Text::Name)?.expect("names are kept");
327        let kind = cursor.u32()?;
328        let value = cursor.value(kind, 0)?;
329        if metadata.insert(key, value).is_some() {
330            return Err(GgufError::Invalid("duplicate GGUF metadata key"));
331        }
332    }
333    let mut tensors = Vec::new();
334    for _ in 0..tensor_count {
335        let name = cursor.text(Text::Name)?.expect("names are kept");
336        let dimensions = cursor.u32()?;
337        if !(1..=8).contains(&dimensions) {
338            return Err(GgufError::Invalid("invalid tensor rank"));
339        }
340        let mut elements: u64 = 1;
341        for _ in 0..dimensions {
342            elements = elements
343                .checked_mul(cursor.u64()?)
344                .ok_or(GgufError::Invalid("tensor size overflow"))?;
345        }
346        cursor.u32()?;
347        cursor.u64()?;
348        tensors.push(Tensor { name, elements });
349    }
350    Ok(Gguf { metadata, tensors })
351}
352
353impl Gguf {
354    pub fn architecture(&self) -> Option<&str> {
355        self.text("general.architecture")
356            .filter(|arch| repository_name(arch))
357    }
358    pub fn text(&self, key: &str) -> Option<&str> {
359        match self.metadata.get(key) {
360            Some(GgufValue::Text(text)) => Some(text),
361            _ => None,
362        }
363    }
364    pub fn u64(&self, key: &str) -> Option<u64> {
365        match self.metadata.get(key) {
366            Some(GgufValue::Unsigned(value)) => Some(*value),
367            Some(GgufValue::Signed(value)) => u64::try_from(*value).ok(),
368            _ => None,
369        }
370    }
371    fn arch_u64(&self, suffix: &str) -> Option<u64> {
372        self.u64(&format!("{}.{suffix}", self.architecture()?))
373    }
374    pub fn total_elements(&self) -> u64 {
375        self.tensors.iter().map(|tensor| tensor.elements).sum()
376    }
377}
378
379/// f16 KV-cache bytes per token for uniform full attention; `None` when the architecture
380/// mixes attention kinds, where the standard formula would fabricate a number.
381pub fn kv_bytes_per_token(gguf: &Gguf) -> Option<f64> {
382    let arch = gguf.architecture()?;
383    let prefix = format!("{arch}.");
384    let non_uniform = gguf.metadata.iter().any(|(key, value)| {
385        key.strip_prefix(&prefix).is_some_and(|suffix| {
386            suffix.starts_with("ssm.")
387                || suffix == "full_attention_interval"
388                || suffix.contains("sliding_window")
389                || suffix == "attention.kv_lora_rank"
390                || (suffix == "attention.head_count_kv" && matches!(value, GgufValue::Array(_)))
391        })
392    });
393    if non_uniform {
394        return None;
395    }
396    let layers = gguf.arch_u64("block_count")?;
397    let kv_heads = gguf.arch_u64("attention.head_count_kv")?;
398    let head_dim = || {
399        let heads = gguf.arch_u64("attention.head_count")?;
400        let width = gguf.arch_u64("embedding_length")?;
401        (heads > 0 && width % heads == 0).then(|| width / heads)
402    };
403    let key = gguf.arch_u64("attention.key_length").or_else(head_dim)?;
404    let value = gguf.arch_u64("attention.value_length").or_else(head_dim)?;
405    let bytes = layers
406        .checked_mul(kv_heads)?
407        .checked_mul(key.checked_add(value)?)?
408        .checked_mul(2)?;
409    (bytes > 0).then_some(bytes as f64)
410}
411
412/// Active parameters per token for MoE: shared tensors plus the routed share of expert tensors.
413pub fn active_params(gguf: &Gguf) -> Option<f64> {
414    let experts = gguf.arch_u64("expert_count").filter(|count| *count > 0)?;
415    let used = gguf
416        .arch_u64("expert_used_count")
417        .filter(|used| *used > 0 && *used <= experts)?;
418    let routed: u64 = gguf
419        .tensors
420        .iter()
421        .filter(|tensor| tensor.name.contains("_exps."))
422        .map(|tensor| tensor.elements)
423        .sum();
424    if routed == 0 {
425        return None;
426    }
427    let shared = gguf.total_elements() - routed;
428    Some(shared as f64 + routed as f64 * used as f64 / experts as f64)
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum LicenseError {
433    Missing,
434    Unrecognised,
435    Conflict,
436}
437
438fn gguf_license(id: &str, name: Option<&str>) -> Option<&'static str> {
439    let id = id.trim().to_ascii_lowercase();
440    let mapped = match id.as_str() {
441        "apache-2.0" | "apache 2.0" => "apache-2.0",
442        "mit" => "mit",
443        "bsd-3-clause" => "bsd-3-clause",
444        "cc-by-4.0" => "cc-by-4.0",
445        "cc-by-sa-4.0" => "cc-by-sa-4.0",
446        "gemma" => "gemma",
447        "llama2" => "llama-2-community",
448        "llama3" => "llama-3-community",
449        "llama3.1" => "llama-3.1-community",
450        "llama3.2" => "llama-3.2-community",
451        "llama3.3" => "llama-3.3-community",
452        "other" => return name.and_then(|name| gguf_license(name, None)),
453        _ => return None,
454    };
455    LICENSES.contains(&mapped).then_some(mapped)
456}
457
458fn blob_license(head: &str) -> Option<&'static str> {
459    let text = head
460        .split_whitespace()
461        .collect::<Vec<_>>()
462        .join(" ")
463        .to_ascii_lowercase();
464    let opening: String = text.chars().take(120).collect();
465    let starts = |prefix: &str| text.starts_with(prefix);
466    if starts("apache license") && opening.contains("2.0") {
467        Some("apache-2.0")
468    } else if (starts("mit license") || starts("copyright"))
469        && text.contains("permission is hereby granted, free of charge")
470    {
471        Some("mit")
472    } else if starts("llama 3.3 community license agreement") {
473        Some("llama-3.3-community")
474    } else if starts("llama 3.2 community license agreement") {
475        Some("llama-3.2-community")
476    } else if starts("llama 3.1 community license agreement") {
477        Some("llama-3.1-community")
478    } else if starts("meta llama 3 community license agreement") {
479        Some("llama-3-community")
480    } else if starts("llama 2 community license agreement") {
481        Some("llama-2-community")
482    } else if starts("gemma terms of use") {
483        Some("gemma")
484    } else {
485        None
486    }
487}
488
489/// The catalog license id, accepted only when sourced, open and unambiguous.
490pub fn license_id(
491    gguf: Option<&str>,
492    gguf_name: Option<&str>,
493    blob_head: Option<&str>,
494) -> Result<&'static str, LicenseError> {
495    let from_gguf = gguf.map(|id| gguf_license(id, gguf_name));
496    let from_blob = blob_head
497        .filter(|head| !head.trim().is_empty())
498        .map(blob_license);
499    match (from_gguf, from_blob) {
500        (Some(Some(left)), Some(Some(right))) if left == right => Ok(left),
501        (Some(Some(_)), Some(Some(_))) => Err(LicenseError::Conflict),
502        (Some(Some(id)), None) | (None, Some(Some(id))) => Ok(id),
503        (Some(None), _) | (_, Some(None)) => Err(LicenseError::Unrecognised),
504        (None, None) => Err(LicenseError::Missing),
505    }
506}
507
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct Rejection(&'static str);
510impl Rejection {
511    pub fn reason(&self) -> &'static str {
512        self.0
513    }
514}
515
516/// Catalog capabilities from listing chips: `chat` unless embedding-only, then chips in catalog order.
517pub fn catalog_capabilities(chips: &[String]) -> Vec<String> {
518    let mut capabilities: Vec<String> = Vec::new();
519    if !chips.iter().any(|cap| cap == "embedding") {
520        capabilities.push("chat".into());
521    }
522    for known in CAPABILITIES {
523        if known != "chat" && chips.iter().any(|cap| cap == known) {
524            capabilities.push(known.into());
525        }
526    }
527    capabilities
528}
529
530pub struct AdmissionInput<'a> {
531    pub repository: &'a str,
532    pub tag: &'a str,
533    pub capabilities: &'a [String],
534    pub layer: &'a ModelLayer,
535    pub config: &'a RegistryConfig,
536    pub gguf: &'a Gguf,
537    pub license_head: Option<&'a str>,
538    pub today: &'a str,
539}
540
541pub(crate) fn parameter_label(count: f64) -> String {
542    let (value, unit) = if count >= 1e12 {
543        (count / 1e12, "T")
544    } else if count >= 1e9 {
545        (count / 1e9, "B")
546    } else {
547        (count / 1e6, "M")
548    };
549    let text = format!("{value:.1}");
550    format!("{}{unit}", text.strip_suffix(".0").unwrap_or(&text))
551}
552
553/// Builds a fully sourced `provenance: auto` entry, or explains why the variant is skipped.
554pub fn build_entry(input: &AdmissionInput) -> Result<CatalogModel, Rejection> {
555    let id = format!("{}:{}", input.repository, input.tag);
556    let config = input.config;
557    let gguf = input.gguf;
558    let architecture = gguf
559        .architecture()
560        .ok_or(Rejection("GGUF architecture missing"))?;
561    static PARAMS: OnceLock<Regex> = OnceLock::new();
562    let params = config.model_type.trim();
563    if !pattern(&PARAMS, r"^\d+(\.\d+)?[BMT]$").is_match(params) {
564        return Err(Rejection("parameter count not sourced"));
565    }
566    let count = parse_param_count(params).map_err(|_| Rejection("parameter count not sourced"))?;
567    let tensors = gguf.total_elements() as f64;
568    // Registry labels are rounded ("7B" for 6.74B): allow half of the last printed digit.
569    let decimals = params[..params.len() - 1]
570        .split_once('.')
571        .map_or(0, |(_, fraction)| fraction.len());
572    let unit = count / params[..params.len() - 1].parse::<f64>().unwrap_or(1.0);
573    let rounding = 0.5 * unit / 10f64.powi(decimals as i32);
574    if (tensors - count).abs() > (count * PARAMETER_TOLERANCE).max(rounding) {
575        return Err(Rejection("parameter count disagrees with tensor table"));
576    }
577    let license = license_id(
578        gguf.text("general.license"),
579        gguf.text("general.license.name"),
580        input.license_head,
581    )
582    .map_err(|error| {
583        Rejection(match error {
584            LicenseError::Missing => "license missing",
585            LicenseError::Unrecognised => "license not recognised as open",
586            LicenseError::Conflict => "license sources disagree",
587        })
588    })?;
589    let context = gguf
590        .u64(&format!("{architecture}.context_length"))
591        .filter(|tokens| *tokens > 0)
592        .ok_or(Rejection("context length missing"))?;
593    let (architecture_kind, active) = if gguf
594        .u64(&format!("{architecture}.expert_count"))
595        .is_some_and(|experts| experts > 0)
596    {
597        let active = active_params(gguf).ok_or(Rejection("MoE active parameters not sourced"))?;
598        (Architecture::Moe, Some(parameter_label(active)))
599    } else {
600        (Architecture::Dense, None)
601    };
602    let capabilities = catalog_capabilities(input.capabilities);
603    let quant = sized_quantization(
604        count,
605        &architecture_kind,
606        &config.file_type.to_ascii_uppercase(),
607        input.layer.disk_bytes,
608        Some(input.layer.sha256.clone()),
609        input.layer.projectors.clone(),
610    )
611    .map_err(|_| Rejection("quantization cannot be sized"))?;
612    let entry = CatalogModel {
613        id: strip_control(&id),
614        family: strip_control(&config.model_family),
615        params: params.into(),
616        architecture: architecture_kind,
617        active_params: active,
618        license: license.into(),
619        open_weight: true,
620        context_length: context as f64,
621        capabilities,
622        release_date: None,
623        added_at: Some(input.today.into()),
624        provenance: EntryProvenance::Auto,
625        source: Source {
626            ollama: Some(strip_control(&id)),
627            ..Default::default()
628        },
629        quantizations: vec![quant],
630        kv_bytes_per_token: kv_bytes_per_token(gguf),
631        benchmark_proxy: None,
632    };
633    let encoded = json!({"schemaVersion":crate::catalog::SCHEMA_VERSION,"generatedAt":"2026-01-01T00:00:00.000Z","models":[entry]});
634    crate::catalog::Catalog::parse(&encoded.to_string())
635        .map_err(|_| Rejection("entry fails catalog validation"))?;
636    Ok(entry)
637}
638
639/// The quality-gate observation for an entry, citing exactly the reads that sourced it.
640pub fn observation(entry: &CatalogModel, checked_at: &str) -> Value {
641    let reference = entry.source.ollama.as_deref().unwrap_or(&entry.id);
642    let (repository, tag) = crate::registry_collector::parse_reference(reference);
643    let path = if repository.contains('/') {
644        repository.to_string()
645    } else {
646        format!("library/{repository}")
647    };
648    json!({
649        "id": entry.id,
650        "checkedAt": checked_at,
651        "sources": [
652            format!("https://registry.ollama.ai/v2/{path}/manifests/{tag}"),
653            format!("https://ollama.com/{path}:{tag}"),
654        ],
655        "model": entry,
656    })
657}