1use crate::{
5 catalog::{
6 EntryProvenance, LICENSES, RecencyBasis, coordinates, date, digest, model_file,
7 parameter_label, parse_document, recency, recency_label, require, timestamp,
8 },
9 reports::{strip_control, table},
10 sizing::{HEADROOM, Hardware, ValidationError, memory_capacity},
11};
12use serde::{Deserialize, Serialize};
13use std::collections::HashSet;
14
15const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum GenerationKind {
20 Image,
21 Video,
22}
23impl GenerationKind {
24 pub fn name(self) -> &'static str {
25 match self {
26 Self::Image => "image",
27 Self::Video => "video",
28 }
29 }
30 pub fn parse(value: &str) -> Option<Self> {
31 match value {
32 "image" => Some(Self::Image),
33 "video" => Some(Self::Video),
34 _ => None,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum Workflow {
43 FluxCheckpoint,
44 FluxSplit,
45 WanT2v,
46 #[serde(rename = "wan22-ti2v")]
48 Wan22Ti2v,
49 QwenImage,
51 MingDesign,
53}
54impl Workflow {
55 pub fn kind(self) -> GenerationKind {
56 match self {
57 Self::FluxCheckpoint | Self::FluxSplit | Self::QwenImage | Self::MingDesign => {
58 GenerationKind::Image
59 }
60 Self::WanT2v | Self::Wan22Ti2v => GenerationKind::Video,
61 }
62 }
63 pub fn roles(self) -> &'static [FileRole] {
65 match self {
66 Self::FluxCheckpoint => &[FileRole::Checkpoint],
67 Self::FluxSplit => &[
68 FileRole::Diffusion,
69 FileRole::TextEncoder,
70 FileRole::Clip,
71 FileRole::Vae,
72 ],
73 Self::WanT2v | Self::Wan22Ti2v | Self::QwenImage | Self::MingDesign => {
74 &[FileRole::Diffusion, FileRole::TextEncoder, FileRole::Vae]
75 }
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum FileRole {
83 Checkpoint,
84 Diffusion,
85 TextEncoder,
86 Clip,
87 Vae,
88}
89impl FileRole {
90 pub fn folder(self) -> &'static str {
92 match self {
93 Self::Checkpoint => "checkpoints",
94 Self::Diffusion => "diffusion_models",
95 Self::TextEncoder => "text_encoders",
96 Self::Clip => "text_encoders",
97 Self::Vae => "vae",
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
103#[serde(rename_all = "camelCase", deny_unknown_fields)]
104pub struct GenerationFile {
105 pub role: FileRole,
106 pub folder: String,
107 pub repo: String,
108 pub revision: String,
109 pub file: String,
110 pub sha256: String,
111 pub bytes: u64,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
115#[serde(rename_all = "camelCase", deny_unknown_fields)]
116pub struct GenerationModel {
117 pub id: String,
118 pub family: String,
119 pub kind: GenerationKind,
120 pub params: String,
121 pub license: String,
122 pub open_weight: bool,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub release_date: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub added_at: Option<String>,
127 #[serde(default, skip_serializing_if = "EntryProvenance::is_curated")]
128 pub provenance: EntryProvenance,
129 #[serde(default)]
130 pub default: bool,
131 pub source: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub workflow: Option<Workflow>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub workflow_source: Option<String>,
137 pub files: Vec<GenerationFile>,
138}
139
140pub const GENERATION_SCHEMA_VERSION: u8 = 2;
141
142#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
143#[serde(rename_all = "camelCase", deny_unknown_fields)]
144pub struct GenerationCatalog {
145 pub schema_version: u8,
146 pub generated_at: String,
147 pub models: Vec<GenerationModel>,
148}
149
150fn https(value: &str) -> Result<(), ValidationError> {
151 require(
152 value.len() <= 2048
153 && value.starts_with("https://")
154 && value.len() > "https://".len()
155 && !value
156 .chars()
157 .any(|ch| ch.is_control() || ch.is_whitespace()),
158 "source links must be HTTPS",
159 )
160}
161fn model_id(value: &str) -> bool {
162 !value.is_empty()
163 && value.len() <= 128
164 && value.as_bytes()[0].is_ascii_alphanumeric()
165 && value.bytes().all(|byte| {
166 byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-:".contains(&byte)
167 })
168 && GenerationKind::parse(value).is_none()
169}
170
171impl GenerationModel {
172 pub fn recency(&self) -> Option<(&str, RecencyBasis)> {
173 recency(self.release_date.as_deref(), self.added_at.as_deref())
174 }
175 pub fn recency_label(&self) -> String {
176 recency_label(self.recency())
177 }
178 pub fn runnable(&self) -> Result<Workflow, ValidationError> {
180 self.workflow.ok_or_else(|| {
181 ValidationError(format!(
182 "{} is fit-only (workflow coming): RigSpark has no built-in ComfyUI workflow for this architecture yet",
183 self.id
184 ))
185 })
186 }
187 pub fn total_bytes(&self) -> u64 {
188 self.files.iter().map(|file| file.bytes).sum()
189 }
190 pub fn largest_bytes(&self) -> u64 {
191 self.files.iter().map(|file| file.bytes).max().unwrap_or(0)
192 }
193 pub fn file(&self, role: FileRole) -> Option<&GenerationFile> {
194 self.files.iter().find(|file| file.role == role)
195 }
196 fn validate(&self) -> Result<(), ValidationError> {
197 require(model_id(&self.id), "invalid generation model id")?;
198 require(
199 !self.family.is_empty() && self.family.len() <= 128,
200 "empty text field",
201 )?;
202 require(parameter_label(&self.params), "invalid parameter label")?;
203 require(
204 self.open_weight && LICENSES.contains(&self.license.as_str()),
205 "generation catalog requires an allowlisted open-weight license",
206 )?;
207 if let Some(released) = &self.release_date {
208 date(released)?;
209 }
210 if let Some(added) = &self.added_at {
211 date(added)?;
212 }
213 match self.provenance {
214 EntryProvenance::Curated => require(
215 self.release_date.is_some() && self.workflow.is_some(),
216 "curated generation entries need releaseDate and a workflow",
217 )?,
218 EntryProvenance::Auto => require(
219 self.added_at.is_some(),
220 "auto generation entries need addedAt",
221 )?,
222 }
223 https(&self.source)?;
224 require(
225 self.workflow.is_some() == self.workflow_source.is_some(),
226 "workflowSource must accompany a workflow",
227 )?;
228 let roles: Vec<_> = self.files.iter().map(|file| file.role).collect();
229 match (self.workflow, &self.workflow_source) {
230 (Some(workflow), Some(source)) => {
231 https(source)?;
232 require(
233 roles.len() == workflow.roles().len()
234 && workflow.roles().iter().all(|role| roles.contains(role)),
235 "files must match the workflow roles exactly",
236 )?;
237 require(
238 workflow.kind() == self.kind,
239 "workflow does not produce this model kind",
240 )?;
241 }
242 _ => require(
243 !roles.is_empty()
244 && roles.len() <= 8
245 && roles.iter().collect::<HashSet<_>>().len() == roles.len(),
246 "fit-only entries need sourced files with distinct roles",
247 )?,
248 }
249 let mut total = 0u64;
250 for file in &self.files {
251 require(
252 file.folder == file.role.folder(),
253 "file folder does not match its role",
254 )?;
255 coordinates(&file.repo, &file.revision)?;
256 model_file(&file.file, 512)?;
257 require(
258 file.file.ends_with(".safetensors"),
259 "generation weights must be .safetensors files",
260 )?;
261 digest(&file.sha256)?;
262 require(
263 file.bytes > 0 && file.bytes <= MAX_SAFE_INTEGER,
264 "invalid weight file size",
265 )?;
266 total = total
267 .checked_add(file.bytes)
268 .filter(|total| *total <= MAX_SAFE_INTEGER)
269 .ok_or_else(|| ValidationError("invalid weight file size".into()))?;
270 }
271 Ok(())
272 }
273}
274
275impl GenerationCatalog {
276 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
277 let mut result: Self = parse_document(raw)?;
278 require(
279 (1..=GENERATION_SCHEMA_VERSION).contains(&result.schema_version)
280 && !result.models.is_empty()
281 && result.models.len() <= 1024,
282 "unsupported or empty generation catalog",
283 )?;
284 timestamp(&result.generated_at)?;
285 let mut ids = HashSet::new();
286 let mut defaults = HashSet::new();
287 let mut kinds = HashSet::new();
288 for model in &result.models {
289 require(
290 result.schema_version >= 2
291 || (model.provenance.is_curated() && model.added_at.is_none()),
292 "schema v1 generation catalogs cannot carry v2 fields",
293 )?;
294 model.validate()?;
295 require(
296 !model.default || model.workflow.is_some(),
297 "a default model must be runnable",
298 )?;
299 require(ids.insert(&model.id), "duplicate generation model id")?;
300 kinds.insert(model.kind);
301 if model.default {
302 require(
303 defaults.insert(model.kind),
304 "each kind needs exactly one default model",
305 )?;
306 }
307 }
308 require(
309 kinds == defaults,
310 "each kind needs exactly one default model",
311 )?;
312 for model in &mut result.models {
313 model.family = strip_control(&model.family);
314 }
315 Ok(result)
316 }
317 pub fn bundled() -> Result<Self, ValidationError> {
318 Self::parse(crate::GENERATION_JSON)
319 }
320 pub fn resolve(&self, query: &str) -> Result<&GenerationModel, ValidationError> {
322 let found = match GenerationKind::parse(query) {
323 Some(kind) => self
324 .models
325 .iter()
326 .find(|model| model.kind == kind && model.default),
327 None => self.models.iter().find(|model| model.id == query),
328 };
329 found.ok_or_else(|| {
330 ValidationError(
331 "unknown generation model; use image, video, or an id from `rigspark catalog --generation`"
332 .into(),
333 )
334 })
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
339#[serde(rename_all = "camelCase")]
340pub struct RejectedGenerationCandidate {
341 pub id: String,
342 pub reason: String,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
346#[serde(rename_all = "camelCase")]
347pub struct GenerationEnrichment {
348 pub catalog: GenerationCatalog,
349 pub updated: Vec<String>,
350 pub rejected: Vec<RejectedGenerationCandidate>,
351}
352
353fn built_in_family(model: &GenerationModel) -> bool {
354 matches!(
355 (model.kind, model.workflow, model.family.as_str()),
356 (
357 GenerationKind::Image,
358 Some(Workflow::FluxCheckpoint | Workflow::FluxSplit),
359 "flux1-schnell"
360 ) | (GenerationKind::Video, Some(Workflow::WanT2v), "wan2.1-t2v")
361 )
362}
363
364pub fn enrich_generation_catalog(
369 catalog: &GenerationCatalog,
370 candidates: Vec<GenerationModel>,
371 kind: GenerationKind,
372 now: &str,
373) -> Result<GenerationEnrichment, ValidationError> {
374 timestamp(now)?;
375 let mut next = catalog.clone();
376 let mut updated = Vec::new();
377 let mut rejected = Vec::new();
378
379 for mut candidate in candidates {
380 let reason = if candidate.kind != kind {
381 Some("candidate does not match the requested kind".into())
382 } else if !built_in_family(&candidate) {
383 Some("candidate has no built-in workflow family".into())
384 } else {
385 candidate.validate().err().map(|error| error.to_string())
386 };
387 if let Some(reason) = reason {
388 rejected.push(RejectedGenerationCandidate {
389 id: strip_control(&candidate.id),
390 reason,
391 });
392 continue;
393 }
394
395 if let Some(existing) = next
396 .models
397 .iter_mut()
398 .find(|model| model.id == candidate.id)
399 {
400 candidate.default = existing.default;
401 if *existing != candidate {
402 updated.push(candidate.id.clone());
403 *existing = candidate;
404 }
405 } else {
406 updated.push(candidate.id.clone());
407 next.models.push(candidate);
408 }
409 }
410
411 updated.sort();
412 updated.dedup();
413 rejected.sort_by(|left, right| left.id.cmp(&right.id));
414 if !updated.is_empty() {
415 next.generated_at = now.into();
416 next.models.sort_by(|left, right| left.id.cmp(&right.id));
417 next = GenerationCatalog::parse(
418 &serde_json::to_string(&next)
419 .map_err(|_| ValidationError("invalid generation catalog".into()))?,
420 )?;
421 }
422 Ok(GenerationEnrichment {
423 catalog: next,
424 updated,
425 rejected,
426 })
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
430#[serde(rename_all = "lowercase")]
431pub enum FitVerdict {
432 Yes,
433 Slow,
434 No,
435}
436impl FitVerdict {
437 pub fn name(self) -> &'static str {
438 match self {
439 Self::Yes => "yes",
440 Self::Slow => "slow",
441 Self::No => "no",
442 }
443 }
444}
445
446#[derive(Debug, Clone, Serialize)]
448#[serde(rename_all = "camelCase")]
449pub struct GenerationFit {
450 pub verdict: FitVerdict,
451 pub reason: &'static str,
452 pub memory_kind: &'static str,
453 pub usable_bytes: f64,
454 pub budget_bytes: f64,
455 pub total_bytes: u64,
456 pub largest_bytes: u64,
457 pub speed: &'static str,
459}
460
461fn system_ram_budget(hardware: &Hardware) -> f64 {
462 let mut cpu = hardware.clone();
463 cpu.gpu.clear();
464 cpu.unified_memory = Some(false);
465 memory_capacity(&cpu).1 * (1.0 - HEADROOM)
466}
467
468pub fn fit(model: &GenerationModel, hardware: &Hardware) -> GenerationFit {
469 let (memory_kind, usable_bytes) = memory_capacity(hardware);
470 let budget_bytes = usable_bytes * (1.0 - HEADROOM);
471 let total_bytes = model.total_bytes();
472 let largest_bytes = model.largest_bytes();
473 let (verdict, reason) = if total_bytes as f64 <= budget_bytes {
474 (FitVerdict::Yes, "all weights fit in memory together")
475 } else if largest_bytes as f64 <= budget_bytes {
476 (
477 FitVerdict::Slow,
478 "weights fit one stage at a time; ComfyUI swaps models between stages",
479 )
480 } else if memory_kind == "vram" && largest_bytes as f64 <= system_ram_budget(hardware) {
481 (
482 FitVerdict::Slow,
483 "largest weight file exceeds VRAM; ComfyUI offloads to system RAM",
484 )
485 } else {
486 (FitVerdict::No, "largest weight file exceeds usable memory")
487 };
488 GenerationFit {
489 verdict,
490 reason,
491 memory_kind,
492 usable_bytes,
493 budget_bytes,
494 total_bytes,
495 largest_bytes,
496 speed: "unknown",
497 }
498}
499
500pub fn catalog_text(catalog: &GenerationCatalog, hardware: &Hardware) -> String {
501 let mut models: Vec<_> = catalog.models.iter().collect();
502 models.sort_by(|left, right| {
503 left.kind
504 .name()
505 .cmp(right.kind.name())
506 .then_with(|| right.release_date.cmp(&left.release_date))
507 .then_with(|| left.id.cmp(&right.id))
508 });
509 let rows = models
510 .into_iter()
511 .map(|model| {
512 let fit = fit(model, hardware);
513 vec![
514 model.id.clone(),
515 model.kind.name().into(),
516 model.params.clone(),
517 format!("{:.1}", model.total_bytes() as f64 / 1073741824.0),
518 fit.verdict.name().into(),
519 fit.speed.into(),
520 model.license.clone(),
521 model.recency_label(),
522 if model.workflow.is_some() {
523 "runs".into()
524 } else {
525 "workflow coming".into()
526 },
527 ]
528 })
529 .collect();
530 format!(
531 "Generation catalog (runtime: ComfyUI, local only; models: {})\n{}\n\
532 Fit covers weight memory only; generation speed is unknown (no sourced data).\n\
533 Run: rigspark generate image --prompt \"...\" --comfyui-dir <ComfyUI directory>\n",
534 catalog.models.len(),
535 table(
536 &[
537 ("Model", false),
538 ("Kind", false),
539 ("Params", false),
540 ("Weights GiB", true),
541 ("Fit", false),
542 ("Speed", false),
543 ("License", false),
544 ("Release", false),
545 ("Generate", false),
546 ],
547 rows,
548 )
549 )
550}