Skip to main content

mold_core/
generation_profile.rs

1//! Canonical, versioned generation-control profiles.
2//!
3//! A profile is the one model/recipe authority consumed by admission, Rust
4//! clients, and `/api/models`. Browser clients receive the same fully-resolved
5//! recipes and never reconstruct family policy. The legacy flattened model
6//! defaults remain a derived compatibility view for one release.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::{
12    validation, GuidanceCapabilities, Ltx2PipelineMode, OutputFormat, Scheduler,
13    SourceImageCapability,
14};
15
16pub const GENERATION_PROFILE_SCHEMA_VERSION: u32 = 1;
17
18#[derive(
19    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
20)]
21#[serde(rename_all = "kebab-case")]
22pub enum ResolutionDomain {
23    Dynamic,
24    Buckets,
25    SourceDriven,
26    None,
27}
28
29/// What an off-bucket resolution means for a `Buckets`-domain recipe.
30///
31/// `Reject` keeps the historical fail-closed behaviour (H3's reviewed
32/// runtime genuinely refuses off-bucket shapes). `Warn` admits any size
33/// that clears the alignment/limit/aspect gates — the buckets are the
34/// trained sizes the model is optimized for, not the only runnable ones —
35/// and the advisory dimension-warning channel tells the user results may
36/// vary. Absent on the wire means `Reject`, so older profiles keep today's
37/// semantics and clients fail closed against older servers.
38#[derive(
39    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
40)]
41#[serde(rename_all = "kebab-case")]
42#[ts(rename_all = "kebab-case")]
43pub enum OffBucketPolicy {
44    #[default]
45    Reject,
46    Warn,
47}
48
49#[derive(
50    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
51)]
52#[serde(rename_all = "kebab-case")]
53pub enum ControlMode {
54    Adjustable,
55    Fixed,
56    Hidden,
57}
58
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
61)]
62#[serde(rename_all = "kebab-case")]
63pub enum ProvenanceKind {
64    Upstream,
65    MoldPolicy,
66    Derived,
67    DeliveryLimit,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
71pub struct ProfileProvenance {
72    pub kind: ProvenanceKind,
73    pub source: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub revision: Option<String>,
76    pub qualified: bool,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub evidence: Option<String>,
79}
80
81/// Server-side qualification record for upstream resolution candidates.
82///
83/// `qualified` means the dimensions may be presented as recommendations; it
84/// is not a per-size runtime-performance claim. A dynamic family can qualify
85/// a pinned upstream oracle when Mold's alignment, pixel admission, and image
86/// delivery paths are resolution-generic. Bucketed or size-sensitive families
87/// additionally need a checked-in exact-size generation-and-delivery campaign.
88/// The generator keeps the evidence visible so those two qualification paths
89/// cannot be conflated.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct ResolutionQualificationRecord {
92    pub family: &'static str,
93    pub source: &'static str,
94    pub revision: &'static str,
95    pub qualified: bool,
96    pub evidence: &'static str,
97    pub candidates: &'static [(u32, u32)],
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
101#[ts(rename = "ProfileResolutionPreset")]
102pub struct ResolutionPreset {
103    pub id: String,
104    pub width: u32,
105    pub height: u32,
106    pub tier: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
110#[ts(rename = "ProfileAspectGroup")]
111pub struct AspectGroup {
112    pub id: String,
113    pub label: String,
114    pub presets: Vec<ResolutionPreset>,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
118pub struct ResolutionProfile {
119    pub domain: ResolutionDomain,
120    pub alignment: u32,
121    pub min_width: u32,
122    pub min_height: u32,
123    #[ts(type = "number")]
124    pub max_pixels: u64,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub max_axis_pixels: Option<u32>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub min_aspect_ratio: Option<f64>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub max_aspect_ratio: Option<f64>,
131    /// Only meaningful for the `Buckets` domain; see [`OffBucketPolicy`].
132    /// Absent on the wire (older servers and profiles) means `Reject`.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub off_bucket: Option<OffBucketPolicy>,
135    pub aspect_groups: Vec<AspectGroup>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
139pub struct IntegerControl {
140    pub default: u32,
141    pub min: u32,
142    pub max: u32,
143    pub step: u32,
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub recommended: Vec<u32>,
146    pub mode: ControlMode,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
150pub struct FloatControl {
151    pub default: f64,
152    pub min: f64,
153    pub max: f64,
154    pub step: f64,
155    pub mode: ControlMode,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
159#[serde(tag = "mode", rename_all = "kebab-case")]
160#[ts(rename = "ProfileFpsControl")]
161pub enum FpsControl {
162    Fixed {
163        value: u32,
164    },
165    Adjustable {
166        default: u32,
167        min: u32,
168        max: u32,
169        step: u32,
170    },
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
174pub struct TemporalProfile {
175    pub frames: IntegerControl,
176    pub frame_offset: u32,
177    pub fps: FpsControl,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub max_duration_seconds: Option<u32>,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
183pub struct GenerationDefaultsProfile {
184    pub width: u32,
185    pub height: u32,
186    pub steps: u32,
187    pub guidance: f64,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub frames: Option<u32>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub fps: Option<u32>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub negative_prompt: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
197pub struct RecipeSelector {
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub pipeline: Option<Ltx2PipelineMode>,
200}
201
202/// A non-numeric request field's complete UI/admission contract.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
204pub struct FeatureControlProfile {
205    pub mode: ControlMode,
206    pub required: bool,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub reason: Option<String>,
209}
210
211/// A repeatable adapter input and its immutable stack limit.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
213pub struct AdapterControlProfile {
214    pub mode: ControlMode,
215    pub max_count: u32,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub reason: Option<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
221pub struct OutputCapabilitiesProfile {
222    pub default_format: OutputFormat,
223    pub formats: Vec<OutputFormat>,
224    pub audio_requires_mp4: bool,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub delivery_reason: Option<String>,
227}
228
229/// Delivery encoders linked into a concrete Mold binary.
230///
231/// The authored registry describes the complete Mold-qualified contract. Each
232/// executable narrows that contract to what it can actually deliver before it
233/// advertises, renders, or validates a request.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct GenerationDeliveryCapabilities {
236    pub mp4: bool,
237    pub webp: bool,
238}
239
240impl GenerationDeliveryCapabilities {
241    pub const fn new(mp4: bool, webp: bool) -> Self {
242        Self { mp4, webp }
243    }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
247pub struct WanRecipeCapabilitiesProfile {
248    pub mode: ControlMode,
249    pub supports_distill_strength: bool,
250    pub supports_first_last_frame: bool,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub first_last_frame_min_frames: Option<u32>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub reason: Option<String>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
258pub struct GenerationCapabilitiesProfile {
259    pub guidance: GuidanceCapabilities,
260    pub negative_prompt: FeatureControlProfile,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub source_image: Option<SourceImageCapability>,
263    pub supports_lora: bool,
264    pub supports_controlnet: bool,
265    pub supports_sequence: bool,
266    pub supports_extend: bool,
267    pub supports_audio: bool,
268    pub source_video: FeatureControlProfile,
269    pub mask: FeatureControlProfile,
270    pub keyframes: FeatureControlProfile,
271    pub audio: FeatureControlProfile,
272    pub lora: AdapterControlProfile,
273    pub controlnet: AdapterControlProfile,
274    pub output: OutputCapabilitiesProfile,
275    pub wan_recipe: WanRecipeCapabilitiesProfile,
276    #[serde(default, skip_serializing_if = "Vec::is_empty")]
277    pub schedulers: Vec<Scheduler>,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
281pub struct GenerationRecipeProfile {
282    pub id: String,
283    pub label: String,
284    pub request_selector: RecipeSelector,
285    pub defaults: GenerationDefaultsProfile,
286    pub resolution: ResolutionProfile,
287    pub steps: IntegerControl,
288    pub guidance: FloatControl,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub temporal: Option<TemporalProfile>,
291    pub capabilities: GenerationCapabilitiesProfile,
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub provenance: Vec<ProfileProvenance>,
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
297pub struct GenerationProfileSet {
298    pub schema_version: u32,
299    pub profile_id: String,
300    pub profile_hash: String,
301    pub default_recipe_id: String,
302    pub recipes: Vec<GenerationRecipeProfile>,
303}
304
305impl GenerationProfileSet {
306    pub fn default_recipe(&self) -> Option<&GenerationRecipeProfile> {
307        self.recipes
308            .iter()
309            .find(|recipe| recipe.id == self.default_recipe_id)
310    }
311
312    pub fn recipe_for_pipeline(
313        &self,
314        pipeline: Option<Ltx2PipelineMode>,
315    ) -> Option<&GenerationRecipeProfile> {
316        match pipeline {
317            Some(pipeline) => self
318                .recipes
319                .iter()
320                .find(|recipe| recipe.request_selector.pipeline == Some(pipeline)),
321            None => self.default_recipe(),
322        }
323    }
324
325    /// Recompute the content address after a server-side runtime probe refines
326    /// an advertised capability. The profile hash must describe the exact
327    /// contract on the wire, not the pre-probe manifest approximation.
328    pub fn refresh_hash(&mut self) {
329        self.profile_hash.clear();
330        let encoded = serde_json::to_vec(self).expect("generation profile must serialize");
331        self.profile_hash = format!("{:x}", Sha256::digest(encoded));
332    }
333}
334
335/// Narrow an authored profile to the delivery encoders in a concrete binary.
336///
337/// This is intentionally shared by server and local surfaces so neither can
338/// advertise or accept a format the executing binary cannot encode. Recipes
339/// with no viable delivery format remain unavailable; they are never silently
340/// redirected to an unrelated container.
341pub fn qualify_generation_profile_delivery(
342    profile: &mut GenerationProfileSet,
343    delivery: GenerationDeliveryCapabilities,
344) {
345    for recipe in &mut profile.recipes {
346        recipe
347            .capabilities
348            .output
349            .formats
350            .retain(|format| match format {
351                OutputFormat::Mp4 => delivery.mp4,
352                OutputFormat::Webp => delivery.webp,
353                _ => true,
354            });
355        if !recipe
356            .capabilities
357            .output
358            .formats
359            .contains(&recipe.capabilities.output.default_format)
360        {
361            if let Some(format) = recipe.capabilities.output.formats.first().copied() {
362                recipe.capabilities.output.default_format = format;
363            }
364        }
365        if recipe.capabilities.output.audio_requires_mp4 && !delivery.mp4 {
366            recipe.capabilities.supports_audio = false;
367        }
368    }
369
370    profile
371        .recipes
372        .retain(|recipe| !recipe.capabilities.output.formats.is_empty());
373    if !profile
374        .recipes
375        .iter()
376        .any(|recipe| recipe.id == profile.default_recipe_id)
377    {
378        profile.default_recipe_id = profile
379            .recipes
380            .first()
381            .map(|recipe| recipe.id.clone())
382            .unwrap_or_default();
383    }
384    profile.refresh_hash();
385}
386
387/// Resolve the profile-owned output default for a concrete request selector.
388pub fn generation_profile_default_output_format(
389    profile: &GenerationProfileSet,
390    pipeline: Option<Ltx2PipelineMode>,
391) -> Result<OutputFormat, String> {
392    let recipe = profile.recipe_for_pipeline(pipeline).ok_or_else(|| {
393        if let Some(pipeline) = pipeline {
394            format!("pipeline '{pipeline}' is not available for this model")
395        } else {
396            format!(
397                "generation profile '{}' has no default recipe",
398                profile.profile_id
399            )
400        }
401    })?;
402    Ok(recipe.capabilities.output.default_format)
403}
404
405/// Fill an omitted request output from its resolved recipe contract.
406pub fn materialize_generation_profile_output_default(
407    profile: &GenerationProfileSet,
408    request: &mut crate::GenerateRequest,
409) -> Result<(), String> {
410    if request.output_format.is_none() {
411        request.output_format = Some(generation_profile_default_output_format(
412            profile,
413            request.pipeline,
414        )?);
415    }
416    Ok(())
417}
418
419/// Validate model-owned request fields against the exact resolved recipe.
420/// Family validation may still perform engine-specific structural checks, but
421/// it must not widen these advertised controls.
422pub fn validate_request_against_generation_profile(
423    profile: &GenerationProfileSet,
424    request: &crate::GenerateRequest,
425) -> Result<(), String> {
426    let recipe = if let Some(pipeline) = request.pipeline {
427        profile
428            .recipes
429            .iter()
430            .find(|recipe| recipe.request_selector.pipeline == Some(pipeline))
431            .ok_or_else(|| format!("pipeline '{}' is not available for this model", pipeline))?
432    } else {
433        profile.default_recipe().ok_or_else(|| {
434            format!(
435                "generation profile '{}' has no default recipe",
436                profile.profile_id
437            )
438        })?
439    };
440    validate_request_against_recipe(recipe, request)
441}
442
443pub fn validate_request_against_recipe(
444    recipe: &GenerationRecipeProfile,
445    request: &crate::GenerateRequest,
446) -> Result<(), String> {
447    validate_integer("steps", request.steps, &recipe.steps)?;
448    validate_float("guidance", request.guidance, &recipe.guidance)?;
449    if let Some(scheduler) = request.scheduler {
450        let advertised = &recipe.capabilities.schedulers;
451        if !advertised.contains(&scheduler) {
452            return Err(format!(
453                "scheduler '{scheduler}' is not available for this recipe"
454            ));
455        }
456    }
457    if let Some(output_format) = request.output_format {
458        if !recipe.capabilities.output.formats.contains(&output_format) {
459            return Err(format!(
460                "output format '{}' is not available for this recipe",
461                output_format.extension()
462            ));
463        }
464    }
465
466    let resolution = &recipe.resolution;
467    if resolution.domain != ResolutionDomain::None {
468        validate_resolution(resolution, request.width, request.height)?;
469    }
470
471    if let Some(temporal) = &recipe.temporal {
472        let frames = request.frames.unwrap_or(temporal.frames.default);
473        let effective_fps = request.fps.unwrap_or(match temporal.fps {
474            FpsControl::Fixed { value } => value,
475            FpsControl::Adjustable { default, .. } => default,
476        });
477        let mut effective_frames = temporal.frames.clone();
478        if let Some(seconds) = temporal.max_duration_seconds {
479            let raw_duration_cap = seconds
480                .saturating_mul(effective_fps.max(1))
481                .saturating_add(temporal.frame_offset);
482            let grid_cap = raw_duration_cap.saturating_sub(temporal.frame_offset)
483                / temporal.frames.step
484                * temporal.frames.step
485                + temporal.frame_offset;
486            effective_frames.max = effective_frames.max.min(grid_cap);
487        }
488        validate_integer("frames", frames, &effective_frames)?;
489        match temporal.fps {
490            FpsControl::Fixed { value } => {
491                if request.fps.is_some_and(|fps| fps != value) {
492                    return Err(format!("fps is fixed at {value} for this recipe"));
493                }
494            }
495            FpsControl::Adjustable { min, max, step, .. } => {
496                if let Some(fps) = request.fps {
497                    if !(min..=max).contains(&fps) || !(fps - min).is_multiple_of(step) {
498                        return Err(format!(
499                            "fps must be {min} through {max} in steps of {step}"
500                        ));
501                    }
502                }
503            }
504        }
505    } else if request.frames.is_some() || request.fps.is_some() {
506        return Err("frames and fps are not supported by this recipe".to_string());
507    }
508    Ok(())
509}
510
511pub fn validate_dimensions_against_recipe(
512    recipe: &GenerationRecipeProfile,
513    width: u32,
514    height: u32,
515) -> Result<(), String> {
516    if recipe.resolution.domain == ResolutionDomain::None {
517        return Err("resolution is not available for this recipe".to_string());
518    }
519    validate_resolution(&recipe.resolution, width, height)
520}
521
522/// Advisory counterpart to the `Warn` off-bucket policy: the size is admitted
523/// (it cleared every hard gate), but the model is not tuned for it. `None`
524/// for exact buckets, for `Reject`-policy recipes (they refuse instead), and
525/// for sizes the recipe refuses outright.
526pub fn off_bucket_resolution_warning(
527    recipe: &GenerationRecipeProfile,
528    width: u32,
529    height: u32,
530) -> Option<String> {
531    let profile = &recipe.resolution;
532    if profile.domain != ResolutionDomain::Buckets
533        || profile.off_bucket.unwrap_or_default() != OffBucketPolicy::Warn
534        || validate_resolution(profile, width, height).is_err()
535    {
536        return None;
537    }
538    let exact = profile
539        .aspect_groups
540        .iter()
541        .flat_map(|group| &group.presets)
542        .any(|preset| preset.width == width && preset.height == height);
543    (!exact).then(|| format!("This model isn't optimized for {width}x{height} — results may vary."))
544}
545
546/// Client-surface advisory for a custom size: the server (or forced-local
547/// engine) is the admission authority, so a recipe refusal is reported as a
548/// warning rather than blocking entry — the request still submits and the
549/// authoritative refusal comes back as the job's own error. Falls through to
550/// the warn-policy off-bucket advisory for admitted sizes.
551pub fn resolution_advisory(
552    recipe: &GenerationRecipeProfile,
553    width: u32,
554    height: u32,
555) -> Option<String> {
556    match validate_dimensions_against_recipe(recipe, width, height) {
557        Err(error) => Some(format!("{error} — the server may reject this size")),
558        Ok(()) => off_bucket_resolution_warning(recipe, width, height),
559    }
560}
561
562fn validate_resolution(profile: &ResolutionProfile, width: u32, height: u32) -> Result<(), String> {
563    if width < profile.min_width || height < profile.min_height {
564        return Err(format!(
565            "width and height must each be at least {}x{} for this recipe",
566            profile.min_width, profile.min_height
567        ));
568    }
569    if !width.is_multiple_of(profile.alignment) || !height.is_multiple_of(profile.alignment) {
570        return Err(format!(
571            "width and height must be multiples of {} for this recipe",
572            profile.alignment
573        ));
574    }
575    let pixels = u64::from(width) * u64::from(height);
576    if pixels > profile.max_pixels {
577        return Err(format!(
578            "resolution {width}x{height} exceeds this recipe's {} pixel limit",
579            profile.max_pixels
580        ));
581    }
582    if let Some(max_axis) = profile.max_axis_pixels {
583        if width > max_axis || height > max_axis {
584            return Err(format!(
585                "width and height must not exceed {max_axis} for this recipe"
586            ));
587        }
588    }
589    let aspect = f64::from(width) / f64::from(height);
590    if profile
591        .min_aspect_ratio
592        .is_some_and(|minimum| aspect < minimum)
593        || profile
594            .max_aspect_ratio
595            .is_some_and(|maximum| aspect > maximum)
596    {
597        return Err(format!(
598            "resolution {width}x{height} is outside this recipe's aspect-ratio range"
599        ));
600    }
601    if profile.domain == ResolutionDomain::Buckets
602        && profile.off_bucket.unwrap_or_default() == OffBucketPolicy::Reject
603        && !profile
604            .aspect_groups
605            .iter()
606            .flat_map(|group| &group.presets)
607            .any(|preset| preset.width == width && preset.height == height)
608    {
609        return Err(format!(
610            "resolution {width}x{height} is not an available bucket for this recipe"
611        ));
612    }
613    Ok(())
614}
615
616fn validate_integer(name: &str, value: u32, control: &IntegerControl) -> Result<(), String> {
617    if control.mode == ControlMode::Fixed && value != control.default {
618        return Err(format!(
619            "{name} is fixed at {} for this recipe",
620            control.default
621        ));
622    }
623    if !(control.min..=control.max).contains(&value)
624        || !(value - control.min).is_multiple_of(control.step)
625    {
626        return Err(format!(
627            "{name} must be {} through {} in steps of {}",
628            control.min, control.max, control.step
629        ));
630    }
631    Ok(())
632}
633
634fn validate_float(name: &str, value: f64, control: &FloatControl) -> Result<(), String> {
635    if !value.is_finite() {
636        return Err(format!("{name} must be finite"));
637    }
638    if control.mode == ControlMode::Fixed && (value - control.default).abs() > f64::EPSILON {
639        return Err(format!(
640            "{name} is fixed at {} for this recipe",
641            control.default
642        ));
643    }
644    if value < control.min || value > control.max {
645        return Err(format!(
646            "{name} must be {} through {}",
647            control.min, control.max
648        ));
649    }
650    if control.step <= 0.0 || !control.step.is_finite() {
651        return Err(format!("{name} has an invalid profile step"));
652    }
653    let steps = (value - control.min) / control.step;
654    let tolerance = f64::EPSILON * 16.0 * steps.abs().max(1.0);
655    if (steps - steps.round()).abs() > tolerance {
656        return Err(format!(
657            "{name} must be {} through {} in steps of {}",
658            control.min, control.max, control.step
659        ));
660    }
661    Ok(())
662}
663
664#[derive(Debug, Clone)]
665pub struct GenerationProfileInput<'a> {
666    pub model: &'a str,
667    pub family: &'a str,
668    pub sub_family: Option<&'a str>,
669    pub default_width: u32,
670    pub default_height: u32,
671    pub default_steps: u32,
672    pub default_guidance: f64,
673    pub default_frames: Option<u32>,
674    pub default_fps: Option<u32>,
675    pub default_negative_prompt: Option<String>,
676    pub source_image: Option<SourceImageCapability>,
677    pub supports_sequence: bool,
678    pub supports_extend: bool,
679    pub supports_audio: bool,
680}
681
682/// Resolve the canonical shipped profile for a built-in manifest.
683///
684/// Catalog advertisement, generated documentation, and registry invariants
685/// all call this function so they cannot independently reinterpret manifest
686/// defaults or capabilities.
687pub fn generation_profile_for_manifest(
688    manifest: &crate::manifest::ModelManifest,
689) -> GenerationProfileSet {
690    let family = manifest.family.as_str();
691    generation_profile_for_manifest_with_defaults(
692        manifest,
693        GenerationDefaultsProfile {
694            width: manifest.defaults.width,
695            height: manifest.defaults.height,
696            steps: manifest.defaults.steps,
697            guidance: manifest.defaults.guidance,
698            frames: manifest.defaults.frames,
699            fps: manifest.defaults.fps,
700            negative_prompt: crate::manifest::default_negative_prompt_for_family(family)
701                .map(str::to_string),
702        },
703    )
704}
705
706/// Resolve a built-in manifest while preserving validated local default
707/// overlays. Identity and capabilities still come exclusively from the
708/// manifest; callers may replace only user-owned defaults.
709pub fn generation_profile_for_manifest_with_defaults(
710    manifest: &crate::manifest::ModelManifest,
711    defaults: GenerationDefaultsProfile,
712) -> GenerationProfileSet {
713    let family = manifest.family.as_str();
714    resolve_generation_profile(GenerationProfileInput {
715        model: &manifest.name,
716        family,
717        sub_family: None,
718        default_width: defaults.width,
719        default_height: defaults.height,
720        default_steps: defaults.steps,
721        default_guidance: defaults.guidance,
722        default_frames: defaults.frames,
723        default_fps: defaults.fps,
724        default_negative_prompt: defaults.negative_prompt,
725        source_image: manifest.defaults.source_image,
726        supports_sequence: crate::catalog::chain_capable_family(family),
727        supports_extend: crate::catalog::extend_capable_model(
728            family,
729            manifest.defaults.source_image,
730        ),
731        supports_audio: family == "ltx2",
732    })
733}
734
735const SD15: &[(u32, u32)] = &[(512, 512), (512, 768), (768, 512), (384, 512), (512, 384)];
736const SDXL: &[(u32, u32)] = &[
737    (1024, 1024),
738    (1152, 896),
739    (896, 1152),
740    (1216, 832),
741    (832, 1216),
742    (1344, 768),
743    (768, 1344),
744    (1536, 640),
745    (640, 1536),
746];
747const SD3: &[(u32, u32)] = &[
748    (1024, 1024),
749    (1152, 896),
750    (896, 1152),
751    (1216, 832),
752    (832, 1216),
753    (1344, 768),
754    (768, 1344),
755];
756const FLUX: &[(u32, u32)] = &[
757    (1024, 1024),
758    (1024, 768),
759    (768, 1024),
760    (1024, 576),
761    (576, 1024),
762    (768, 768),
763];
764/// Official, runtime-qualified Z-Image-Turbo 1024-tier presets.
765const Z_IMAGE_UPSTREAM_CANDIDATES: &[(u32, u32)] = &[
766    (1024, 1024),
767    (1152, 896),
768    (896, 1152),
769    (1152, 864),
770    (864, 1152),
771    (1248, 832),
772    (832, 1248),
773    (1280, 720),
774    (720, 1280),
775    (1344, 576),
776    (576, 1344),
777];
778/// Current official Qwen-Image standard aspect-ratio presets.
779const QWEN_UPSTREAM_CANDIDATES: &[(u32, u32)] = &[
780    (1328, 1328),
781    (1664, 928),
782    (928, 1664),
783    (1472, 1104),
784    (1104, 1472),
785    (1584, 1056),
786    (1056, 1584),
787];
788const WUERSTCHEN: &[(u32, u32)] = &[(1024, 1024)];
789const LTX_VIDEO: &[(u32, u32)] = &[
790    (704, 480),
791    (768, 512),
792    (512, 512),
793    (1024, 576),
794    (1216, 704),
795    (576, 1024),
796    (768, 768),
797    (512, 768),
798];
799const LTX2: &[(u32, u32)] = &[
800    (704, 480),
801    (768, 512),
802    (512, 512),
803    (1024, 576),
804    (1216, 704),
805    (704, 1216),
806    (576, 1024),
807    (768, 768),
808    (512, 768),
809    (1536, 1024),
810    (1024, 1536),
811    (1920, 1088),
812    (1088, 1920),
813];
814const WAN_480: &[(u32, u32)] = &[(832, 480), (480, 832)];
815const WAN_480_720: &[(u32, u32)] = &[(832, 480), (480, 832), (1280, 720), (720, 1280)];
816const WAN_TI2V: &[(u32, u32)] = &[(1280, 704), (704, 1280)];
817const H3: &[(u32, u32)] = &[
818    (1536, 672),
819    (1344, 768),
820    (1024, 768),
821    (768, 768),
822    (768, 1024),
823    (768, 1344),
824];
825
826const Z_IMAGE_QUALIFICATION: ResolutionQualificationRecord =
827    ResolutionQualificationRecord {
828        family: "z-image",
829        source: "https://huggingface.co/spaces/Tongyi-MAI/Z-Image-Turbo/blob/768cb50d847cdbba97c89533ae976be69cf5a5b8/app.py",
830        revision: "768cb50d847cdbba97c89533ae976be69cf5a5b8",
831        qualified: true,
832        evidence: "docs/qualification/z-image-1024-tier-metal-q4.json: exact-size Q4 Metal generation and decoded PNG delivery for every 1024-tier candidate",
833        candidates: Z_IMAGE_UPSTREAM_CANDIDATES,
834    };
835
836const QWEN_IMAGE_QUALIFICATION: ResolutionQualificationRecord =
837    ResolutionQualificationRecord {
838        family: "qwen-image",
839        source: "https://github.com/QwenLM/Qwen-Image/blob/6b5e1f5cec987d404be5ac6657db3b9aacb56a89/README.md",
840        revision: "6b5e1f5cec987d404be5ac6657db3b9aacb56a89",
841        qualified: true,
842        evidence: "contract qualification: pinned upstream README.md aspect_ratios oracle; Mold dynamic /16 admission and common decoded-image delivery are resolution-generic; no per-size runtime-performance claim",
843        candidates: QWEN_UPSTREAM_CANDIDATES,
844    };
845
846/// Return the pinned upstream dimension record and its Mold qualification
847/// status for a family with an authored aspect set.
848pub fn resolution_qualification_record(
849    family: &str,
850) -> Option<&'static ResolutionQualificationRecord> {
851    match canonical_family(family) {
852        "z-image" => Some(&Z_IMAGE_QUALIFICATION),
853        "qwen-image" => Some(&QWEN_IMAGE_QUALIFICATION),
854        _ => None,
855    }
856}
857
858pub fn family_presets(family: &str) -> &'static [(u32, u32)] {
859    match canonical_family(family) {
860        "sd15" => SD15,
861        "sdxl" => SDXL,
862        "sd3" => SD3,
863        "flux" | "flux2" => FLUX,
864        "z-image" => Z_IMAGE_UPSTREAM_CANDIDATES,
865        "qwen-image" | "qwen-image-edit" => QWEN_UPSTREAM_CANDIDATES,
866        "wuerstchen" => WUERSTCHEN,
867        "ltx-video" => LTX_VIDEO,
868        "ltx2" => LTX2,
869        "wan" => WAN_480_720,
870        "minimax-h3" => H3,
871        _ => &[],
872    }
873}
874
875/// Resolve the authored UI grouping for the conservative legacy family
876/// adapter. The dimensions and their display grouping therefore come from the
877/// same registry path as versioned profiles.
878pub fn family_aspect_groups(family: &str) -> Vec<AspectGroup> {
879    let family = canonical_family(family);
880    aspect_groups(family, family_presets(family))
881}
882
883pub fn presets_for_identity<'a>(
884    model: &str,
885    family: &str,
886    sub_family: Option<&str>,
887) -> &'a [(u32, u32)] {
888    let family = canonical_family(family);
889    if family != "wan" {
890        return family_presets(family);
891    }
892    let identity = format!(
893        "{} {}",
894        crate::manifest::resolve_model_name(model).to_ascii_lowercase(),
895        sub_family.unwrap_or_default().to_ascii_lowercase()
896    );
897    if identity.contains("ti2v-5b") {
898        WAN_TI2V
899    } else if identity.contains("1.3b") {
900        WAN_480
901    } else {
902        WAN_480_720
903    }
904}
905
906pub fn canonical_family(family: &str) -> &str {
907    match family.trim() {
908        "ltx-2" => "ltx2",
909        "flux.2" | "flux-2" => "flux2",
910        "minimax_h3" | "minimaxh3" => "minimax-h3",
911        other => other,
912    }
913}
914
915pub fn resolve_generation_profile(input: GenerationProfileInput<'_>) -> GenerationProfileSet {
916    let family = canonical_family(input.family);
917    let profile_id = input
918        .sub_family
919        .map(|sub| format!("{family}.{sub}"))
920        .unwrap_or_else(|| format!("{family}.{}", crate::manifest::model_base_name(input.model)));
921    let mut recipes = if family == "ltx2" {
922        let mut recipes = vec![recipe(&input, "auto", "Auto", None)];
923        for pipeline in Ltx2PipelineMode::ALL {
924            recipes.push(recipe(
925                &input,
926                pipeline.as_str(),
927                &pipeline_label(pipeline),
928                Some(pipeline),
929            ));
930        }
931        recipes
932    } else {
933        vec![recipe(&input, "default", "Default", None)]
934    };
935    recipes.retain(|recipe| {
936        !(family == "ltx2"
937            && recipe.request_selector.pipeline == Some(Ltx2PipelineMode::T2a)
938            && !input.supports_audio)
939    });
940    let mut set = GenerationProfileSet {
941        schema_version: GENERATION_PROFILE_SCHEMA_VERSION,
942        profile_id,
943        profile_hash: String::new(),
944        default_recipe_id: if family == "ltx2" { "auto" } else { "default" }.to_string(),
945        recipes,
946    };
947    set.refresh_hash();
948    set
949}
950
951fn recipe(
952    input: &GenerationProfileInput<'_>,
953    id: &str,
954    label: &str,
955    pipeline: Option<Ltx2PipelineMode>,
956) -> GenerationRecipeProfile {
957    let family = canonical_family(input.family);
958    let audio_only = pipeline == Some(Ltx2PipelineMode::T2a);
959    let source_driven = family == "qwen-image-edit"
960        || matches!(
961            pipeline,
962            Some(Ltx2PipelineMode::Retake | Ltx2PipelineMode::LipDub)
963        );
964    let composed = family == "ltx2"
965        && pipeline
966            .map(Ltx2PipelineMode::refines_spatially)
967            .unwrap_or_else(|| {
968                validation::ltx2_spatial_composition(input.model, None)
969                    == validation::Ltx2SpatialComposition::TiledTwoStage
970            });
971    let composition = if composed {
972        validation::Ltx2SpatialComposition::TiledTwoStage
973    } else {
974        validation::Ltx2SpatialComposition::SinglePass
975    };
976    let alignment = if composed {
977        validation::LTX2_TWO_STAGE_ALIGNMENT
978    } else if family == "wan" {
979        identity_alignment(input.model, family, input.sub_family)
980    } else {
981        validation::dimension_alignment_for_family(Some(family))
982    };
983    let mut dimensions = presets_for_identity(input.model, family, input.sub_family).to_vec();
984    if composed {
985        for rung in validation::LTX2_OUTPUT_RUNGS
986            .iter()
987            .filter(|rung| rung.requires_tiled_stage2())
988        {
989            dimensions.push((rung.width, rung.height));
990            dimensions.push((rung.height, rung.width));
991        }
992    }
993    dimensions.retain(|(width, height)| {
994        width.is_multiple_of(alignment)
995            && height.is_multiple_of(alignment)
996            && validation::validate_generation_dimensions_composed(
997                *width,
998                *height,
999                Some(family),
1000                composition,
1001            )
1002            .is_ok()
1003    });
1004    let resolution = if audio_only {
1005        ResolutionProfile {
1006            domain: ResolutionDomain::None,
1007            alignment: 1,
1008            min_width: 0,
1009            min_height: 0,
1010            max_pixels: 0,
1011            max_axis_pixels: None,
1012            min_aspect_ratio: None,
1013            max_aspect_ratio: None,
1014            off_bucket: None,
1015            aspect_groups: Vec::new(),
1016        }
1017    } else {
1018        ResolutionProfile {
1019            domain: if source_driven {
1020                ResolutionDomain::SourceDriven
1021            } else if family == "wan" {
1022                ResolutionDomain::Buckets
1023            } else {
1024                ResolutionDomain::Dynamic
1025            },
1026            alignment,
1027            min_width: alignment.max(64),
1028            min_height: alignment.max(64),
1029            max_pixels: validation::max_pixels_for_family_composed(Some(family), composition),
1030            max_axis_pixels: validation::max_axis_pixels_for_family_composed(
1031                Some(family),
1032                composition,
1033            ),
1034            min_aspect_ratio: (family == "minimax-h3")
1035                .then_some(crate::minimax_h3::MIN_ASPECT_RATIO),
1036            max_aspect_ratio: (family == "minimax-h3")
1037                .then_some(crate::minimax_h3::MAX_ASPECT_RATIO),
1038            // Wan's buckets are the trained sizes, not the only runnable
1039            // ones — a deliberate off-bucket request is admitted and the
1040            // advisory warning channel says results may vary.
1041            off_bucket: (family == "wan").then_some(OffBucketPolicy::Warn),
1042            aspect_groups: aspect_groups(family, &dimensions),
1043        }
1044    };
1045
1046    let guidance_caps = if family == "minimax-h3" {
1047        GuidanceCapabilities {
1048            adjustable: false,
1049            supports_negative_prompt: false,
1050            fixed_scale: Some(0.0),
1051        }
1052    } else {
1053        GuidanceCapabilities::for_recipe(family, input.model, pipeline)
1054    };
1055    let effective_guidance = guidance_caps.fixed_scale.unwrap_or(input.default_guidance);
1056    let temporal = temporal_profile(input, family);
1057    let flux2_dev = family == "flux2"
1058        && crate::manifest::resolve_model_name(input.model)
1059            .to_ascii_lowercase()
1060            .contains("dev");
1061    let wan = family == "wan";
1062    let normalized_model = crate::manifest::resolve_model_name(input.model).to_ascii_lowercase();
1063    let lora_supported = validation::family_supports_lora(family)
1064        && !flux2_dev
1065        && !(wan && normalized_model.ends_with("a14b:fp8"));
1066    let source_video_required = matches!(
1067        pipeline,
1068        Some(Ltx2PipelineMode::IcLora | Ltx2PipelineMode::Retake | Ltx2PipelineMode::LipDub)
1069    );
1070    let source_video_supported = family == "ltx2" && !audio_only;
1071    let keyframes_required = pipeline == Some(Ltx2PipelineMode::Keyframe);
1072    let keyframes_supported = family == "ltx2" || wan;
1073    let audio_input_required = pipeline == Some(Ltx2PipelineMode::A2Vid);
1074    let audio_input_supported = family == "ltx2" && !audio_only;
1075    let mask_supported = !audio_only
1076        && !matches!(
1077            family,
1078            "ltx-video" | "ltx2" | "wan" | "qwen-image-edit" | "minimax-h3"
1079        )
1080        && !flux2_dev
1081        && input.source_image != Some(SourceImageCapability::Unsupported);
1082    let controlnet_supported = family == "sd15";
1083    let output = if audio_only {
1084        OutputCapabilitiesProfile {
1085            default_format: OutputFormat::Wav,
1086            formats: vec![OutputFormat::Wav],
1087            audio_requires_mp4: false,
1088            delivery_reason: Some("Audio-only delivery uses WAV.".to_string()),
1089        }
1090    } else if family == "minimax-h3" {
1091        OutputCapabilitiesProfile {
1092            default_format: OutputFormat::Mp4,
1093            formats: vec![OutputFormat::Mp4],
1094            audio_requires_mp4: true,
1095            delivery_reason: Some("Synchronized H3 audio/video delivery requires MP4.".to_string()),
1096        }
1097    } else if temporal.is_some() {
1098        OutputCapabilitiesProfile {
1099            default_format: OutputFormat::Mp4,
1100            formats: vec![
1101                OutputFormat::Mp4,
1102                OutputFormat::Gif,
1103                OutputFormat::Apng,
1104                OutputFormat::Webp,
1105            ],
1106            audio_requires_mp4: family == "ltx2",
1107            delivery_reason: (family == "ltx2")
1108                .then(|| "Audio-enabled video delivery requires MP4.".to_string()),
1109        }
1110    } else {
1111        OutputCapabilitiesProfile {
1112            default_format: OutputFormat::Png,
1113            formats: vec![OutputFormat::Png, OutputFormat::Jpeg, OutputFormat::Webp],
1114            audio_requires_mp4: false,
1115            delivery_reason: None,
1116        }
1117    };
1118    let defaults = GenerationDefaultsProfile {
1119        width: if audio_only { 0 } else { input.default_width },
1120        height: if audio_only { 0 } else { input.default_height },
1121        steps: input.default_steps,
1122        guidance: effective_guidance,
1123        frames: temporal.as_ref().map(|profile| profile.frames.default),
1124        fps: temporal.as_ref().map(|profile| match profile.fps {
1125            FpsControl::Fixed { value } => value,
1126            FpsControl::Adjustable { default, .. } => default,
1127        }),
1128        negative_prompt: input.default_negative_prompt.clone(),
1129    };
1130    GenerationRecipeProfile {
1131        id: id.to_string(),
1132        label: label.to_string(),
1133        request_selector: RecipeSelector { pipeline },
1134        defaults,
1135        resolution,
1136        steps: IntegerControl {
1137            default: input.default_steps,
1138            min: if family == "minimax-h3" { 2 } else { 1 },
1139            max: 100,
1140            step: 1,
1141            recommended: vec![input.default_steps],
1142            mode: ControlMode::Adjustable,
1143        },
1144        guidance: FloatControl {
1145            default: effective_guidance,
1146            min: if guidance_caps.adjustable {
1147                0.0
1148            } else {
1149                effective_guidance
1150            },
1151            max: if guidance_caps.adjustable {
1152                100.0
1153            } else {
1154                effective_guidance
1155            },
1156            step: 0.1,
1157            mode: if guidance_caps.adjustable {
1158                ControlMode::Adjustable
1159            } else {
1160                ControlMode::Fixed
1161            },
1162        },
1163        temporal,
1164        capabilities: GenerationCapabilitiesProfile {
1165            guidance: guidance_caps,
1166            negative_prompt: feature_control(
1167                guidance_caps.supports_negative_prompt,
1168                false,
1169                "This recipe does not encode a negative prompt.",
1170            ),
1171            source_image: input.source_image,
1172            supports_lora: lora_supported,
1173            supports_controlnet: controlnet_supported,
1174            supports_sequence: input.supports_sequence && !audio_only,
1175            supports_extend: input.supports_extend && !audio_only,
1176            supports_audio: input.supports_audio || audio_only,
1177            source_video: feature_control(
1178                source_video_supported,
1179                source_video_required,
1180                "This recipe does not accept a source video.",
1181            ),
1182            mask: feature_control(
1183                mask_supported,
1184                false,
1185                "This model does not accept an inpainting mask.",
1186            ),
1187            keyframes: feature_control(
1188                keyframes_supported,
1189                keyframes_required,
1190                "This model does not accept keyframes.",
1191            ),
1192            audio: feature_control(
1193                audio_input_supported,
1194                audio_input_required,
1195                "This recipe does not accept source audio.",
1196            ),
1197            lora: AdapterControlProfile {
1198                mode: if lora_supported {
1199                    ControlMode::Adjustable
1200                } else {
1201                    ControlMode::Hidden
1202                },
1203                max_count: if lora_supported {
1204                    if pipeline == Some(Ltx2PipelineMode::IcLora) {
1205                        3
1206                    } else {
1207                        4
1208                    }
1209                } else {
1210                    0
1211                },
1212                reason: (!lora_supported)
1213                    .then(|| "This model does not accept LoRA adapters.".to_string()),
1214            },
1215            controlnet: AdapterControlProfile {
1216                mode: if controlnet_supported {
1217                    ControlMode::Adjustable
1218                } else {
1219                    ControlMode::Hidden
1220                },
1221                max_count: u32::from(controlnet_supported),
1222                reason: (!controlnet_supported)
1223                    .then(|| "ControlNet generation is available for SD1.5 models.".to_string()),
1224            },
1225            output,
1226            wan_recipe: WanRecipeCapabilitiesProfile {
1227                mode: if wan {
1228                    ControlMode::Adjustable
1229                } else {
1230                    ControlMode::Hidden
1231                },
1232                supports_distill_strength: wan
1233                    && (normalized_model.ends_with("a14b:q4")
1234                        || normalized_model.ends_with("a14b:q5")),
1235                supports_first_last_frame: wan
1236                    && input.source_image != Some(SourceImageCapability::Unsupported),
1237                first_last_frame_min_frames: wan.then_some(validation::WAN_TI2V_FLF_MIN_FRAMES),
1238                reason: (!wan)
1239                    .then(|| "Wan sampler controls apply only to Wan models.".to_string()),
1240            },
1241            schedulers: match family {
1242                "sd15" | "sdxl" => {
1243                    vec![Scheduler::Ddim, Scheduler::EulerAncestral, Scheduler::UniPc]
1244                }
1245                "wan" => vec![Scheduler::UniPc, Scheduler::Euler, Scheduler::DpmPp],
1246                _ => Vec::new(),
1247            },
1248        },
1249        provenance: provenance(family),
1250    }
1251}
1252
1253fn feature_control(
1254    supported: bool,
1255    required: bool,
1256    unsupported_reason: &'static str,
1257) -> FeatureControlProfile {
1258    FeatureControlProfile {
1259        mode: if supported {
1260            ControlMode::Adjustable
1261        } else {
1262            ControlMode::Hidden
1263        },
1264        required: supported && required,
1265        reason: (!supported).then(|| unsupported_reason.to_string()),
1266    }
1267}
1268
1269fn temporal_profile(input: &GenerationProfileInput<'_>, family: &str) -> Option<TemporalProfile> {
1270    let step = validation::frame_step_for_family(family)?;
1271    let offset = validation::frame_offset_for_family(family).unwrap_or(1);
1272    let fps = input.default_fps.unwrap_or(validation::LTX2_DEFAULT_FPS);
1273    let max = if family == "minimax-h3" {
1274        345
1275    } else if family == "ltx2" {
1276        // The absolute resource guard (604) is not itself on LTX-2's 8n+1
1277        // request grid. Advertise the largest requestable value; admission
1278        // applies the lower duration-derived cap for the selected FPS.
1279        validation::max_frames_for_family_at_fps(family, 120)?
1280    } else {
1281        validation::max_frames_for_family_at_fps(family, fps)?
1282    };
1283    let min = validation::min_frames_for_family(family).unwrap_or(offset);
1284    let default = input.default_frames.unwrap_or(min).clamp(min, max);
1285    Some(TemporalProfile {
1286        frames: IntegerControl {
1287            default,
1288            min,
1289            max,
1290            step,
1291            recommended: vec![default],
1292            mode: ControlMode::Adjustable,
1293        },
1294        frame_offset: offset,
1295        fps: if let Some(fixed) = validation::fixed_fps_for_family(family) {
1296            FpsControl::Fixed { value: fixed }
1297        } else {
1298            FpsControl::Adjustable {
1299                default: fps,
1300                min: 1,
1301                max: 120,
1302                step: 1,
1303            }
1304        },
1305        max_duration_seconds: validation::max_runtime_seconds_for_family(family),
1306    })
1307}
1308
1309fn aspect_groups(family: &str, dimensions: &[(u32, u32)]) -> Vec<AspectGroup> {
1310    let mut groups: Vec<AspectGroup> = Vec::new();
1311    for &(width, height) in dimensions {
1312        let id = authored_aspect_label(family, width, height);
1313        let preset = ResolutionPreset {
1314            id: format!("{width}x{height}"),
1315            width,
1316            height,
1317            tier: "recommended".to_string(),
1318        };
1319        if let Some(group) = groups.iter_mut().find(|group| group.id == id) {
1320            group.presets.push(preset);
1321        } else {
1322            groups.push(AspectGroup {
1323                label: id.clone(),
1324                id,
1325                presets: vec![preset],
1326            });
1327        }
1328    }
1329    for group in &mut groups {
1330        group
1331            .presets
1332            .sort_by_key(|preset| preset.width * preset.height);
1333    }
1334    groups
1335}
1336
1337fn authored_aspect_label(family: &str, width: u32, height: u32) -> String {
1338    match (canonical_family(family), width, height) {
1339        ("qwen-image" | "qwen-image-edit", 1664, 928) => "≈16:9".to_string(),
1340        ("qwen-image" | "qwen-image-edit", 928, 1664) => "≈9:16".to_string(),
1341        _ => {
1342            let divisor = gcd(width, height);
1343            format!("{}:{}", width / divisor, height / divisor)
1344        }
1345    }
1346}
1347
1348fn gcd(mut left: u32, mut right: u32) -> u32 {
1349    while right != 0 {
1350        (left, right) = (right, left % right);
1351    }
1352    left.max(1)
1353}
1354
1355fn identity_alignment(model: &str, family: &str, sub_family: Option<&str>) -> u32 {
1356    if family == "wan"
1357        && format!("{} {}", model, sub_family.unwrap_or_default())
1358            .to_ascii_lowercase()
1359            .contains("ti2v-5b")
1360    {
1361        32
1362    } else {
1363        validation::dimension_alignment_for_model(model, Some(family))
1364    }
1365}
1366
1367fn pipeline_label(pipeline: Ltx2PipelineMode) -> String {
1368    pipeline
1369        .as_str()
1370        .split('-')
1371        .map(|part| {
1372            let mut chars = part.chars();
1373            chars
1374                .next()
1375                .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
1376                .unwrap_or_default()
1377        })
1378        .collect::<Vec<_>>()
1379        .join(" ")
1380}
1381
1382fn provenance(family: &str) -> Vec<ProfileProvenance> {
1383    if canonical_family(family) == "qwen-image-edit" {
1384        return vec![ProfileProvenance {
1385            kind: ProvenanceKind::MoldPolicy,
1386            source: "Mold source-driven Qwen Image Edit guidance".to_string(),
1387            revision: None,
1388            qualified: true,
1389            evidence: Some(
1390                "source fitting preserves the input aspect on the dynamic /16 canvas; optional shape presets reuse Mold's qualified Qwen Image aspect set"
1391                    .to_string(),
1392            ),
1393        }];
1394    }
1395    if let Some(record) = resolution_qualification_record(family) {
1396        return vec![ProfileProvenance {
1397            kind: ProvenanceKind::Upstream,
1398            source: record.source.to_string(),
1399            revision: Some(record.revision.to_string()),
1400            qualified: record.qualified,
1401            evidence: Some(record.evidence.to_string()),
1402        }];
1403    }
1404    let (source, revision, evidence) = match family {
1405        "ltx-video" => (
1406            "https://github.com/Lightricks/LTX-Video",
1407            Some("4b2d053057623ddd4d0a1d3e9cd28890e9ef487f"),
1408            "mold.generation-profile.v1",
1409        ),
1410        "ltx2" => (
1411            "https://github.com/Lightricks/LTX-2",
1412            Some("4f8905737aac86a554637cac86c178877a39c744"),
1413            "mold.generation-profile.v1",
1414        ),
1415        "wan" => (
1416            "https://github.com/Wan-Video/Wan2.2",
1417            Some("42bf4cfaa384bc21833865abc2f9e6c0e67233dc"),
1418            "mold.generation-profile.v1",
1419        ),
1420        "minimax-h3" => (
1421            "https://github.com/MiniMax-AI/MiniMax-H3",
1422            Some("fa6891ff7cdaaa03fa4497e89ac64ff169219acf"),
1423            "mold.generation-profile.v1",
1424        ),
1425        _ => (
1426            "mold-qualified compatibility profile",
1427            None,
1428            "mold.generation-profile.v1",
1429        ),
1430    };
1431    vec![ProfileProvenance {
1432        kind: if source.starts_with("http") {
1433            ProvenanceKind::Upstream
1434        } else {
1435            ProvenanceKind::MoldPolicy
1436        },
1437        source: source.to_string(),
1438        revision: revision.map(str::to_string),
1439        qualified: true,
1440        evidence: Some(evidence.to_string()),
1441    }]
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446    use super::*;
1447
1448    fn input<'a>(model: &'a str, family: &'a str) -> GenerationProfileInput<'a> {
1449        GenerationProfileInput {
1450            model,
1451            family,
1452            sub_family: None,
1453            default_width: 1024,
1454            default_height: 1024,
1455            default_steps: 20,
1456            default_guidance: 3.5,
1457            default_frames: None,
1458            default_fps: None,
1459            default_negative_prompt: None,
1460            source_image: None,
1461            supports_sequence: false,
1462            supports_extend: false,
1463            supports_audio: false,
1464        }
1465    }
1466
1467    #[test]
1468    fn qualified_z_image_candidates_are_profile_recommendations() {
1469        let profile = resolve_generation_profile(input("z-image-turbo:q4", "z-image"));
1470        let recipe = profile.default_recipe().unwrap();
1471        let presets = recipe
1472            .resolution
1473            .aspect_groups
1474            .iter()
1475            .flat_map(|group| &group.presets)
1476            .map(|preset| (preset.width, preset.height))
1477            .collect::<std::collections::HashSet<_>>();
1478        let candidates = resolution_qualification_record("z-image").unwrap();
1479        assert!(candidates.qualified);
1480        assert_eq!(presets.len(), candidates.candidates.len());
1481        assert!(presets.contains(&(1280, 720)));
1482        assert!(presets.contains(&(720, 1280)));
1483    }
1484
1485    #[test]
1486    fn wan_subfamily_selects_exact_checkpoint_contract() {
1487        let mut wan = input("cv:opaque", "wan");
1488        wan.sub_family = Some("wan22-ti2v-5b");
1489        let recipe = resolve_generation_profile(wan)
1490            .default_recipe()
1491            .unwrap()
1492            .clone();
1493        assert_eq!(recipe.resolution.alignment, 32);
1494        assert_eq!(recipe.resolution.aspect_groups.len(), 2);
1495        assert!(recipe.resolution.aspect_groups.iter().all(|group| {
1496            group
1497                .presets
1498                .iter()
1499                .all(|preset| preset.width == 1280 || preset.height == 1280)
1500        }));
1501    }
1502
1503    #[test]
1504    fn h3_temporal_ceiling_is_valid_on_both_grids() {
1505        let mut h3 = input("minimax-h3-fl2va:official-bf16", "minimax-h3");
1506        h3.default_frames = Some(crate::minimax_h3::MIN_FRAMES);
1507        h3.default_fps = Some(24);
1508        let temporal = resolve_generation_profile(h3)
1509            .default_recipe()
1510            .unwrap()
1511            .temporal
1512            .clone()
1513            .unwrap();
1514        assert_eq!(temporal.frames.max, 345);
1515        assert_eq!(
1516            (temporal.frames.max - temporal.frame_offset) % temporal.frames.step,
1517            0
1518        );
1519        assert!(temporal.frames.max <= 15 * 24);
1520    }
1521
1522    #[test]
1523    fn profile_hash_is_stable_and_content_addressed() {
1524        let left = resolve_generation_profile(input("flux-dev:q4", "flux"));
1525        let right = resolve_generation_profile(input("flux-dev:q4", "flux"));
1526        assert_eq!(left.profile_hash, right.profile_hash);
1527        assert_eq!(left.profile_hash.len(), 64);
1528    }
1529
1530    #[test]
1531    fn advanced_and_delivery_controls_are_recipe_owned() {
1532        let sd15 = resolve_generation_profile(input("sd15-base:q4", "sd15"));
1533        let sd15_caps = &sd15.default_recipe().unwrap().capabilities;
1534        assert_eq!(sd15_caps.controlnet.mode, ControlMode::Adjustable);
1535        assert_eq!(sd15_caps.controlnet.max_count, 1);
1536        assert_eq!(
1537            sd15_caps.output.formats,
1538            vec![OutputFormat::Png, OutputFormat::Jpeg, OutputFormat::Webp]
1539        );
1540
1541        let wan = resolve_generation_profile(input("wan22-t2v-a14b:fp8", "wan"));
1542        let wan_caps = &wan.default_recipe().unwrap().capabilities;
1543        assert_eq!(wan_caps.lora.mode, ControlMode::Hidden);
1544        assert_eq!(wan_caps.controlnet.mode, ControlMode::Hidden);
1545        assert_eq!(wan_caps.wan_recipe.mode, ControlMode::Adjustable);
1546        assert!(!wan_caps.wan_recipe.supports_distill_strength);
1547        assert_eq!(
1548            wan_caps.wan_recipe.first_last_frame_min_frames,
1549            Some(validation::WAN_TI2V_FLF_MIN_FRAMES)
1550        );
1551    }
1552
1553    #[test]
1554    fn explicit_pipeline_lookup_never_falls_back_to_default_recipe() {
1555        let mut ltx = input("ltx2-distilled:q4", "ltx2");
1556        ltx.default_frames = Some(121);
1557        ltx.default_fps = Some(24);
1558        let profile = resolve_generation_profile(ltx);
1559        assert!(profile.recipe_for_pipeline(None).is_some());
1560        assert!(profile
1561            .recipe_for_pipeline(Some(Ltx2PipelineMode::T2a))
1562            .is_none());
1563    }
1564
1565    #[test]
1566    fn t2a_dimensionless_profile_reaches_family_admission_with_zero_canvas() {
1567        let mut ltx = input("ltx-2.3-22b-dev:fp8", "ltx2");
1568        ltx.default_frames = Some(97);
1569        ltx.default_fps = Some(24);
1570        ltx.supports_audio = true;
1571        let profile = resolve_generation_profile(ltx);
1572        let recipe = profile
1573            .recipe_for_pipeline(Some(Ltx2PipelineMode::T2a))
1574            .unwrap();
1575        assert_eq!(recipe.resolution.domain, ResolutionDomain::None);
1576        let request: crate::GenerateRequest = serde_json::from_value(serde_json::json!({
1577            "prompt": "rain on a tin roof",
1578            "model": "ltx-2.3-22b-dev:fp8",
1579            "width": 0,
1580            "height": 0,
1581            "steps": recipe.defaults.steps,
1582            "guidance": recipe.defaults.guidance,
1583            "frames": recipe.defaults.frames,
1584            "fps": recipe.defaults.fps,
1585            "pipeline": "t2a",
1586            "output_format": "wav"
1587        }))
1588        .unwrap();
1589        validate_request_against_generation_profile(&profile, &request).unwrap();
1590        crate::validation::validate_generate_request_with_family(&request, Some("ltx2")).unwrap();
1591    }
1592
1593    #[test]
1594    fn scheduler_contract_matches_engine_solver_families() {
1595        let sd = resolve_generation_profile(input("sdxl-base:q4", "sdxl"));
1596        assert_eq!(
1597            sd.default_recipe().unwrap().capabilities.schedulers,
1598            vec![Scheduler::Ddim, Scheduler::EulerAncestral, Scheduler::UniPc]
1599        );
1600
1601        let mut wan_input = input("wan22-t2v-a14b:q5", "wan");
1602        wan_input.default_frames = Some(81);
1603        wan_input.default_fps = Some(16);
1604        let wan = resolve_generation_profile(wan_input);
1605        assert_eq!(
1606            wan.default_recipe().unwrap().capabilities.schedulers,
1607            vec![Scheduler::UniPc, Scheduler::Euler, Scheduler::DpmPp]
1608        );
1609        let mut request = request_for(&wan, 1280, 720);
1610        request.scheduler = Some(Scheduler::Ddim);
1611        assert!(validate_request_against_generation_profile(&wan, &request)
1612            .unwrap_err()
1613            .contains("not available"));
1614
1615        let flux = resolve_generation_profile(input("flux-dev:q4", "flux"));
1616        let mut flux_request = request_for(&flux, 1024, 1024);
1617        flux_request.scheduler = Some(Scheduler::Euler);
1618        assert!(
1619            validate_request_against_generation_profile(&flux, &flux_request)
1620                .unwrap_err()
1621                .contains("not available")
1622        );
1623    }
1624
1625    #[test]
1626    fn z_and_qwen_provenance_is_pinned_and_qualification_is_explicit() {
1627        for (model, family, revision, qualified, evidence_fragment) in [
1628            (
1629                "z-image-turbo:q4",
1630                "z-image",
1631                "768cb50d847cdbba97c89533ae976be69cf5a5b8",
1632                true,
1633                "docs/qualification/z-image-1024-tier-metal-q4.json",
1634            ),
1635            (
1636                "qwen-image:q4",
1637                "qwen-image",
1638                "6b5e1f5cec987d404be5ac6657db3b9aacb56a89",
1639                true,
1640                "no per-size runtime-performance claim",
1641            ),
1642        ] {
1643            let profile = resolve_generation_profile(input(model, family));
1644            let provenance = &profile.default_recipe().unwrap().provenance[0];
1645            assert_eq!(provenance.qualified, qualified);
1646            assert_eq!(provenance.revision.as_deref(), Some(revision));
1647            assert!(provenance.source.contains(revision));
1648            let evidence = provenance.evidence.as_deref().unwrap();
1649            assert!(evidence.contains(evidence_fragment));
1650        }
1651    }
1652
1653    #[test]
1654    fn qwen_image_edit_presets_are_mold_source_fitting_guidance() {
1655        let profile =
1656            resolve_generation_profile(input("qwen-image-edit-2511:q4", "qwen-image-edit"));
1657        let recipe = profile.default_recipe().unwrap();
1658        assert_eq!(recipe.resolution.domain, ResolutionDomain::SourceDriven);
1659        assert_eq!(recipe.provenance[0].kind, ProvenanceKind::MoldPolicy);
1660        assert!(recipe.provenance[0].source.contains("source-driven"));
1661        assert!(resolution_qualification_record("qwen-image-edit").is_none());
1662    }
1663
1664    #[test]
1665    fn qwen_candidates_are_profile_recommendations() {
1666        let profile = resolve_generation_profile(input("qwen-image:q4", "qwen-image"));
1667        let presets = profile
1668            .default_recipe()
1669            .unwrap()
1670            .resolution
1671            .aspect_groups
1672            .iter()
1673            .flat_map(|group| &group.presets)
1674            .map(|preset| (preset.width, preset.height))
1675            .collect::<std::collections::HashSet<_>>();
1676        let candidates = resolution_qualification_record("qwen-image").unwrap();
1677        assert!(candidates.qualified);
1678        assert_eq!(candidates.candidates, QWEN_UPSTREAM_CANDIDATES);
1679        assert_eq!(presets.len(), candidates.candidates.len());
1680        assert!(presets.contains(&(1664, 928)));
1681        assert!(presets.contains(&(928, 1664)));
1682    }
1683
1684    #[test]
1685    fn adjustable_float_controls_enforce_the_advertised_step() {
1686        let profile = resolve_generation_profile(input("flux-dev:q4", "flux"));
1687        let mut request = request_for(&profile, 1024, 1024);
1688        request.guidance = 3.6;
1689        validate_request_against_generation_profile(&profile, &request).unwrap();
1690        request.guidance = 3.65;
1691        assert!(
1692            validate_request_against_generation_profile(&profile, &request)
1693                .unwrap_err()
1694                .contains("steps of 0.1")
1695        );
1696    }
1697
1698    #[test]
1699    fn ltx2_frame_ceiling_tracks_the_requested_fps() {
1700        let mut ltx = input("ltx2-distilled:q4", "ltx2");
1701        ltx.default_width = 768;
1702        ltx.default_height = 512;
1703        ltx.default_frames = Some(121);
1704        ltx.default_fps = Some(24);
1705        let profile = resolve_generation_profile(ltx);
1706        assert_eq!(
1707            profile
1708                .default_recipe()
1709                .unwrap()
1710                .temporal
1711                .as_ref()
1712                .unwrap()
1713                .frames
1714                .max,
1715            validation::LTX2_MAX_FRAMES_ABSOLUTE - 3
1716        );
1717
1718        let mut request = request_for(&profile, 768, 512);
1719        request.fps = Some(12);
1720        request.frames = Some(241);
1721        validate_request_against_generation_profile(&profile, &request).unwrap();
1722        request.frames = Some(249);
1723        assert!(
1724            validate_request_against_generation_profile(&profile, &request)
1725                .unwrap_err()
1726                .contains("frames must be")
1727        );
1728
1729        request.fps = Some(120);
1730        request.frames = Some(601);
1731        validate_request_against_generation_profile(&profile, &request).unwrap();
1732    }
1733
1734    #[test]
1735    fn every_shipped_manifest_profile_is_internally_admissible() {
1736        for manifest in crate::manifest::known_manifests()
1737            .iter()
1738            .filter(|manifest| manifest.is_generation_model())
1739        {
1740            let profile = generation_profile_for_manifest(manifest);
1741            assert_eq!(profile.schema_version, GENERATION_PROFILE_SCHEMA_VERSION);
1742            assert_eq!(profile.profile_hash.len(), 64, "{}", manifest.name);
1743            assert!(profile.default_recipe().is_some(), "{}", manifest.name);
1744
1745            for recipe in &profile.recipes {
1746                let context = format!("{} recipe {}", manifest.name, recipe.id);
1747                assert!(
1748                    (recipe.steps.min..=recipe.steps.max).contains(&recipe.steps.default),
1749                    "{context}: step default is outside its control"
1750                );
1751                assert!(
1752                    (recipe.guidance.min..=recipe.guidance.max).contains(&recipe.guidance.default),
1753                    "{context}: guidance default is outside its control"
1754                );
1755
1756                if let Some(temporal) = &recipe.temporal {
1757                    assert!(
1758                        (temporal.frames.min..=temporal.frames.max)
1759                            .contains(&temporal.frames.default),
1760                        "{context}: frame default is outside its control"
1761                    );
1762                    assert_eq!(
1763                        (temporal.frames.default - temporal.frame_offset) % temporal.frames.step,
1764                        0,
1765                        "{context}: frame default is off-grid"
1766                    );
1767                }
1768
1769                if recipe.resolution.domain == ResolutionDomain::None {
1770                    assert!(recipe.resolution.aspect_groups.is_empty(), "{context}");
1771                    continue;
1772                }
1773                assert_resolution(
1774                    &context,
1775                    recipe,
1776                    recipe.defaults.width,
1777                    recipe.defaults.height,
1778                );
1779                let mut preset_ids = std::collections::HashSet::new();
1780                for group in &recipe.resolution.aspect_groups {
1781                    for preset in &group.presets {
1782                        assert!(preset_ids.insert(&preset.id), "{context}: duplicate preset");
1783                        assert_resolution(&context, recipe, preset.width, preset.height);
1784                    }
1785                }
1786                if recipe.resolution.domain == ResolutionDomain::Buckets {
1787                    assert!(
1788                        recipe
1789                            .resolution
1790                            .aspect_groups
1791                            .iter()
1792                            .flat_map(|group| &group.presets)
1793                            .any(|preset| {
1794                                preset.width == recipe.defaults.width
1795                                    && preset.height == recipe.defaults.height
1796                            }),
1797                        "{context}: bucket default is not advertised"
1798                    );
1799                }
1800            }
1801        }
1802    }
1803
1804    fn assert_resolution(context: &str, recipe: &GenerationRecipeProfile, width: u32, height: u32) {
1805        let resolution = &recipe.resolution;
1806        assert!(width >= resolution.min_width, "{context}: width too small");
1807        assert!(
1808            height >= resolution.min_height,
1809            "{context}: height too small"
1810        );
1811        assert_eq!(width % resolution.alignment, 0, "{context}: width off-grid");
1812        assert_eq!(
1813            height % resolution.alignment,
1814            0,
1815            "{context}: height off-grid"
1816        );
1817        assert!(
1818            u64::from(width) * u64::from(height) <= resolution.max_pixels,
1819            "{context}: pixel ceiling exceeded"
1820        );
1821        if let Some(max_axis) = resolution.max_axis_pixels {
1822            assert!(
1823                width <= max_axis && height <= max_axis,
1824                "{context}: axis exceeded"
1825            );
1826        }
1827        let aspect = f64::from(width) / f64::from(height);
1828        if let Some(min) = resolution.min_aspect_ratio {
1829            assert!(aspect >= min, "{context}: aspect below minimum");
1830        }
1831        if let Some(max) = resolution.max_aspect_ratio {
1832            assert!(aspect <= max, "{context}: aspect above maximum");
1833        }
1834    }
1835
1836    fn request_for(
1837        profile: &GenerationProfileSet,
1838        width: u32,
1839        height: u32,
1840    ) -> crate::GenerateRequest {
1841        let recipe = profile.default_recipe().unwrap();
1842        serde_json::from_value(serde_json::json!({
1843            "prompt": "test",
1844            "model": "test",
1845            "width": width,
1846            "height": height,
1847            "steps": recipe.defaults.steps,
1848            "guidance": recipe.defaults.guidance,
1849            "frames": recipe.defaults.frames,
1850            "fps": recipe.defaults.fps
1851        }))
1852        .unwrap()
1853    }
1854
1855    #[test]
1856    fn profile_admission_accepts_z_wide_and_rejects_non_bucket_wan() {
1857        let z = resolve_generation_profile(input("z-image-turbo:q4", "z-image"));
1858        let z_request = request_for(&z, 1280, 720);
1859        validate_request_against_generation_profile(&z, &z_request).unwrap();
1860
1861        let mut wan_input = input("wan22-t2v-a14b:q5", "wan");
1862        wan_input.default_width = 1280;
1863        wan_input.default_height = 720;
1864        wan_input.default_frames = Some(81);
1865        wan_input.default_fps = Some(16);
1866        let wan = resolve_generation_profile(wan_input);
1867        let valid = request_for(&wan, 1280, 720);
1868        validate_request_against_generation_profile(&wan, &valid).unwrap();
1869
1870        // Wan's buckets are the trained sizes, not the only runnable ones: a
1871        // deliberate aligned off-bucket request is admitted (the advisory
1872        // dimension-warning channel says results may vary)...
1873        let off_bucket = request_for(&wan, 1024, 768);
1874        validate_request_against_generation_profile(&wan, &off_bucket).unwrap();
1875
1876        // ...while a Reject-policy bucket profile (H3's reviewed bridge, and
1877        // every profile serialized before the field existed) still refuses.
1878        let mut strict = wan.clone();
1879        for recipe in &mut strict.recipes {
1880            recipe.resolution.off_bucket = Some(OffBucketPolicy::Reject);
1881        }
1882        assert!(
1883            validate_request_against_generation_profile(&strict, &off_bucket)
1884                .unwrap_err()
1885                .contains("not an available bucket")
1886        );
1887
1888        // The advisory helper (the TUI's warning source) fires exactly for the
1889        // admitted off-bucket size — never for exact buckets, never for a
1890        // Reject-policy recipe, never for a refused shape.
1891        let recipe = wan.default_recipe().unwrap();
1892        assert!(off_bucket_resolution_warning(recipe, 1024, 768)
1893            .unwrap()
1894            .contains("results may vary"));
1895        assert!(off_bucket_resolution_warning(recipe, 1280, 720).is_none());
1896        assert!(off_bucket_resolution_warning(recipe, 1023, 768).is_none());
1897        let strict_recipe = strict.default_recipe().unwrap();
1898        assert!(off_bucket_resolution_warning(strict_recipe, 1024, 768).is_none());
1899    }
1900
1901    #[test]
1902    fn resolution_advisory_downgrades_refusals_for_client_surfaces() {
1903        // The TUI (like the other shells) never blocks a custom size: a
1904        // recipe refusal becomes an advisory naming the server as authority,
1905        // an admitted warn-policy off-bucket keeps the softer message, and an
1906        // exact preset stays silent.
1907        let mut wan_input = input("wan22-t2v-a14b:q5", "wan");
1908        wan_input.default_width = 1280;
1909        wan_input.default_height = 720;
1910        wan_input.default_frames = Some(81);
1911        wan_input.default_fps = Some(16);
1912        let wan = resolve_generation_profile(wan_input);
1913        let recipe = wan.default_recipe().unwrap();
1914        let refused = resolution_advisory(recipe, 1023, 768).unwrap();
1915        assert!(refused.contains("server may reject"));
1916        assert!(resolution_advisory(recipe, 1024, 768)
1917            .unwrap()
1918            .contains("results may vary"));
1919        assert!(resolution_advisory(recipe, 1280, 720).is_none());
1920    }
1921
1922    #[test]
1923    fn profile_admission_rejects_unadvertised_output_format() {
1924        let profile = resolve_generation_profile(input("flux-dev:q4", "flux"));
1925        let mut request = request_for(&profile, 1024, 1024);
1926        request.output_format = Some(OutputFormat::Mp4);
1927        assert!(
1928            validate_request_against_generation_profile(&profile, &request)
1929                .unwrap_err()
1930                .contains("output format 'mp4' is not available")
1931        );
1932    }
1933
1934    #[test]
1935    fn delivery_qualification_repairs_defaults_and_withholds_mp4_only_recipes() {
1936        let mut ltx2_input = input("ltx2:bf16", "ltx2");
1937        ltx2_input.default_frames = Some(97);
1938        ltx2_input.default_fps = Some(24);
1939        let mut ltx2 = resolve_generation_profile(ltx2_input);
1940        let authored_hash = ltx2.profile_hash.clone();
1941        qualify_generation_profile_delivery(
1942            &mut ltx2,
1943            GenerationDeliveryCapabilities::new(false, false),
1944        );
1945        assert_ne!(ltx2.profile_hash, authored_hash);
1946        assert_eq!(
1947            generation_profile_default_output_format(&ltx2, None).unwrap(),
1948            OutputFormat::Gif
1949        );
1950        let default = ltx2.default_recipe().unwrap();
1951        assert_eq!(
1952            default.capabilities.output.formats,
1953            vec![OutputFormat::Gif, OutputFormat::Apng]
1954        );
1955        assert!(!default.capabilities.supports_audio);
1956
1957        let mut request = request_for(&ltx2, 1216, 704);
1958        request.output_format = None;
1959        materialize_generation_profile_output_default(&ltx2, &mut request).unwrap();
1960        assert_eq!(request.output_format, Some(OutputFormat::Gif));
1961        request.output_format = Some(OutputFormat::Apng);
1962        materialize_generation_profile_output_default(&ltx2, &mut request).unwrap();
1963        assert_eq!(request.output_format, Some(OutputFormat::Apng));
1964
1965        let mut h3_input = input("minimax-h3-fl2va:official-bf16", "minimax-h3");
1966        h3_input.default_frames = Some(crate::minimax_h3::MIN_FRAMES);
1967        h3_input.default_fps = Some(crate::minimax_h3::FIXED_FPS);
1968        let mut h3 = resolve_generation_profile(h3_input);
1969        qualify_generation_profile_delivery(
1970            &mut h3,
1971            GenerationDeliveryCapabilities::new(false, false),
1972        );
1973        assert!(h3.recipes.is_empty());
1974        assert!(generation_profile_default_output_format(&h3, None)
1975            .unwrap_err()
1976            .contains("no default recipe"));
1977    }
1978
1979    #[test]
1980    fn profile_admission_enforces_h3_fixed_controls_and_frame_cap() {
1981        let mut h3_input = input("minimax-h3-fl2va:official-bf16", "minimax-h3");
1982        h3_input.default_width = 768;
1983        h3_input.default_height = 768;
1984        h3_input.default_frames = Some(crate::minimax_h3::MIN_FRAMES);
1985        h3_input.default_fps = Some(crate::minimax_h3::FIXED_FPS);
1986        let h3 = resolve_generation_profile(h3_input);
1987        let mut request = request_for(&h3, 768, 768);
1988        request.frames = Some(crate::minimax_h3::MAX_FRAMES);
1989        validate_request_against_generation_profile(&h3, &request).unwrap();
1990
1991        request.frames = Some(362);
1992        assert!(validate_request_against_generation_profile(&h3, &request)
1993            .unwrap_err()
1994            .contains("frames must be"));
1995        request.frames = Some(crate::minimax_h3::MAX_FRAMES);
1996        request.guidance = 1.0;
1997        assert!(validate_request_against_generation_profile(&h3, &request)
1998            .unwrap_err()
1999            .contains("guidance is fixed"));
2000    }
2001}