1use 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 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
51pub 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
102pub 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
122pub 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 Array(u64),
179 Omitted,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum GgufError {
185 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 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
310pub 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
379pub 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
412pub 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
489pub 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
516pub 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
553pub 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 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
639pub 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}