Skip to main content

mold_server/
model_manager.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use mold_core::{
6    build_model_catalog, Config, GenerateRequest, GenerationMemoryEstimate, ModelComponentOption,
7    ModelComponentStatus, ModelComponentsResponse, ModelDefaults, ModelInfo, ModelInfoExtended,
8    ModelPaths,
9};
10#[cfg(test)]
11use mold_inference::device::ActivationFamily;
12
13use mold_catalog::resolve::{
14    installed_intent_from_sidecar, looks_like_catalog_id, resolve_intent_to_model_config,
15    MissingCompanionPolicy, ResolveError, ResolveOptions,
16};
17
18use crate::model_cache::ModelResidency;
19use crate::{routes::ApiError, state::AppState};
20
21pub(crate) type EngineProgressCallback = Arc<dyn Fn(mold_inference::ProgressEvent) + Send + Sync>;
22
23pub use crate::memory_preflight::ActivationHint;
24#[cfg(test)]
25pub(crate) use crate::memory_preflight::{
26    check_model_memory_budget, preflight_memory_guard_with_available, rejection_suggestion,
27};
28pub(crate) use crate::memory_preflight::{
29    effective_load_available_bytes, estimate_generation_memory_for_request, preflight_memory_guard,
30    request_requires_fresh_engine_for_offload_policy, select_server_load_strategy_for_budget,
31    select_server_load_strategy_for_device, server_offload_enabled_for_paths,
32};
33
34pub(crate) fn request_has_effective_lora(req: &GenerateRequest) -> bool {
35    const ZERO_SCALE_EPS: f64 = 1e-8;
36    if let Some(loras) = &req.loras {
37        if !loras.is_empty() {
38            return loras.iter().any(|lora| lora.scale.abs() > ZERO_SCALE_EPS);
39        }
40    }
41    req.lora
42        .as_ref()
43        .is_some_and(|lora| lora.scale.abs() > ZERO_SCALE_EPS)
44}
45
46pub(crate) fn resolve_installed_catalog_paths_for_worker(
47    model_name: &str,
48    config: &Config,
49) -> Result<Option<(ModelPaths, Config)>, ApiError> {
50    if !looks_like_catalog_id(model_name) {
51        return Ok(None);
52    }
53
54    let Some(intent) = installed_intent_from_sidecar(&config.resolved_models_dir(), model_name)
55    else {
56        return Ok(None);
57    };
58    let model_cfg = resolve_intent_to_paths(model_name, &intent, config)
59        .map_err(|e| resolve_error_to_api_error(&e))?;
60    let mut resolved_config = config.clone();
61    resolved_config
62        .models
63        .insert(model_name.to_string(), model_cfg);
64    let paths = ModelPaths::resolve(model_name, &resolved_config).ok_or_else(|| {
65        ApiError::not_found(format!(
66            "catalog model '{model_name}' resolved to a config that ModelPaths \
67             could not turn into runtime paths — internal mismatch, please file an issue."
68        ))
69    })?;
70
71    Ok(Some((paths, resolved_config)))
72}
73
74pub(crate) type DownloadProgressCallback =
75    Arc<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>;
76
77pub(crate) enum PullStatus {
78    AlreadyAvailable,
79    Pulled,
80}
81
82pub(crate) async fn refresh_config(state: &AppState) -> mold_core::Config {
83    let fresh = {
84        let current = state.config.read().await;
85        current.reload_from_disk_preserving_runtime()
86    };
87
88    let mut config = state.config.write().await;
89    *config = fresh.clone();
90    fresh
91}
92
93pub(crate) async fn list_models(state: &AppState) -> Vec<ModelInfoExtended> {
94    let config = refresh_config(state).await;
95    let models_dir = config.resolved_models_dir();
96
97    // Multi-GPU mode: derive "loaded" state from the worker pool so /api/models
98    // reflects the actual engine cache, not the legacy single-GPU snapshot.
99    if state.gpu_pool.worker_count() > 0 {
100        let loaded_models = loaded_models_across_pool(state);
101        let primary = loaded_models.first().cloned();
102        let mut catalog = build_model_catalog(&config, primary.as_deref(), primary.is_some());
103        // Mark every GPU-resident model as loaded (not just the primary).
104        for entry in catalog.iter_mut() {
105            if loaded_models.contains(&entry.info.name) {
106                entry.info.is_loaded = true;
107            }
108        }
109        catalog.extend(installed_catalog_models(
110            state,
111            &config,
112            &models_dir,
113            primary.as_deref(),
114            primary.is_some(),
115        ));
116        return catalog;
117    }
118
119    let snapshot = state.model_cache.lock().await.snapshot();
120    let mut catalog =
121        build_model_catalog(&config, snapshot.model_name.as_deref(), snapshot.is_loaded);
122    catalog.extend(installed_catalog_models(
123        state,
124        &config,
125        &models_dir,
126        snapshot.model_name.as_deref(),
127        snapshot.is_loaded,
128    ));
129    catalog
130}
131
132fn loaded_models_across_pool(state: &AppState) -> Vec<String> {
133    let mut names = Vec::new();
134    for worker in &state.gpu_pool.workers {
135        // Prefer the active-generation model (cache entry is taken out during
136        // inflight generation), else whatever is GPU-resident.
137        let active = worker
138            .active_generation
139            .read()
140            .ok()
141            .and_then(|g| g.as_ref().map(|g| g.model.clone()));
142        let loaded = active.or_else(|| {
143            let cache = worker.model_cache.lock().ok()?;
144            cache.active_model().map(|s| s.to_string())
145        });
146        if let Some(name) = loaded {
147            if !names.contains(&name) {
148                names.push(name);
149            }
150        }
151    }
152    names
153}
154
155// ── Catalog bridge ───────────────────────────────────────────────────────────
156//
157// The `cv:*` / `hf:*` resolution logic itself lives in the shared
158// `mold_catalog::resolve` module (consumed here and by `mold-cli`); this
159// section keeps only the server-specific orchestration (intent cache, live
160// install, API-error translation).
161
162/// Best-effort family lookup for a catalog (`cv:*` / `hf:*`) model name,
163/// used to feed `validate_generate_request_with_family` so catalog IDs
164/// get the same family-gated feature checks as manifest-resident models.
165///
166/// Reads from the intent cache first (populated by `install_catalog_model`
167/// before the engine-load resolution runs), then falls back to
168/// `state.config.models` for back-compat with callers that snapshotted
169/// the resolved `ModelConfig`.
170pub(crate) async fn catalog_family_for(state: &AppState, model_name: &str) -> Option<String> {
171    if !looks_like_catalog_id(model_name) {
172        return None;
173    }
174    {
175        let intents = state.catalog_intents.read().await;
176        if let Some(intent) = intents.get(model_name) {
177            return Some(intent.family.clone());
178        }
179    }
180    let config = state.config.read().await;
181    if let Some(family) = config.models.get(model_name).and_then(|m| m.family.clone()) {
182        return Some(family);
183    }
184    installed_intent_from_sidecar(&config.resolved_models_dir(), model_name)
185        .map(|intent| intent.family)
186}
187
188/// Resolve the family slug for any model name — checks the static manifest
189/// first (covers `flux-dev:q8` and friends), then falls back to the catalog
190/// config entry (covers `cv:*` / `hf:*`). Used by the activation-budget
191/// preflight to dispatch to the right [`ActivationFamily`].
192pub(crate) async fn family_for_model(state: &AppState, model_name: &str) -> Option<String> {
193    if let Some(manifest) = mold_core::manifest::find_manifest(model_name) {
194        return Some(manifest.family.clone());
195    }
196    catalog_family_for(state, model_name).await
197}
198
199/// Sync variant of [`family_for_model`] for the GPU-worker hot path which
200/// runs inside `spawn_blocking` and only has a `&Config` snapshot to work
201/// with. Falls through to manifest-then-config in the same order.
202pub(crate) fn family_for_model_sync(
203    model_name: &str,
204    config: &mold_core::Config,
205) -> Option<String> {
206    if let Some(manifest) = mold_core::manifest::find_manifest(model_name) {
207        return Some(manifest.family.clone());
208    }
209    config.models.get(model_name).and_then(|m| m.family.clone())
210}
211
212/// Sync variant of [`activation_hint_for_request`] for the GPU-worker hot
213/// path. Returns `None` when the family slug can't be resolved.
214pub(crate) fn activation_hint_for_request_sync(
215    config: &mold_core::Config,
216    req: &GenerateRequest,
217) -> Option<ActivationHint> {
218    let family = family_for_model_sync(&req.model, config)?;
219    Some(ActivationHint::from_request(req, &family))
220}
221
222/// Build an [`ActivationHint`] for the given request using the resolved
223/// model family (manifest or catalog). Returns `None` when the family slug
224/// can't be resolved — the preflight then falls back to the size-only peak.
225pub(crate) async fn activation_hint_for_request(
226    state: &AppState,
227    req: &GenerateRequest,
228) -> Option<ActivationHint> {
229    let family = family_for_model(state, &req.model).await?;
230    Some(ActivationHint::from_request(req, &family))
231}
232
233/// Server-side disk-aware resolution: turn a pure `CatalogModelIntent` into a
234/// runtime `ModelConfig`. The server fails hard on any missing required
235/// companion so the engine-load retry path can distinguish "still
236/// downloading" from "broken config", and requires the primary present.
237/// The resolution logic itself is the shared `mold_catalog::resolve`
238/// implementation (also consumed by `mold-cli`).
239pub(crate) fn resolve_intent_to_paths(
240    model_name: &str,
241    intent: &mold_catalog::synthesis::CatalogModelIntent,
242    config: &mold_core::Config,
243) -> Result<mold_core::ModelConfig, ResolveError> {
244    resolve_intent_to_model_config(
245        model_name,
246        intent,
247        config,
248        ResolveOptions {
249            missing_companions: MissingCompanionPolicy::Fail,
250            require_primary_present: true,
251        },
252    )
253}
254
255/// If `model_name` is a catalog ID and not yet in the intent cache, hit
256/// live HF/Civitai for the entry and synthesize its
257/// [`CatalogModelIntent`]. Returns `Ok(())` when a fresh entry was
258/// installed or one was already cached. Returns a typed [`InstallError`]
259/// so the caller can map per-variant to a user-facing HTTP status.
260///
261/// This function is *pure synthesis* — no disk reads. The intent stays
262/// valid across download events; resolution into a `ModelConfig` is run
263/// lazily by [`resolve_intent_to_paths`] at engine-load time.
264pub(crate) async fn install_catalog_model(
265    state: &AppState,
266    model_name: &str,
267) -> Result<(), mold_core::InstallError> {
268    if !looks_like_catalog_id(model_name) {
269        // Caller should have shape-checked first; treat it as a no-op
270        // success rather than fabricating a custom error variant.
271        return Ok(());
272    }
273
274    // Already installed from a prior request — keep the cached intent.
275    {
276        let intents = state.catalog_intents.read().await;
277        if intents.contains_key(model_name) {
278            return Ok(());
279        }
280    }
281
282    let models_dir = state.config.read().await.resolved_models_dir();
283    if let Some(intent) = installed_intent_from_sidecar(&models_dir, model_name) {
284        let mut intents = state.catalog_intents.write().await;
285        intents.insert(model_name.to_string(), intent);
286        return Ok(());
287    }
288
289    // Live single-id lookup via the shared cv:/hf: dispatcher. Tokens are
290    // picked up from env so unauthenticated browsing still works; the
291    // Civitai base is test-overridable through AppState.
292    let entry = mold_catalog::live::fetch_entry_by_id(
293        model_name,
294        state.catalog_live_civitai_base.as_str(),
295        "https://huggingface.co",
296        std::env::var("CIVITAI_TOKEN").ok().as_deref(),
297        std::env::var("HF_TOKEN").ok().as_deref(),
298    )
299    .await
300    .map_err(|e| live_error_to_install_error(model_name, &e))?;
301
302    let intent = mold_catalog::synthesis::synthesize_intent(&entry, &models_dir).map_err(|e| {
303        mold_core::InstallError::RecipeMalformed(format!("synthesize intent for {model_name}: {e}"))
304    })?;
305    if model_name.starts_with("cv:") {
306        write_catalog_sidecar_from_intent(&models_dir, &entry, &intent);
307    }
308
309    let mut intents = state.catalog_intents.write().await;
310    intents.insert(model_name.to_string(), intent);
311    Ok(())
312}
313
314fn write_catalog_sidecar_from_intent(
315    models_dir: &std::path::Path,
316    entry: &mold_catalog::entry::CatalogEntry,
317    intent: &mold_catalog::synthesis::CatalogModelIntent,
318) {
319    let sc_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, entry.id.as_str());
320    let Some(sidecar_dir) = sc_path.parent() else {
321        return;
322    };
323    let Ok(primary_rel) = intent.primary_recipe_path.strip_prefix(sidecar_dir) else {
324        return;
325    };
326    let Some(primary_rel) = primary_rel.to_str() else {
327        return;
328    };
329    let sidecar = mold_catalog::sidecar::sidecar_from_entry(entry, primary_rel.to_string());
330    if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
331        tracing::warn!(
332            target: "catalog.sidecar",
333            catalog_id = %entry.id.as_str(),
334            error = %e,
335            "sidecar write failed after live catalog install",
336        );
337    }
338}
339
340/// Translate a `LiveSearchError` into the user-facing `InstallError`
341/// shape. The `Upstream` variant carries the HTTP status from the
342/// upstream's response body — 404 is "not found", anything else is
343/// "malformed recipe" (the live API answered, but the response wasn't
344/// what mold expects).
345fn live_error_to_install_error(
346    model_name: &str,
347    err: &mold_catalog::live::LiveSearchError,
348) -> mold_core::InstallError {
349    use mold_catalog::live::LiveSearchError;
350    match err {
351        LiveSearchError::Network(e) => {
352            mold_core::InstallError::Network(format!("{model_name}: {e}"))
353        }
354        LiveSearchError::Decode(e) => mold_core::InstallError::RecipeMalformed(format!(
355            "{model_name}: decode upstream payload: {e}"
356        )),
357        LiveSearchError::Upstream { status, body, .. } if *status == 404 => {
358            mold_core::InstallError::NotFound(format!(
359                "{model_name}: upstream returned 404 ({})",
360                truncate_body(body)
361            ))
362        }
363        LiveSearchError::Upstream { status, body, .. } => {
364            mold_core::InstallError::RecipeMalformed(format!(
365                "{model_name}: upstream HTTP {status}: {}",
366                truncate_body(body)
367            ))
368        }
369    }
370}
371
372fn truncate_body(body: &str) -> String {
373    let trimmed = body.trim();
374    if trimmed.len() > 160 {
375        format!("{}…", &trimmed[..160])
376    } else {
377        trimmed.to_string()
378    }
379}
380
381/// Translate `InstallError` into the user-facing `ApiError`. Each variant
382/// maps to a distinct HTTP status so clients can tell "Civitai is down"
383/// from "you typed a bad ID".
384pub(crate) fn install_error_to_api_error(err: &mold_core::InstallError) -> ApiError {
385    use mold_core::InstallError;
386    match err {
387        InstallError::Network(msg) => {
388            // 502 Bad Gateway via internal_with_status — the catalog
389            // upstream is unreachable, not a mold internal failure.
390            ApiError::internal_with_status(
391                format!("network unreachable: {msg}"),
392                axum::http::StatusCode::BAD_GATEWAY,
393            )
394        }
395        InstallError::NotFound(msg) => ApiError::not_found(msg.to_string()),
396        InstallError::RecipeMalformed(msg) => ApiError::internal(msg.to_string()),
397    }
398}
399
400/// Translate `ResolveError` into the user-facing `ApiError`. The
401/// download-repairable variants surface as 404 (the model isn't loadable
402/// yet) with distinct, specific messages so the user can act; the
403/// never-should-happen `UnknownFamily` surfaces as a 500 internal error.
404pub(crate) fn resolve_error_to_api_error(err: &ResolveError) -> ApiError {
405    if matches!(err, ResolveError::UnknownFamily { .. }) {
406        return ApiError::internal(err.to_string());
407    }
408    ApiError::not_found(err.to_string())
409}
410
411/// Return `ModelInfoExtended` entries for every installed catalog
412/// checkpoint discovered via per-install sidecars. Replaces the
413/// bulk-scrape DB query that used to back this surface.
414fn installed_catalog_models(
415    _state: &AppState,
416    config: &mold_core::Config,
417    models_dir: &std::path::Path,
418    loaded_model: Option<&str>,
419    engine_is_loaded: bool,
420) -> Vec<ModelInfoExtended> {
421    let walked = mold_catalog::sidecar::walk_sidecars(models_dir);
422    let mut out = Vec::new();
423    for (sidecar_dir, sidecar) in walked {
424        if sidecar.kind != "checkpoint" {
425            continue;
426        }
427        if mold_catalog::sidecar::primary_looks_like_auxiliary(&sidecar) {
428            continue;
429        }
430        // Skip sidecars whose primary file isn't actually present —
431        // partial pulls / aborted downloads.
432        if mold_catalog::sidecar::primary_path_if_present(&sidecar_dir, &sidecar).is_none() {
433            continue;
434        }
435        // Skip entries already covered by a manifest.
436        if mold_core::manifest::find_manifest_by_hf_repo(&sidecar.source_id).is_some() {
437            continue;
438        }
439
440        let size_gb = sidecar
441            .size_bytes
442            .map(|b| b as f32 / 1_000_000_000.0)
443            .unwrap_or(0.0);
444
445        let defaults = mold_catalog::defaults::runtime_defaults_for_family(
446            &sidecar.family,
447            sidecar.sub_family.as_deref(),
448        );
449        let user_cfg = config.lookup_model_config(&sidecar.id);
450        let w = user_cfg
451            .as_ref()
452            .and_then(|cfg| cfg.default_width)
453            .unwrap_or(defaults.width);
454        let h = user_cfg
455            .as_ref()
456            .and_then(|cfg| cfg.default_height)
457            .unwrap_or(defaults.height);
458        let steps = user_cfg
459            .as_ref()
460            .and_then(|cfg| cfg.default_steps)
461            .unwrap_or(defaults.steps);
462        let guidance = user_cfg
463            .as_ref()
464            .and_then(|cfg| cfg.default_guidance)
465            .unwrap_or(defaults.guidance);
466
467        let description = match &sidecar.author {
468            Some(a) if !a.is_empty() => format!("{} by {a}", sidecar.name),
469            _ => sidecar.name.clone(),
470        };
471
472        out.push(ModelInfoExtended {
473            downloaded: true,
474            defaults: ModelDefaults {
475                default_width: w,
476                default_height: h,
477                default_steps: steps,
478                default_guidance: guidance,
479                description,
480            },
481            info: ModelInfo {
482                name: sidecar.id.clone(),
483                family: sidecar.family.clone(),
484                size_gb,
485                is_loaded: loaded_model.is_some_and(|n| engine_is_loaded && n == sidecar.id),
486                last_used: None,
487                hf_repo: String::new(),
488            },
489            disk_usage_bytes: sidecar.size_bytes,
490            remaining_download_bytes: Some(0),
491        });
492    }
493    out
494}
495
496/// Check whether a model is available — either already in the cache or
497/// has resolvable paths on disk. Returns `Some(paths)` if the model needs
498/// to be created from scratch, `None` if already in the cache.
499pub(crate) async fn check_model_available(
500    state: &AppState,
501    model_name: &str,
502) -> Result<Option<ModelPaths>, ApiError> {
503    // Check the model cache first.
504    {
505        let cache = state.model_cache.lock().await;
506        if cache.contains(model_name) {
507            return Ok(None);
508        }
509    }
510
511    let paths = {
512        let config = state.config.read().await;
513        if config.manifest_model_needs_download(model_name) {
514            None
515        } else {
516            ModelPaths::resolve(model_name, &config)
517        }
518    };
519    if let Some(paths) = paths {
520        return Ok(Some(paths));
521    }
522
523    {
524        let current = state.config.read().await.clone();
525        let fresh_config = current.reload_from_disk_preserving_runtime();
526        let needs_download = fresh_config.manifest_model_needs_download(model_name);
527        let paths = if needs_download {
528            None
529        } else {
530            ModelPaths::resolve(model_name, &fresh_config)
531        };
532        {
533            let mut config = state.config.write().await;
534            *config = fresh_config;
535        }
536        if let Some(paths) = paths {
537            return Ok(Some(paths));
538        }
539    }
540
541    // Catalog bridge: install (live lookup → cache intent), then resolve the
542    // intent against fresh disk state on every request. Lazy resolution
543    // means a request that fires while files are still downloading no
544    // longer seals a wrong `cfg.vae` into the config — the next request
545    // re-resolves once files land.
546    if looks_like_catalog_id(model_name) {
547        if let Err(install_err) = install_catalog_model(state, model_name).await {
548            return Err(install_error_to_api_error(&install_err));
549        }
550        let intents = state.catalog_intents.read().await;
551        let intent = intents
552            .get(model_name)
553            .ok_or_else(|| {
554                ApiError::not_found(format!(
555                    "catalog model '{model_name}' is not installed. Download it from \
556                     the catalog first."
557                ))
558            })?
559            .clone();
560        drop(intents);
561
562        let resolved = {
563            let config = state.config.read().await;
564            resolve_intent_to_paths(model_name, &intent, &config)
565        };
566        match resolved {
567            Ok(model_cfg) => {
568                // Cache the resolved ModelConfig back into config.models so
569                // downstream `resolved_model_config` / family lookups still
570                // work as before.
571                {
572                    let mut config = state.config.write().await;
573                    config.models.insert(model_name.to_string(), model_cfg);
574                }
575                let config = state.config.read().await;
576                if let Some(paths) = ModelPaths::resolve(model_name, &config) {
577                    return Ok(Some(paths));
578                }
579                // ModelPaths::resolve failed despite successful synthesis —
580                // shouldn't happen because resolve_intent_to_paths checks
581                // companions, but surface a precise diagnostic if it does.
582                return Err(ApiError::not_found(format!(
583                    "catalog model '{model_name}' resolved to a config that ModelPaths \
584                     could not turn into runtime paths — internal mismatch, please file an issue."
585                )));
586            }
587            Err(e) => return Err(resolve_error_to_api_error(&e)),
588        }
589    }
590
591    if mold_core::manifest::find_manifest(model_name).is_some() {
592        return Err(ApiError::not_found(format!(
593            "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
594        )));
595    }
596    Err(ApiError::unknown_model(format!(
597        "unknown model '{model_name}'. Run 'mold list' to see available models."
598    )))
599}
600
601pub(crate) async fn estimate_generation_memory(
602    state: &AppState,
603    req: &GenerateRequest,
604) -> Result<GenerationMemoryEstimate, ApiError> {
605    let paths = match check_model_available(state, &req.model).await? {
606        Some(paths) => paths,
607        None => {
608            let config = state.config.read().await;
609            ModelPaths::resolve(&req.model, &config).ok_or_else(|| {
610                ApiError::not_found(format!(
611                    "model '{}' is loaded but runtime paths are not available for estimation",
612                    req.model
613                ))
614            })?
615        }
616    };
617    let hint = activation_hint_for_request(state, req).await;
618    let estimate = estimate_generation_memory_for_request(req, &paths, hint);
619
620    Ok(GenerationMemoryEstimate {
621        model: req.model.clone(),
622        peak_memory_bytes: estimate.peak_memory_bytes,
623        activation_memory_bytes: estimate.activation_memory_bytes,
624        available_memory_bytes: estimate.available_memory_bytes,
625        load_strategy: format!("{:?}", estimate.load_strategy).to_ascii_lowercase(),
626        fits_available_memory: estimate.fits_available_memory,
627    })
628}
629
630pub(crate) async fn model_component_status(
631    state: &AppState,
632    model_name: &str,
633) -> Result<ModelComponentsResponse, ApiError> {
634    let resolved = mold_core::manifest::resolve_model_name(model_name);
635    if let Some(manifest) = mold_core::manifest::find_manifest(&resolved) {
636        let config = state.config.read().await;
637        let models_dir = config.resolved_models_dir();
638        let components = manifest
639            .files
640            .iter()
641            .map(|file| {
642                let kind = manifest_component_kind(file.component);
643                let path = models_dir.join(mold_core::manifest::storage_path(manifest, file));
644                ModelComponentStatus {
645                    kind: kind.to_string(),
646                    name: manifest_component_name(file.component, &file.hf_filename).to_string(),
647                    present: path.is_file(),
648                    path: Some(path.to_string_lossy().to_string()),
649                    repair_model: Some(resolved.clone()),
650                    options: component_options_for_kind(&config, kind, Some(&path)),
651                }
652            })
653            .collect();
654        return Ok(ModelComponentsResponse {
655            model: resolved,
656            components,
657        });
658    }
659
660    let config = state.config.read().await;
661    let Some(paths) = ModelPaths::resolve(model_name, &config) else {
662        return Err(ApiError::unknown_model(format!(
663            "unknown model '{model_name}'. Run 'mold list' to see available models."
664        )));
665    };
666    Ok(ModelComponentsResponse {
667        model: model_name.to_string(),
668        components: component_status_from_paths(&config, model_name, &paths),
669    })
670}
671
672fn manifest_component_kind(component: mold_core::manifest::ModelComponent) -> &'static str {
673    use mold_core::manifest::ModelComponent;
674    match component {
675        ModelComponent::Transformer | ModelComponent::TransformerShard => "transformer",
676        ModelComponent::Vae => "vae",
677        ModelComponent::SpatialUpscaler => "spatial_upscaler",
678        ModelComponent::TemporalUpscaler => "temporal_upscaler",
679        ModelComponent::DistilledLora => "distilled_lora",
680        ModelComponent::T5Encoder | ModelComponent::TextEncoder => "text_encoder",
681        ModelComponent::ClipEncoder | ModelComponent::ClipEncoder2 => "clip",
682        ModelComponent::T5Tokenizer
683        | ModelComponent::ClipTokenizer
684        | ModelComponent::ClipTokenizer2
685        | ModelComponent::TextTokenizer => "tokenizer",
686        ModelComponent::Decoder => "decoder",
687        ModelComponent::Upscaler => "upscaler",
688    }
689}
690
691fn manifest_component_name(component: mold_core::manifest::ModelComponent, filename: &str) -> &str {
692    use mold_core::manifest::ModelComponent;
693    match component {
694        ModelComponent::Transformer => "transformer",
695        ModelComponent::TransformerShard => "transformer shard",
696        ModelComponent::Vae => "vae",
697        ModelComponent::SpatialUpscaler => "spatial upscaler",
698        ModelComponent::TemporalUpscaler => "temporal upscaler",
699        ModelComponent::DistilledLora => "distilled lora",
700        ModelComponent::T5Encoder => "t5 encoder",
701        ModelComponent::ClipEncoder => "clip encoder",
702        ModelComponent::T5Tokenizer => "t5 tokenizer",
703        ModelComponent::ClipTokenizer => "clip tokenizer",
704        ModelComponent::ClipEncoder2 => "clip-g encoder",
705        ModelComponent::ClipTokenizer2 => "clip-g tokenizer",
706        ModelComponent::TextEncoder => "text encoder",
707        ModelComponent::TextTokenizer => "text tokenizer",
708        ModelComponent::Decoder => "decoder",
709        ModelComponent::Upscaler => filename,
710    }
711}
712
713fn component_status_from_paths(
714    config: &Config,
715    model_name: &str,
716    paths: &ModelPaths,
717) -> Vec<ModelComponentStatus> {
718    let mut components = Vec::new();
719    let mut push_path = |kind: &str, name: &str, path: &std::path::Path| {
720        components.push(ModelComponentStatus {
721            kind: kind.to_string(),
722            name: name.to_string(),
723            present: path.is_file(),
724            path: Some(path.to_string_lossy().to_string()),
725            repair_model: Some(model_name.to_string()),
726            options: component_options_for_kind(config, kind, Some(path)),
727        });
728    };
729    push_path("transformer", "transformer", &paths.transformer);
730    for shard in &paths.transformer_shards {
731        push_path("transformer", "transformer shard", shard);
732    }
733    push_path("vae", "vae", &paths.vae);
734    if let Some(path) = &paths.spatial_upscaler {
735        push_path("spatial_upscaler", "spatial upscaler", path);
736    }
737    if let Some(path) = &paths.temporal_upscaler {
738        push_path("temporal_upscaler", "temporal upscaler", path);
739    }
740    if let Some(path) = &paths.distilled_lora {
741        push_path("distilled_lora", "distilled lora", path);
742    }
743    if let Some(path) = &paths.t5_encoder {
744        push_path("text_encoder", "t5 encoder", path);
745    }
746    if let Some(path) = &paths.clip_encoder {
747        push_path("clip", "clip encoder", path);
748    }
749    if let Some(path) = &paths.clip_encoder_2 {
750        push_path("clip", "clip-g encoder", path);
751    }
752    for path in &paths.text_encoder_files {
753        push_path("text_encoder", "text encoder", path);
754    }
755    if let Some(path) = &paths.decoder {
756        push_path("decoder", "decoder", path);
757    }
758    components
759}
760
761fn component_options_for_kind(
762    config: &Config,
763    kind: &str,
764    current_path: Option<&Path>,
765) -> Vec<ModelComponentOption> {
766    let mut options = BTreeMap::<String, ModelComponentOption>::new();
767    if let Some(path) = current_path {
768        add_component_option(&mut options, path);
769    }
770    for model_cfg in config.models.values() {
771        for path in config_component_paths_for_kind(model_cfg, kind) {
772            add_component_option(&mut options, Path::new(path));
773        }
774    }
775    let models_dir = config.resolved_models_dir();
776    for manifest in mold_core::manifest::known_manifests() {
777        for file in &manifest.files {
778            if manifest_component_kind(file.component) != kind {
779                continue;
780            }
781            let path = models_dir.join(mold_core::manifest::storage_path(manifest, file));
782            if path.is_file() {
783                add_component_option(&mut options, &path);
784            }
785        }
786    }
787    options.into_values().collect()
788}
789
790fn config_component_paths_for_kind<'a>(
791    model_cfg: &'a mold_core::config::ModelConfig,
792    kind: &str,
793) -> Vec<&'a str> {
794    let mut paths = Vec::new();
795    match kind {
796        "transformer" => {
797            if let Some(path) = model_cfg.transformer.as_deref() {
798                paths.push(path);
799            }
800            if let Some(shards) = &model_cfg.transformer_shards {
801                paths.extend(shards.iter().map(String::as_str));
802            }
803        }
804        "vae" => {
805            if let Some(path) = model_cfg.vae.as_deref() {
806                paths.push(path);
807            }
808        }
809        "text_encoder" => {
810            if let Some(path) = model_cfg.t5_encoder.as_deref() {
811                paths.push(path);
812            }
813            if let Some(files) = &model_cfg.text_encoder_files {
814                paths.extend(files.iter().map(String::as_str));
815            }
816        }
817        "clip" => {
818            if let Some(path) = model_cfg.clip_encoder.as_deref() {
819                paths.push(path);
820            }
821            if let Some(path) = model_cfg.clip_encoder_2.as_deref() {
822                paths.push(path);
823            }
824        }
825        "tokenizer" => {
826            for path in [
827                model_cfg.t5_tokenizer.as_deref(),
828                model_cfg.clip_tokenizer.as_deref(),
829                model_cfg.clip_tokenizer_2.as_deref(),
830                model_cfg.text_tokenizer.as_deref(),
831            ]
832            .into_iter()
833            .flatten()
834            {
835                paths.push(path);
836            }
837        }
838        "spatial_upscaler" => {
839            if let Some(path) = model_cfg.spatial_upscaler.as_deref() {
840                paths.push(path);
841            }
842        }
843        "temporal_upscaler" => {
844            if let Some(path) = model_cfg.temporal_upscaler.as_deref() {
845                paths.push(path);
846            }
847        }
848        "distilled_lora" => {
849            if let Some(path) = model_cfg.distilled_lora.as_deref() {
850                paths.push(path);
851            }
852        }
853        "decoder" => {
854            if let Some(path) = model_cfg.decoder.as_deref() {
855                paths.push(path);
856            }
857        }
858        _ => {}
859    }
860    paths
861}
862
863fn add_component_option(options: &mut BTreeMap<String, ModelComponentOption>, path: &Path) {
864    let path_str = path.to_string_lossy().to_string();
865    options.entry(path_str.clone()).or_insert_with(|| {
866        let label = path
867            .file_name()
868            .and_then(|name| name.to_str())
869            .unwrap_or(path_str.as_str())
870            .to_string();
871        ModelComponentOption {
872            label,
873            path: path_str,
874            present: path.is_file(),
875        }
876    });
877}
878
879/// Ensure the requested model is loaded on GPU and ready for inference.
880///
881/// Checks the model cache: if already loaded, just touches the LRU order.
882/// If cached but unloaded, reloads it. If not in cache, creates a new engine.
883///
884/// `hint` carries the per-request resolution / family used by the activation
885/// budget. `None` falls back to the previous fixed-headroom approximation —
886/// admin-API loads with no resolution context (cache prewarm, etc.) take
887/// this path.
888pub(crate) async fn ensure_model_ready(
889    state: &AppState,
890    model_name: &str,
891    progress: Option<EngineProgressCallback>,
892    hint: Option<ActivationHint>,
893    request_has_lora: bool,
894) -> Result<(), ApiError> {
895    let _guard = state.model_load_lock.lock().await;
896
897    // Fast path: model is in cache and loaded.
898    {
899        let mut cache = state.model_cache.lock().await;
900        // Grab active model's VRAM before mutable borrow via get_mut.
901        let active_vram = cache.active_vram_bytes();
902        if let Some(entry) = cache.get_mut(model_name) {
903            if entry.residency == ModelResidency::Gpu {
904                let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
905                    request_requires_fresh_engine_for_offload_policy(paths, hint, request_has_lora)
906                });
907                if must_recreate {
908                    tracing::info!(
909                        model = %model_name,
910                        "recreating loaded engine for request-specific offload policy"
911                    );
912                } else {
913                    // Already loaded — just set up progress callback.
914                    if let Some(callback) = progress.clone() {
915                        entry.engine.set_on_progress(Box::new(move |event| {
916                            callback(event);
917                        }));
918                    } else {
919                        entry.engine.clear_on_progress();
920                    }
921                    return Ok(());
922                }
923            }
924
925            // Cached but not on GPU (Parked) — need to reload.
926            // MPS memory guard: check before unloading the active model.
927            // Include the active model's footprint as reclaimable memory.
928            let cached_paths = entry.engine.model_paths().cloned();
929            if let Some(paths) = cached_paths.as_ref() {
930                preflight_memory_guard(model_name, paths, active_vram, 0, hint)?;
931            }
932            let load_strategy = cached_paths
933                .as_ref()
934                .map(|paths| {
935                    select_server_load_strategy_for_budget(
936                        paths,
937                        effective_load_available_bytes(active_vram, 0),
938                        hint,
939                    )
940                })
941                .unwrap_or(mold_inference::LoadStrategy::Eager);
942            if load_strategy == mold_inference::LoadStrategy::Sequential {
943                tracing::info!(
944                    model = %model_name,
945                    "server load strategy degraded to sequential to fit memory budget"
946                );
947            }
948
949            // Parked engines retain tokenizers/caches for faster reload.
950            // First unload the currently active model (if any) to free VRAM.
951            if let Some(active_name) = cache.unload_active() {
952                #[cfg(feature = "metrics")]
953                crate::metrics::clear_model_loaded(&active_name);
954                tracing::info!(
955                    from = %active_name,
956                    to = %model_name,
957                    "unloaded active model to reload cached model"
958                );
959                // Legacy no-worker path only: hardcoded ordinal 0 is safe here
960                // because `state.model_load_lock` (taken above) is the only
961                // lock protecting GPU 0's primary context on this path — the
962                // GpuPool path uses `worker.model_load_lock` and
963                // `reclaim_gpu_memory(worker.gpu.ordinal)` via `gpu_worker`.
964                mold_inference::reclaim_gpu_memory(0);
965            }
966
967            // Take the engine out of cache to load in spawn_blocking. Using
968            // `take()` (not `remove()`) keeps the model name in the cache's
969            // `in_flight` set so concurrent `check_model_available` calls
970            // still see it as logically cached during the load window.
971            let cached = cache.take(model_name).ok_or_else(|| {
972                ApiError::internal(format!("cache race: model '{model_name}' vanished"))
973            })?;
974            drop(cache);
975
976            let mut engine = cached.engine;
977            if load_strategy == mold_inference::LoadStrategy::Sequential {
978                let Some(paths) = cached_paths else {
979                    let evicted = {
980                        let mut cache = state.model_cache.lock().await;
981                        cache.insert(engine, 0)
982                    };
983                    drop(evicted);
984                    return Err(ApiError::internal(format!(
985                        "cached engine for '{model_name}' does not expose model paths"
986                    )));
987                };
988                let config = state.config.read().await;
989                let offload = server_offload_enabled_for_paths(&paths, hint, request_has_lora);
990                let resolved_catalog_config =
991                    resolve_installed_catalog_paths_for_worker(model_name, &config)?
992                        .map(|(_, config)| config);
993                let engine_config = resolved_catalog_config.as_ref().unwrap_or(&config);
994                match mold_inference::create_engine_with_pool(
995                    model_name.to_string(),
996                    paths,
997                    engine_config,
998                    load_strategy,
999                    0,
1000                    offload,
1001                    Some(state.shared_pool.clone()),
1002                ) {
1003                    Ok(new_engine) => {
1004                        drop(config);
1005                        drop(engine);
1006                        engine = new_engine;
1007                    }
1008                    Err(e) => {
1009                        drop(config);
1010                        let evicted = {
1011                            let mut cache = state.model_cache.lock().await;
1012                            cache.insert(engine, 0)
1013                        };
1014                        drop(evicted);
1015                        return Err(ApiError::internal(format!(
1016                            "failed to recreate cached engine for '{model_name}': {e}"
1017                        )));
1018                    }
1019                }
1020            }
1021
1022            if let Some(callback) = progress.clone() {
1023                engine.set_on_progress(Box::new(move |event| {
1024                    callback(event);
1025                }));
1026            } else {
1027                engine.clear_on_progress();
1028            }
1029
1030            let model_log = model_name.to_string();
1031            #[cfg(feature = "metrics")]
1032            let load_start = std::time::Instant::now();
1033            // Sample VRAM baseline before load so we can record the new
1034            // model's per-load delta rather than the device-global usage.
1035            let vram_baseline = mold_inference::device::vram_in_use_bytes(0);
1036            let join_result = tokio::task::spawn_blocking(move || {
1037                tracing::info!(model = %model_log, "reloading cached engine...");
1038                if let Err(e) = engine.load() {
1039                    tracing::error!("model reload failed: {e:#}");
1040                    return Err((
1041                        ApiError::internal(format!("model reload error: {e}")),
1042                        engine,
1043                    ));
1044                }
1045                Ok(engine)
1046            })
1047            .await;
1048
1049            match join_result {
1050                Ok(Ok(loaded_engine)) => {
1051                    #[cfg(feature = "metrics")]
1052                    {
1053                        let duration = load_start.elapsed().as_secs_f64();
1054                        crate::metrics::record_model_load(model_name, duration);
1055                        crate::metrics::set_model_loaded(model_name);
1056                        let vram_est = mold_inference::device::vram_in_use_bytes(0);
1057                        crate::metrics::record_gpu_memory(vram_est);
1058                    }
1059                    let vram = mold_inference::device::vram_load_delta(0, vram_baseline);
1060                    // Insert under the cache lock, but drop the evicted engine
1061                    // OUTSIDE the lock — `cuMemFree` and safetensor unmap during
1062                    // `Box<dyn ...>` drop can block other cache users for hundreds
1063                    // of milliseconds. `insert` clears the in_flight marker.
1064                    let evicted = {
1065                        let mut cache = state.model_cache.lock().await;
1066                        cache.insert(loaded_engine, vram)
1067                    };
1068                    drop(evicted);
1069                }
1070                Ok(Err((api_err, unloaded_engine))) => {
1071                    // Put it back as unloaded so cache isn't corrupted. The
1072                    // unloaded engine has no GPU resources to free, but for
1073                    // consistency with the file's invariant — never drop an
1074                    // engine while holding the cache lock — bind and drop
1075                    // outside.
1076                    let evicted = {
1077                        let mut cache = state.model_cache.lock().await;
1078                        cache.insert(unloaded_engine, 0)
1079                    };
1080                    drop(evicted);
1081                    return Err(api_err);
1082                }
1083                Err(join_err) => {
1084                    // The blocking task aborted (panic that escaped the
1085                    // closure, runtime shutdown, etc.). Engine is gone —
1086                    // we can't restore it, so clear the in_flight marker
1087                    // explicitly. Without this, the model name leaks
1088                    // forever in `in_flight`: every subsequent
1089                    // `ensure_model_ready` fast-paths through
1090                    // `cache.contains()` (which still says true), then
1091                    // `cache.take()` returns None, and generation fails
1092                    // with "no engine available after model readiness
1093                    // check" indefinitely for this model.
1094                    {
1095                        let mut cache = state.model_cache.lock().await;
1096                        cache.clear_in_flight(model_name);
1097                    }
1098                    return Err(ApiError::internal(format!(
1099                        "model reload task failed: {join_err}"
1100                    )));
1101                }
1102            }
1103            return Ok(());
1104        }
1105    }
1106
1107    // Not in cache — check if model is available on disk.
1108    match check_model_available(state, model_name).await? {
1109        Some(paths) => create_and_load_engine(state, model_name, paths, progress, hint).await,
1110        None => Ok(()),
1111    }
1112}
1113
1114pub(crate) async fn pull_model(
1115    state: &AppState,
1116    model: &str,
1117    progress: Option<DownloadProgressCallback>,
1118) -> Result<PullStatus, ApiError> {
1119    if mold_core::manifest::find_manifest(&mold_core::manifest::resolve_model_name(model)).is_none()
1120    {
1121        return Err(ApiError::unknown_model(format!(
1122            "unknown model '{model}'. Run 'mold list' to see available models."
1123        )));
1124    }
1125
1126    let _guard = state.pull_lock.lock().await;
1127
1128    {
1129        let config = refresh_config(state).await;
1130        if config.manifest_model_is_downloaded(model) {
1131            return Ok(PullStatus::AlreadyAvailable);
1132        }
1133    }
1134
1135    tracing::info!(model = %model, "pulling model via API");
1136
1137    let opts = mold_core::download::PullOptions::default();
1138    let new_config = match progress {
1139        Some(callback) => {
1140            mold_core::download::pull_and_configure_with_callback(model, callback, &opts)
1141                .await
1142                .map(|(config, _)| config)
1143        }
1144        None => mold_core::download::pull_and_configure(model, &opts)
1145            .await
1146            .map(|(config, _)| config),
1147    }
1148    .map_err(|e| {
1149        tracing::error!("pull failed for {}: {e}", model);
1150        ApiError::internal(format!("failed to pull model '{}': {e}", model))
1151    })?;
1152
1153    {
1154        let mut config = state.config.write().await;
1155        *config = new_config;
1156    }
1157
1158    tracing::info!(model = %model, "pull complete");
1159    Ok(PullStatus::Pulled)
1160}
1161
1162/// Unload the active model from GPU. The engine remains in the cache (unloaded)
1163/// so it can be reloaded quickly on the next request.
1164pub(crate) async fn unload_model(state: &AppState) -> String {
1165    // Always clear the cached upscaler engine to free GPU memory,
1166    // regardless of whether a diffusion model is loaded.
1167    // Use try_lock() to avoid blocking the async runtime if an upscale
1168    // is in progress (the spawn_blocking thread holds this lock).
1169    if let Ok(mut upscaler) = state.upscaler_cache.try_lock() {
1170        if upscaler.is_some() {
1171            *upscaler = None;
1172            tracing::info!("upscaler cache cleared");
1173        }
1174    }
1175
1176    let mut cache = state.model_cache.lock().await;
1177    match cache.unload_active() {
1178        Some(name) => {
1179            #[cfg(feature = "metrics")]
1180            {
1181                crate::metrics::clear_model_loaded(&name);
1182                crate::metrics::record_gpu_memory(0);
1183            }
1184            drop(cache);
1185            // Legacy no-worker path only: hardcoded ordinal 0 is safe here
1186            // because `state.model_load_lock` (taken above) is the only
1187            // lock protecting GPU 0's primary context on this path — the
1188            // GpuPool path uses `worker.model_load_lock` and
1189            // `reclaim_gpu_memory(worker.gpu.ordinal)` via `gpu_worker`.
1190            mold_inference::reclaim_gpu_memory(0);
1191            tracing::info!(model = %name, "model unloaded via API");
1192            format!("unloaded {name}")
1193        }
1194        None => "no model loaded".to_string(),
1195    }
1196}
1197
1198async fn create_and_load_engine(
1199    state: &AppState,
1200    model_name: &str,
1201    paths: ModelPaths,
1202    progress: Option<EngineProgressCallback>,
1203    hint: Option<ActivationHint>,
1204) -> Result<(), ApiError> {
1205    // MPS memory guard: reject before unloading current model so it stays operational.
1206    // Include the active model's footprint as reclaimable memory.
1207    let active_vram = {
1208        let cache = state.model_cache.lock().await;
1209        cache.active_vram_bytes()
1210    };
1211    preflight_memory_guard(model_name, &paths, active_vram, 0, hint)?;
1212    let load_strategy = select_server_load_strategy_for_device(
1213        &paths,
1214        effective_load_available_bytes(active_vram, 0),
1215        mold_inference::device::total_vram_bytes(0),
1216        hint,
1217    );
1218    if load_strategy == mold_inference::LoadStrategy::Sequential {
1219        tracing::info!(
1220            model = %model_name,
1221            "server load strategy degraded to sequential to fit memory budget"
1222        );
1223    }
1224
1225    // Unload the current active model to free GPU memory.
1226    // Only reclaim GPU memory if there was an active model — calling
1227    // reclaim_gpu_memory() (CUDA primary context reset) when nothing was
1228    // loaded is unnecessary and may misbehave on some driver versions.
1229    let had_active = {
1230        let mut cache = state.model_cache.lock().await;
1231        let result = cache.unload_active();
1232        if let Some(ref name) = result {
1233            #[cfg(feature = "metrics")]
1234            crate::metrics::clear_model_loaded(name);
1235            tracing::info!(
1236                from = %name,
1237                to = %model_name,
1238                "unloading active model before loading new one"
1239            );
1240        }
1241        result.is_some()
1242    };
1243    if had_active {
1244        // Legacy no-worker path only: hardcoded ordinal 0 is safe here
1245        // because `state.model_load_lock` (taken above) is the only
1246        // lock protecting GPU 0's primary context on this path — the
1247        // GpuPool path uses `worker.model_load_lock` and
1248        // `reclaim_gpu_memory(worker.gpu.ordinal)` via `gpu_worker`.
1249        mold_inference::reclaim_gpu_memory(0);
1250    }
1251
1252    let config = state.config.read().await;
1253    let offload = server_offload_enabled_for_paths(&paths, hint, false);
1254    let mut new_engine = mold_inference::create_engine_with_pool(
1255        model_name.to_string(),
1256        paths,
1257        &config,
1258        load_strategy,
1259        0,
1260        offload,
1261        Some(state.shared_pool.clone()),
1262    )
1263    .map_err(|e| ApiError::internal(format!("failed to create engine for '{model_name}': {e}")))?;
1264    drop(config);
1265
1266    if let Some(callback) = progress {
1267        new_engine.set_on_progress(Box::new(move |event| {
1268            callback(event);
1269        }));
1270    } else {
1271        new_engine.clear_on_progress();
1272    }
1273
1274    let model_log = model_name.to_string();
1275    #[cfg(feature = "metrics")]
1276    let load_start = std::time::Instant::now();
1277    // Sample VRAM baseline before load so we can record the new model's
1278    // per-load delta rather than the device-global usage.
1279    let vram_baseline = mold_inference::device::vram_in_use_bytes(0);
1280    new_engine = tokio::task::spawn_blocking(move || {
1281        tracing::info!(model = %model_log, "loading model...");
1282        new_engine.load().map_err(|e| {
1283            tracing::error!("model load failed: {e:#}");
1284            ApiError::internal(format!("model load error: {e}"))
1285        })?;
1286        Ok::<_, ApiError>(new_engine)
1287    })
1288    .await
1289    .map_err(|e| ApiError::internal(format!("model load task failed: {e}")))??;
1290
1291    #[cfg(feature = "metrics")]
1292    {
1293        let duration = load_start.elapsed().as_secs_f64();
1294        crate::metrics::record_model_load(model_name, duration);
1295        crate::metrics::set_model_loaded(model_name);
1296    }
1297
1298    let vram = mold_inference::device::vram_load_delta(0, vram_baseline);
1299    #[cfg(feature = "metrics")]
1300    crate::metrics::record_gpu_memory(mold_inference::device::vram_in_use_bytes(0));
1301
1302    // Insert under the cache lock, but drop the evicted engine OUTSIDE the
1303    // lock — `cuMemFree` and safetensor unmap during `Box<dyn ...>` drop can
1304    // block other cache users for hundreds of milliseconds.
1305    let evicted = {
1306        let mut cache = state.model_cache.lock().await;
1307        cache.insert(new_engine, vram)
1308    };
1309    drop(evicted);
1310
1311    Ok(())
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316    use super::*;
1317    use std::path::PathBuf;
1318
1319    const GB: u64 = 1_000_000_000;
1320
1321    /// RAII guard for `MOLD_LTX2_GEMMA_DEVICE` (and the deprecated alias).
1322    /// Drop restores the prior values so adjacent tests don't see stale
1323    /// state. Cargo's parallel runner is serialized via a static mutex
1324    /// because env vars are process-global.
1325    struct Ltx2GemmaEnvGuard {
1326        _lock: std::sync::MutexGuard<'static, ()>,
1327        prior_main: Option<std::ffi::OsString>,
1328        prior_legacy: Option<std::ffi::OsString>,
1329    }
1330
1331    impl Drop for Ltx2GemmaEnvGuard {
1332        fn drop(&mut self) {
1333            unsafe {
1334                std::env::remove_var("MOLD_LTX2_GEMMA_DEVICE");
1335                std::env::remove_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
1336                if let Some(v) = self.prior_main.take() {
1337                    std::env::set_var("MOLD_LTX2_GEMMA_DEVICE", v);
1338                }
1339                if let Some(v) = self.prior_legacy.take() {
1340                    std::env::set_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER", v);
1341                }
1342            }
1343        }
1344    }
1345
1346    fn ltx2_gemma_env_guard(value: &str) -> Ltx2GemmaEnvGuard {
1347        use std::sync::{Mutex, OnceLock};
1348        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1349        let lock = LOCK
1350            .get_or_init(|| Mutex::new(()))
1351            .lock()
1352            .unwrap_or_else(|p| p.into_inner());
1353        let prior_main = std::env::var_os("MOLD_LTX2_GEMMA_DEVICE");
1354        let prior_legacy = std::env::var_os("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
1355        unsafe {
1356            std::env::remove_var("MOLD_LTX2_DEBUG_FORCE_CPU_PROMPT_ENCODER");
1357            std::env::set_var("MOLD_LTX2_GEMMA_DEVICE", value);
1358        }
1359        Ltx2GemmaEnvGuard {
1360            _lock: lock,
1361            prior_main,
1362            prior_legacy,
1363        }
1364    }
1365
1366    struct OffloadEnvGuard {
1367        _lock: std::sync::MutexGuard<'static, ()>,
1368        prior: Option<std::ffi::OsString>,
1369    }
1370
1371    impl Drop for OffloadEnvGuard {
1372        fn drop(&mut self) {
1373            unsafe {
1374                std::env::remove_var("MOLD_OFFLOAD");
1375                if let Some(v) = self.prior.take() {
1376                    std::env::set_var("MOLD_OFFLOAD", v);
1377                }
1378            }
1379        }
1380    }
1381
1382    fn offload_env_guard(value: &str) -> OffloadEnvGuard {
1383        use std::sync::{Mutex, OnceLock};
1384        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1385        let lock = LOCK
1386            .get_or_init(|| Mutex::new(()))
1387            .lock()
1388            .unwrap_or_else(|p| p.into_inner());
1389        let prior = std::env::var_os("MOLD_OFFLOAD");
1390        unsafe {
1391            std::env::set_var("MOLD_OFFLOAD", value);
1392        }
1393        OffloadEnvGuard { _lock: lock, prior }
1394    }
1395
1396    /// Build a `ModelPaths` whose `transformer` and `vae` files exist on disk
1397    /// with a combined size of `total_bytes`. `estimate_peak_memory()` reads
1398    /// file sizes via `std::fs::metadata`, so the on-disk footprint is what
1399    /// drives the composition under test.
1400    fn test_paths_with_total_size(total_bytes: u64) -> (tempfile::TempDir, ModelPaths) {
1401        let dir = tempfile::tempdir().expect("tempdir");
1402        let transformer = dir.path().join("transformer.safetensors");
1403        let vae = dir.path().join("vae.safetensors");
1404        // Split half-and-half between the two required files so that the
1405        // sum equals `total_bytes`. `set_len` creates a sparse file, which
1406        // is fast and reports the requested size via `metadata().len()`.
1407        let half = total_bytes / 2;
1408        let rest = total_bytes - half;
1409        let f1 = std::fs::File::create(&transformer).expect("create transformer");
1410        f1.set_len(half).expect("set transformer len");
1411        let f2 = std::fs::File::create(&vae).expect("create vae");
1412        f2.set_len(rest).expect("set vae len");
1413
1414        let paths = ModelPaths {
1415            transformer,
1416            transformer_shards: Vec::new(),
1417            vae,
1418            spatial_upscaler: None,
1419            temporal_upscaler: None,
1420            distilled_lora: None,
1421            t5_encoder: None,
1422            clip_encoder: None,
1423            t5_tokenizer: None,
1424            clip_tokenizer: None,
1425            clip_encoder_2: None,
1426            clip_tokenizer_2: None,
1427            text_encoder_files: Vec::new(),
1428            text_tokenizer: None,
1429            decoder: None,
1430        };
1431        (dir, paths)
1432    }
1433
1434    /// Sanity: confirm that `estimate_peak_memory` actually sees the on-disk
1435    /// sizes we set via `test_paths_with_total_size`. The peak includes a
1436    /// fixed headroom term added by the inference crate, so we just verify
1437    /// the lower bound matches our component total.
1438    #[test]
1439    fn test_paths_helper_sets_file_sizes() {
1440        let (_dir, paths) = test_paths_with_total_size(10 * GB);
1441        let peak = mold_inference::device::estimate_peak_memory(
1442            &paths,
1443            mold_inference::LoadStrategy::Eager,
1444        );
1445        assert!(
1446            peak >= 10 * GB,
1447            "expected peak >= 10 GB component sum, got {peak}"
1448        );
1449        // The transformer and vae paths must point to real files.
1450        assert!(PathBuf::from(&paths.transformer).exists());
1451        assert!(PathBuf::from(&paths.vae).exists());
1452    }
1453
1454    /// Composition test: peak fits in `(available + active)` but not in
1455    /// `available` alone — the inner guard must let the swap proceed.
1456    #[test]
1457    fn preflight_uses_active_vram_as_reclaimable() {
1458        // 10 GB on disk → peak ≈ 10 GB + headroom.
1459        // Available 8 GB alone is insufficient; add 10 GB active VRAM →
1460        // 18 GB effective, comfortably above peak.
1461        let (_dir, paths) = test_paths_with_total_size(10 * GB);
1462        let result =
1463            preflight_memory_guard_with_available("swap-test", &paths, 10 * GB, 8 * GB, None);
1464        assert!(
1465            result.is_ok(),
1466            "expected swap to succeed with reclaimable VRAM, got {result:?}"
1467        );
1468    }
1469
1470    /// Composition test: peak exceeds even the post-swap budget — the inner
1471    /// guard must reject.
1472    #[test]
1473    fn preflight_rejects_when_peak_exceeds_effective_available() {
1474        // 20 GB on disk → peak ≥ 20 GB. Available 8 GB + 5 GB active =
1475        // 13 GB effective < peak → reject.
1476        let (_dir, paths) = test_paths_with_total_size(20 * GB);
1477        let result = preflight_memory_guard_with_available("too-big", &paths, 5 * GB, 8 * GB, None);
1478        assert!(
1479            result.is_err(),
1480            "expected oversized model to be rejected, got Ok"
1481        );
1482    }
1483
1484    #[test]
1485    fn memory_guard_ok_when_plenty_of_memory() {
1486        assert!(check_model_memory_budget("test-model", 5 * GB, 20 * GB, "").is_ok());
1487    }
1488
1489    #[test]
1490    fn memory_guard_rejects_over_90pct() {
1491        let result = check_model_memory_budget("flux-dev:bf16", 19 * GB, 20 * GB, "Try --offload.");
1492        assert!(result.is_err());
1493        let err = result.unwrap_err();
1494        assert_eq!(err.code, "INSUFFICIENT_MEMORY");
1495        assert!(err.error.contains("flux-dev:bf16"));
1496        assert!(err.error.contains("budget cap"));
1497    }
1498
1499    #[test]
1500    fn memory_guard_ok_at_90pct_boundary() {
1501        // 18 GB peak, 20 GB available → 90% exactly → should pass
1502        assert!(check_model_memory_budget("test", 18 * GB, 20 * GB, "").is_ok());
1503    }
1504
1505    #[test]
1506    fn memory_guard_ok_in_warn_zone() {
1507        // 17 GB peak, 20 GB available → 85% → passes but would warn
1508        assert!(check_model_memory_budget("test", 17 * GB, 20 * GB, "").is_ok());
1509    }
1510
1511    #[test]
1512    fn memory_guard_ok_below_warn_zone() {
1513        // 15 GB peak, 20 GB available → 75% → no warn, no error
1514        assert!(check_model_memory_budget("test", 15 * GB, 20 * GB, "").is_ok());
1515    }
1516
1517    #[test]
1518    fn memory_guard_rejects_tiny_available() {
1519        // Model larger than total available
1520        let result = check_model_memory_budget("huge-model", 30 * GB, 16 * GB, "");
1521        assert!(result.is_err());
1522    }
1523
1524    /// CUDA branch math: when free VRAM is small but the active model can be
1525    /// reclaimed, `effective_available = free + active_vram` should let the
1526    /// new model load. Without the additive term, a swap of two near-
1527    /// equal-size models on a fully-loaded GPU would always be rejected.
1528    #[test]
1529    fn memory_guard_swap_uses_active_vram_as_reclaimable() {
1530        // Free VRAM is only 2 GB but the currently-loaded model occupies
1531        // 18 GB which becomes available on swap → effective = 20 GB.
1532        // A 15 GB peak model fits comfortably (<= 90% of 20 GB).
1533        let free_vram = 2 * GB;
1534        let active_vram = 18 * GB;
1535        let effective = free_vram + active_vram;
1536        assert!(check_model_memory_budget("swap-target", 15 * GB, effective, "").is_ok());
1537    }
1538
1539    /// Verifies the swap math also rejects when the swap is genuinely
1540    /// infeasible — peak exceeds even the post-swap budget.
1541    #[test]
1542    fn memory_guard_swap_still_rejects_when_oversized() {
1543        // Free 1 GB + active 8 GB = 9 GB effective. A 15 GB model can't fit
1544        // even after the active model is unloaded.
1545        let free_vram = GB;
1546        let active_vram = 8 * GB;
1547        let effective = free_vram + active_vram;
1548        assert!(check_model_memory_budget("too-large", 15 * GB, effective, "").is_err());
1549    }
1550
1551    /// Build a `ModelPaths` whose components include text encoders, mirroring a
1552    /// real FLUX-family layout. Used to verify the Sequential-strategy peak
1553    /// estimate doesn't sum the encoder onto the transformer (the bug fix).
1554    fn flux_shaped_paths_with_sizes(
1555        transformer_gb: u64,
1556        vae_gb: u64,
1557        t5_gb: u64,
1558        clip_gb: u64,
1559    ) -> (tempfile::TempDir, ModelPaths) {
1560        let dir = tempfile::tempdir().expect("tempdir");
1561        let mk = |name: &str, sz: u64| {
1562            let p = dir.path().join(name);
1563            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1564            let f = std::fs::File::create(&p).unwrap();
1565            f.set_len(sz * GB).unwrap();
1566            p
1567        };
1568        let transformer = mk("transformer.safetensors", transformer_gb);
1569        let vae = mk("vae.safetensors", vae_gb);
1570        let t5 = mk("t5.safetensors", t5_gb);
1571        let clip = mk("clip.safetensors", clip_gb);
1572        let paths = ModelPaths {
1573            transformer,
1574            transformer_shards: Vec::new(),
1575            vae,
1576            spatial_upscaler: None,
1577            temporal_upscaler: None,
1578            distilled_lora: None,
1579            t5_encoder: Some(t5),
1580            clip_encoder: Some(clip),
1581            t5_tokenizer: None,
1582            clip_tokenizer: None,
1583            clip_encoder_2: None,
1584            clip_tokenizer_2: None,
1585            text_encoder_files: Vec::new(),
1586            text_tokenizer: None,
1587            decoder: None,
1588        };
1589        (dir, paths)
1590    }
1591
1592    /// Regression: a quantized FLUX-shaped model should fit on a 24 GB card
1593    /// when the sibling model is unloaded and the context reset, even though
1594    /// the Eager (sum) peak would have been ~24 GB and tripped the 90 %
1595    /// hard limit.
1596    ///
1597    /// Concrete shape: FLUX-dev:q8 → transformer ≈ 12 GB, VAE ≈ 0.3 GB,
1598    /// T5 ≈ 9.5 GB, CLIP ≈ 0.25 GB. Eager peak = 12+0.3+9.5+0.25+2 ≈ 24 GB
1599    /// (rejects at 90 % of 24 GB = 21.6). Sequential peak =
1600    /// max(9.75, 12.3) + 2 ≈ 14.3 GB (passes comfortably).
1601    #[test]
1602    fn preflight_passes_for_quantized_flux_on_24gb_card_with_swap() {
1603        // Use whole-GB sizes to match the helper's u64 parameters; the
1604        // composition is realistic enough to exercise Eager-vs-Sequential
1605        // divergence (encoder + transformer both > headroom).
1606        let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
1607        // Free 4 GB on a 24 GB card with an 18 GB sibling about to be
1608        // reclaimed → effective_available passed in by the outer guard
1609        // is total_vram = 24 GB on CUDA, but we test the inner directly with
1610        // the Sequential strategy in mind.
1611        let result = preflight_memory_guard_with_available("flux-dev:q8", &paths, 0, 24 * GB, None);
1612        assert!(
1613            result.is_ok(),
1614            "quantized FLUX must fit on a 24 GB card under the Sequential \
1615             peak estimate (drop-and-reload encoders), got {result:?}"
1616        );
1617    }
1618
1619    #[test]
1620    fn preflight_accepts_forced_flux_offload_bf16_layout_on_24gb() {
1621        let _guard = offload_env_guard("1");
1622        let (_dir, paths) = flux_shaped_paths_with_sizes(24, 1, 10, 1);
1623        let hint = ActivationHint {
1624            width: 1024,
1625            height: 1024,
1626            batch: 1,
1627            dtype_bytes: 2,
1628            family: ActivationFamily::FluxDit,
1629        };
1630
1631        let result =
1632            preflight_memory_guard_with_available("flux-dev:bf16", &paths, 0, 24 * GB, Some(hint));
1633
1634        assert!(
1635            result.is_ok(),
1636            "forced FLUX offload should use streaming-aware peak instead of \
1637            full BF16 transformer residency, got {result:?}"
1638        );
1639    }
1640
1641    #[test]
1642    fn preflight_accepts_large_flux_bf16_auto_offload_on_24gb() {
1643        let _guard = offload_env_guard("0");
1644        let (_dir, paths) = flux_shaped_paths_with_sizes(23, 1, 9, 1);
1645        let hint = ActivationHint {
1646            width: 1024,
1647            height: 1024,
1648            batch: 1,
1649            dtype_bytes: 2,
1650            family: ActivationFamily::FluxDit,
1651        };
1652
1653        let result = preflight_memory_guard_with_available(
1654            "cv:2319074",
1655            &paths,
1656            0,
1657            24_500_000_000,
1658            Some(hint),
1659        );
1660
1661        assert!(
1662            result.is_ok(),
1663            "large FLUX BF16 checkpoints should be admitted on 24 GB cards via \
1664             automatic block offload instead of being rejected by resident \
1665             transformer peak math, got {result:?}"
1666        );
1667    }
1668
1669    #[test]
1670    fn server_auto_enables_offload_for_large_flux_bf16_without_env() {
1671        let _guard = offload_env_guard("0");
1672        let (_dir, paths) = flux_shaped_paths_with_sizes(23, 1, 9, 1);
1673        let hint = ActivationHint {
1674            width: 1024,
1675            height: 1024,
1676            batch: 1,
1677            dtype_bytes: 2,
1678            family: ActivationFamily::FluxDit,
1679        };
1680
1681        assert!(
1682            server_offload_enabled_for_paths(&paths, Some(hint), false),
1683            "large FLUX BF16 checkpoints should load with block offload even \
1684             when MOLD_OFFLOAD is not globally forced"
1685        );
1686    }
1687
1688    fn sd3_gguf_paths_with_monolithic_vae(
1689        transformer_gb: u64,
1690        vae_gb: u64,
1691        t5_gb: u64,
1692        clip_l_gb: u64,
1693        clip_g_gb: u64,
1694    ) -> (tempfile::TempDir, ModelPaths) {
1695        let dir = tempfile::tempdir().expect("tempdir");
1696        let mk = |name: &str, sz: u64| {
1697            let p = dir.path().join(name);
1698            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1699            let f = std::fs::File::create(&p).unwrap();
1700            f.set_len(sz * GB).unwrap();
1701            p
1702        };
1703        let transformer = mk("sd3.5_large-Q8_0.gguf", transformer_gb);
1704        let vae = mk("sd3.5_large.safetensors", vae_gb);
1705        let t5 = mk("t5xxl_fp16.safetensors", t5_gb);
1706        let clip_l = mk("clip_l.safetensors", clip_l_gb);
1707        let clip_g = mk("clip_g.safetensors", clip_g_gb);
1708        let paths = ModelPaths {
1709            transformer,
1710            transformer_shards: Vec::new(),
1711            vae,
1712            spatial_upscaler: None,
1713            temporal_upscaler: None,
1714            distilled_lora: None,
1715            t5_encoder: Some(t5),
1716            clip_encoder: Some(clip_l),
1717            t5_tokenizer: None,
1718            clip_tokenizer: None,
1719            clip_encoder_2: Some(clip_g),
1720            clip_tokenizer_2: None,
1721            text_encoder_files: Vec::new(),
1722            text_tokenizer: None,
1723            decoder: None,
1724        };
1725        (dir, paths)
1726    }
1727
1728    #[test]
1729    fn preflight_accepts_sd3_gguf_with_monolithic_vae_on_24gb() {
1730        let (_dir, paths) = sd3_gguf_paths_with_monolithic_vae(9, 16, 10, 1, 1);
1731        let hint = ActivationHint {
1732            width: 1024,
1733            height: 1024,
1734            batch: 2,
1735            dtype_bytes: 2,
1736            family: ActivationFamily::Sd3Mmdit,
1737        };
1738
1739        let result =
1740            preflight_memory_guard_with_available("sd3.5-large:q8", &paths, 0, 24 * GB, Some(hint));
1741
1742        assert!(
1743            result.is_ok(),
1744            "SD3 GGUF should not count the monolithic VAE checkpoint as \
1745             co-resident with the transformer, got {result:?}"
1746        );
1747    }
1748
1749    #[test]
1750    fn server_load_strategy_keeps_sd3_gguf_eager() {
1751        let (_dir, paths) = sd3_gguf_paths_with_monolithic_vae(9, 16, 10, 1, 1);
1752        let hint = ActivationHint {
1753            width: 1024,
1754            height: 1024,
1755            batch: 2,
1756            dtype_bytes: 2,
1757            family: ActivationFamily::Sd3Mmdit,
1758        };
1759
1760        let strategy = select_server_load_strategy_for_budget(&paths, Some(32 * GB), Some(hint));
1761
1762        assert_eq!(
1763            strategy,
1764            mold_inference::LoadStrategy::Eager,
1765            "SD3 GGUF has its own quantized runtime path; selecting Sequential \
1766             asks the runtime for unsupported block offload"
1767        );
1768    }
1769
1770    fn zimage_gguf_paths(
1771        transformer_gb: u64,
1772        vae_gb: u64,
1773        text_encoder_gb: u64,
1774    ) -> (tempfile::TempDir, ModelPaths) {
1775        let dir = tempfile::tempdir().expect("tempdir");
1776        let mk = |name: &str, sz: u64| {
1777            let p = dir.path().join(name);
1778            let f = std::fs::File::create(&p).unwrap();
1779            f.set_len(sz * GB).unwrap();
1780            p
1781        };
1782        let transformer = mk("z-image-turbo-Q8_0.gguf", transformer_gb);
1783        let vae = mk("vae.safetensors", vae_gb);
1784        let text_encoder = mk("qwen3.safetensors", text_encoder_gb);
1785        let paths = ModelPaths {
1786            transformer,
1787            transformer_shards: Vec::new(),
1788            vae,
1789            spatial_upscaler: None,
1790            temporal_upscaler: None,
1791            distilled_lora: None,
1792            t5_encoder: None,
1793            clip_encoder: None,
1794            t5_tokenizer: None,
1795            clip_tokenizer: None,
1796            clip_encoder_2: None,
1797            clip_tokenizer_2: None,
1798            text_encoder_files: vec![text_encoder],
1799            text_tokenizer: None,
1800            decoder: None,
1801        };
1802        (dir, paths)
1803    }
1804
1805    #[test]
1806    fn server_load_strategy_keeps_zimage_gguf_eager() {
1807        let (_dir, paths) = zimage_gguf_paths(12, 1, 8);
1808        let hint = ActivationHint {
1809            width: 1024,
1810            height: 1024,
1811            batch: 1,
1812            dtype_bytes: 2,
1813            family: ActivationFamily::ZImageDit,
1814        };
1815
1816        let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
1817
1818        assert_eq!(
1819            strategy,
1820            mold_inference::LoadStrategy::Eager,
1821            "Z-Image GGUF has a quantized/dense runtime path; selecting Sequential \
1822             asks the runtime for unsupported block offload"
1823        );
1824    }
1825
1826    #[test]
1827    fn offload_env_is_ignored_for_sd3_gguf() {
1828        let _guard = offload_env_guard("1");
1829        let (_dir, paths) = sd3_gguf_paths_with_monolithic_vae(9, 16, 10, 1, 1);
1830        let hint = ActivationHint {
1831            width: 1024,
1832            height: 1024,
1833            batch: 2,
1834            dtype_bytes: 2,
1835            family: ActivationFamily::Sd3Mmdit,
1836        };
1837
1838        assert!(
1839            !server_offload_enabled_for_paths(&paths, Some(hint), false),
1840            "global MOLD_OFFLOAD must not force unsupported SD3 GGUF block offload"
1841        );
1842    }
1843
1844    #[test]
1845    fn offload_env_is_ignored_for_zimage_gguf() {
1846        let _guard = offload_env_guard("1");
1847        let (_dir, paths) = zimage_gguf_paths(12, 1, 8);
1848        let hint = ActivationHint {
1849            width: 1024,
1850            height: 1024,
1851            batch: 1,
1852            dtype_bytes: 2,
1853            family: ActivationFamily::ZImageDit,
1854        };
1855
1856        assert!(
1857            !server_offload_enabled_for_paths(&paths, Some(hint), false),
1858            "global MOLD_OFFLOAD must not force unsupported Z-Image GGUF block offload"
1859        );
1860    }
1861
1862    #[test]
1863    fn offload_env_is_preserved_for_zimage_bf16() {
1864        let _guard = offload_env_guard("1");
1865        let (_dir, paths) = flux_shaped_paths_with_sizes(6, 1, 8, 0);
1866        let hint = ActivationHint {
1867            width: 1024,
1868            height: 1024,
1869            batch: 1,
1870            dtype_bytes: 2,
1871            family: ActivationFamily::ZImageDit,
1872        };
1873
1874        assert!(
1875            server_offload_enabled_for_paths(&paths, Some(hint), false),
1876            "BF16/FP Z-Image paths should still receive explicit offload"
1877        );
1878    }
1879
1880    #[test]
1881    fn offload_env_is_ignored_for_zimage_lora_with_ambiguous_family_hint() {
1882        let _guard = offload_env_guard("1");
1883        let dir = tempfile::tempdir().expect("tempdir");
1884        let mk = |name: &str, sz: u64| {
1885            let p = dir.path().join(name);
1886            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1887            let f = std::fs::File::create(&p).unwrap();
1888            f.set_len(sz * GB).unwrap();
1889            p
1890        };
1891        let paths = ModelPaths {
1892            transformer: mk("z-image/civitai/2442439/zImageTurbo_turbo.safetensors", 12),
1893            transformer_shards: Vec::new(),
1894            vae: mk("z-image/civitai/2442439/ae_zimgturbo.safetensors", 1),
1895            spatial_upscaler: None,
1896            temporal_upscaler: None,
1897            distilled_lora: None,
1898            t5_encoder: None,
1899            clip_encoder: None,
1900            t5_tokenizer: None,
1901            clip_tokenizer: None,
1902            clip_encoder_2: None,
1903            clip_tokenizer_2: None,
1904            text_encoder_files: vec![mk(
1905                "z-image/civitai/2442439/zImageTurbo_turbo_txt.safetensors",
1906                8,
1907            )],
1908            text_tokenizer: None,
1909            decoder: None,
1910        };
1911        let hint = ActivationHint {
1912            width: 1024,
1913            height: 1024,
1914            batch: 1,
1915            dtype_bytes: 2,
1916            family: ActivationFamily::FluxDit,
1917        };
1918
1919        assert!(
1920            !server_offload_enabled_for_paths(&paths, Some(hint), true),
1921            "Z-Image LoRA requests must not receive global MOLD_OFFLOAD even \
1922             when duplicate catalog rows provide an ambiguous Flux hint"
1923        );
1924    }
1925
1926    #[test]
1927    fn offload_env_is_ignored_for_flux2_lora_request() {
1928        let _guard = offload_env_guard("1");
1929        let (_dir, paths) = flux2_klein9b_bf16_paths();
1930        let hint = ActivationHint {
1931            width: 1024,
1932            height: 1024,
1933            batch: 1,
1934            dtype_bytes: 2,
1935            family: ActivationFamily::Flux2Dit,
1936        };
1937
1938        assert!(
1939            !server_offload_enabled_for_paths(&paths, Some(hint), true),
1940            "global MOLD_OFFLOAD must not force Flux.2 block offload for LoRA \
1941             requests because Flux.2 offload+LoRA is not supported"
1942        );
1943    }
1944
1945    #[test]
1946    fn offload_env_is_ignored_for_flux2_lora_with_ambiguous_family_hint() {
1947        let _guard = offload_env_guard("1");
1948        let dir = tempfile::tempdir().expect("tempdir");
1949        let mk = |name: &str, sz: u64| {
1950            let p = dir.path().join(name);
1951            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1952            let f = std::fs::File::create(&p).unwrap();
1953            f.set_len(sz * GB).unwrap();
1954            p
1955        };
1956        let transformer = mk(
1957            "flux2/civitai/2669986/darkBeast_dbkBlitzV15.safetensors",
1958            18,
1959        );
1960        let paths = ModelPaths {
1961            transformer: transformer.clone(),
1962            transformer_shards: vec![transformer],
1963            vae: mk("flux2/civitai/2669986/flux2-vae.safetensors", 1),
1964            spatial_upscaler: None,
1965            temporal_upscaler: None,
1966            distilled_lora: None,
1967            t5_encoder: None,
1968            clip_encoder: None,
1969            t5_tokenizer: None,
1970            clip_tokenizer: None,
1971            clip_encoder_2: None,
1972            clip_tokenizer_2: None,
1973            text_encoder_files: vec![mk("flux2/civitai/2669986/qwen3.safetensors", 16)],
1974            text_tokenizer: None,
1975            decoder: None,
1976        };
1977        let hint = ActivationHint {
1978            width: 1024,
1979            height: 1024,
1980            batch: 1,
1981            dtype_bytes: 2,
1982            family: ActivationFamily::FluxDit,
1983        };
1984
1985        assert!(
1986            !server_offload_enabled_for_paths(&paths, Some(hint), true),
1987            "Flux.2 LoRA requests must not receive global MOLD_OFFLOAD even \
1988             when the catalog family hint is missing or ambiguous"
1989        );
1990    }
1991
1992    #[test]
1993    fn flux2_lora_request_requires_fresh_engine_when_plain_offload_was_enabled() {
1994        let _guard = offload_env_guard("1");
1995        let dir = tempfile::tempdir().expect("tempdir");
1996        let mk = |name: &str, sz: u64| {
1997            let p = dir.path().join(name);
1998            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1999            let f = std::fs::File::create(&p).unwrap();
2000            f.set_len(sz * GB).unwrap();
2001            p
2002        };
2003        let paths = ModelPaths {
2004            transformer: mk(
2005                "flux2/civitai/2669986/darkBeast_dbkBlitzV15.safetensors",
2006                18,
2007            ),
2008            transformer_shards: Vec::new(),
2009            vae: mk("flux2/civitai/2669986/flux2-vae.safetensors", 1),
2010            spatial_upscaler: None,
2011            temporal_upscaler: None,
2012            distilled_lora: None,
2013            t5_encoder: None,
2014            clip_encoder: None,
2015            t5_tokenizer: None,
2016            clip_tokenizer: None,
2017            clip_encoder_2: None,
2018            clip_tokenizer_2: None,
2019            text_encoder_files: vec![mk("flux2/civitai/2669986/qwen3.safetensors", 16)],
2020            text_tokenizer: None,
2021            decoder: None,
2022        };
2023        let hint = ActivationHint {
2024            width: 1024,
2025            height: 1024,
2026            batch: 1,
2027            dtype_bytes: 2,
2028            family: ActivationFamily::Flux2Dit,
2029        };
2030
2031        assert!(
2032            request_requires_fresh_engine_for_offload_policy(&paths, Some(hint), true),
2033            "a cached Flux.2 engine loaded for plain offload must be recreated \
2034             before serving a LoRA request, otherwise the runtime still sees \
2035             offload+LoRA"
2036        );
2037    }
2038
2039    #[test]
2040    fn offload_env_is_preserved_for_plain_flux2_request() {
2041        let _guard = offload_env_guard("1");
2042        let (_dir, paths) = flux2_klein9b_bf16_paths();
2043        let hint = ActivationHint {
2044            width: 1024,
2045            height: 1024,
2046            batch: 1,
2047            dtype_bytes: 2,
2048            family: ActivationFamily::Flux2Dit,
2049        };
2050
2051        assert!(
2052            server_offload_enabled_for_paths(&paths, Some(hint), false),
2053            "plain Flux.2 requests should still receive explicit offload"
2054        );
2055    }
2056
2057    #[test]
2058    fn offload_env_is_ignored_for_flux2_gguf() {
2059        let _guard = offload_env_guard("1");
2060        let (dir, mut paths) = flux2_klein9b_bf16_paths();
2061        let gguf = dir.path().join("flux2-klein-9b-q8.gguf");
2062        std::fs::File::create(&gguf)
2063            .unwrap()
2064            .set_len(12 * GB)
2065            .unwrap();
2066        paths.transformer = gguf;
2067        paths.transformer_shards.clear();
2068        let hint = ActivationHint {
2069            width: 1024,
2070            height: 1024,
2071            batch: 1,
2072            dtype_bytes: 2,
2073            family: ActivationFamily::Flux2Dit,
2074        };
2075
2076        assert!(
2077            !server_offload_enabled_for_paths(&paths, Some(hint), false),
2078            "global MOLD_OFFLOAD must not force Flux.2 GGUF block offload \
2079             because GGUF variants use quantized transformer paths"
2080        );
2081    }
2082
2083    #[test]
2084    fn offload_env_is_ignored_for_flux2_nvfp4() {
2085        let _guard = offload_env_guard("1");
2086        let dir = tempfile::tempdir().expect("tempdir");
2087        let mk = |name: &str, sz: u64| {
2088            let p = dir.path().join(name);
2089            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2090            let f = std::fs::File::create(&p).unwrap();
2091            f.set_len(sz * GB).unwrap();
2092            p
2093        };
2094        let paths = ModelPaths {
2095            transformer: mk(
2096                "flux2/civitai/2759597/miracleinNSFWGeneration_10Nvfp4.safetensors",
2097                18,
2098            ),
2099            transformer_shards: Vec::new(),
2100            vae: mk("flux2/civitai/2759597/flux2-vae.safetensors", 1),
2101            spatial_upscaler: None,
2102            temporal_upscaler: None,
2103            distilled_lora: None,
2104            t5_encoder: None,
2105            clip_encoder: None,
2106            t5_tokenizer: None,
2107            clip_tokenizer: None,
2108            clip_encoder_2: None,
2109            clip_tokenizer_2: None,
2110            text_encoder_files: vec![mk("flux2/civitai/2759597/qwen3.safetensors", 8)],
2111            text_tokenizer: None,
2112            decoder: None,
2113        };
2114        let hint = ActivationHint {
2115            width: 1024,
2116            height: 1024,
2117            batch: 1,
2118            dtype_bytes: 2,
2119            family: ActivationFamily::Flux2Dit,
2120        };
2121
2122        assert!(
2123            !server_offload_enabled_for_paths(&paths, Some(hint), false),
2124            "global MOLD_OFFLOAD must not force Flux.2 NVFP4 block offload \
2125             because the NVFP4 streaming linear path is the memory-control mechanism"
2126        );
2127    }
2128
2129    fn qwen_image_q8_paths(
2130        transformer_gb: u64,
2131        vae_gb: u64,
2132        text_encoder_gb: u64,
2133    ) -> (tempfile::TempDir, ModelPaths) {
2134        let dir = tempfile::tempdir().expect("tempdir");
2135        let mk = |name: &str, sz: u64| {
2136            let p = dir.path().join(name);
2137            let f = std::fs::File::create(&p).unwrap();
2138            f.set_len(sz * GB).unwrap();
2139            p
2140        };
2141        let transformer = mk("qwen-image-Q8_0.gguf", transformer_gb);
2142        let vae = mk("qwen-image-vae.safetensors", vae_gb);
2143        let text_encoder = mk("qwen2.5-vl.safetensors", text_encoder_gb);
2144        let paths = ModelPaths {
2145            transformer,
2146            transformer_shards: Vec::new(),
2147            vae,
2148            spatial_upscaler: None,
2149            temporal_upscaler: None,
2150            distilled_lora: None,
2151            t5_encoder: None,
2152            clip_encoder: None,
2153            t5_tokenizer: None,
2154            clip_tokenizer: None,
2155            clip_encoder_2: None,
2156            clip_tokenizer_2: None,
2157            text_encoder_files: vec![text_encoder],
2158            text_tokenizer: None,
2159            decoder: None,
2160        };
2161        (dir, paths)
2162    }
2163
2164    #[test]
2165    fn preflight_accepts_quantized_qwen_image_q8_on_24gb() {
2166        let (_dir, paths) = qwen_image_q8_paths(21, 1, 16);
2167        let hint = ActivationHint {
2168            width: 1024,
2169            height: 1024,
2170            batch: 2,
2171            dtype_bytes: 2,
2172            family: ActivationFamily::QwenImageDit,
2173        };
2174
2175        let result =
2176            preflight_memory_guard_with_available("qwen-image:q8", &paths, 0, 24 * GB, Some(hint));
2177
2178        assert!(
2179            result.is_ok(),
2180            "Qwen-Image GGUF Q8 should be admitted on 24 GB because the runtime \
2181             uses split-CFG and staged text/VAE phases instead of the generic \
2182             full-headroom sequential estimate, got {result:?}"
2183        );
2184    }
2185
2186    #[test]
2187    fn server_load_strategy_uses_sequential_for_zimage_requests() {
2188        let (_dir, paths) = flux_shaped_paths_with_sizes(6, 1, 8, 0);
2189        let hint = ActivationHint {
2190            width: 1024,
2191            height: 1024,
2192            batch: 1,
2193            dtype_bytes: 2,
2194            family: ActivationFamily::ZImageDit,
2195        };
2196
2197        let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
2198
2199        assert_eq!(
2200            strategy,
2201            mold_inference::LoadStrategy::Sequential,
2202            "Z-Image server requests should use staged loading so base/source/LoRA \
2203             share the same memory contract"
2204        );
2205    }
2206
2207    /// Companion: under the *old* Eager-strategy math the same model would
2208    /// have been rejected. Verifying explicitly so a regression that flips
2209    /// the strategy back gets caught.
2210    #[test]
2211    fn eager_strategy_would_have_rejected_quantized_flux_on_24gb() {
2212        let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
2213        let eager_peak = mold_inference::device::estimate_peak_memory(
2214            &paths,
2215            mold_inference::LoadStrategy::Eager,
2216        );
2217        // 12 + 1 + 10 + 1 + 2 GB headroom = 26 GB → above 90 % of 24 GB.
2218        let hard_limit = (24 * GB) * 9 / 10;
2219        assert!(
2220            eager_peak > hard_limit,
2221            "Eager peak ({eager_peak}) should exceed hard limit ({hard_limit}) — \
2222            this is the false-rejection the Sequential switch fixes"
2223        );
2224    }
2225
2226    #[test]
2227    fn server_load_strategy_degrades_when_only_sequential_fits() {
2228        let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
2229        let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), None);
2230
2231        assert_eq!(
2232            strategy,
2233            mold_inference::LoadStrategy::Sequential,
2234            "server load should match the sequential preflight assumption instead \
2235             of eager-loading a model whose summed components exceed the budget"
2236        );
2237    }
2238
2239    #[test]
2240    fn server_load_strategy_stays_eager_when_eager_fits() {
2241        let (_dir, paths) = flux_shaped_paths_with_sizes(8, 1, 2, 1);
2242        let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), None);
2243
2244        assert_eq!(strategy, mold_inference::LoadStrategy::Eager);
2245    }
2246
2247    #[test]
2248    fn server_load_strategy_stays_eager_when_no_budget_available() {
2249        let (_dir, paths) = flux_shaped_paths_with_sizes(12, 1, 10, 1);
2250        let strategy = select_server_load_strategy_for_budget(&paths, None, None);
2251
2252        assert_eq!(strategy, mold_inference::LoadStrategy::Eager);
2253    }
2254
2255    /// Tier 2.3: the server-side preflight must consume the
2256    /// resolution-scaled activation budget. A model that fits at 768²
2257    /// (where the activation budget is the 256 MB floor) must be rejected
2258    /// at 2048² (where the budget grows past 1 GB) on the same card.
2259    #[test]
2260    fn preflight_memory_guard_accepts_resolution_for_activation_budget() {
2261        let _guard = offload_env_guard("0");
2262        // Shape: 19 GB transformer, 1 GB VAE, 9 GB T5, 1 GB CLIP. Sequential
2263        // peak = max(10, 20) + 2 GB headroom = 22 GB. On a 25 GB card the
2264        // 90 % hard limit is 22.5 GB:
2265        //   * 768²:  22 + 0.256 (floor) = 22.256 ≤ 22.5 → accept
2266        //   * 2048²: 22 + 1.09          = 23.09  > 22.5 → reject
2267        // Without the activation hint both would land at 22 GB and accept.
2268        let (_dir, paths) = flux_shaped_paths_with_sizes(19, 1, 9, 1);
2269
2270        let hint_768 = ActivationHint {
2271            width: 768,
2272            height: 768,
2273            batch: 1,
2274            dtype_bytes: 2,
2275            family: ActivationFamily::FluxDit,
2276        };
2277        let hint_2048 = ActivationHint {
2278            width: 2048,
2279            height: 2048,
2280            batch: 1,
2281            dtype_bytes: 2,
2282            family: ActivationFamily::FluxDit,
2283        };
2284
2285        let card_total = 25 * GB;
2286        let result_768 = preflight_memory_guard_with_available(
2287            "flux-dev",
2288            &paths,
2289            0,
2290            card_total,
2291            Some(hint_768),
2292        );
2293        let result_2048 = preflight_memory_guard_with_available(
2294            "flux-dev",
2295            &paths,
2296            0,
2297            card_total,
2298            Some(hint_2048),
2299        );
2300
2301        assert!(
2302            result_768.is_ok(),
2303            "768² FLUX should fit on 30 GB (small activation budget), got {result_768:?}"
2304        );
2305        assert!(
2306            result_2048.is_err(),
2307            "2048² FLUX must be rejected on 30 GB (large activation budget pushes \
2308            peak past 90 % cap), got {result_2048:?}"
2309        );
2310    }
2311
2312    fn flux2_klein9b_bf16_paths() -> (tempfile::TempDir, ModelPaths) {
2313        let dir = tempfile::tempdir().expect("tempdir");
2314        let mk = |name: &str, sz: u64| {
2315            let p = dir.path().join(name);
2316            let f = std::fs::File::create(&p).unwrap();
2317            f.set_len(sz * GB).unwrap();
2318            p
2319        };
2320        let shard_a = mk("diffusion_pytorch_model-00001-of-00002.safetensors", 10);
2321        let shard_b = mk("diffusion_pytorch_model-00002-of-00002.safetensors", 8);
2322        let vae = mk("flux2-vae.safetensors", 1);
2323        let te_a = mk("text_encoder-00001-of-00004.safetensors", 5);
2324        let te_b = mk("text_encoder-00002-of-00004.safetensors", 5);
2325        let te_c = mk("text_encoder-00003-of-00004.safetensors", 5);
2326        let te_d = mk("text_encoder-00004-of-00004.safetensors", 1);
2327        let paths = ModelPaths {
2328            transformer: shard_a.clone(),
2329            transformer_shards: vec![shard_a, shard_b],
2330            vae,
2331            spatial_upscaler: None,
2332            temporal_upscaler: None,
2333            distilled_lora: None,
2334            t5_encoder: None,
2335            clip_encoder: None,
2336            t5_tokenizer: None,
2337            clip_tokenizer: None,
2338            clip_encoder_2: None,
2339            clip_tokenizer_2: None,
2340            text_encoder_files: vec![te_a, te_b, te_c, te_d],
2341            text_tokenizer: None,
2342            decoder: None,
2343        };
2344        (dir, paths)
2345    }
2346
2347    fn flux2_large_bf16_paths_with_quantized_encoder() -> (tempfile::TempDir, ModelPaths) {
2348        let dir = tempfile::tempdir().expect("tempdir");
2349        let mk = |name: &str, sz: u64| {
2350            let p = dir.path().join(name);
2351            let f = std::fs::File::create(&p).unwrap();
2352            f.set_len(sz * GB).unwrap();
2353            p
2354        };
2355        let shard_a = mk("diffusion_pytorch_model-00001-of-00002.safetensors", 10);
2356        let shard_b = mk("diffusion_pytorch_model-00002-of-00002.safetensors", 8);
2357        let vae = mk("flux2-vae.safetensors", 1);
2358        let qwen3_q3 = mk("qwen3-q3.gguf", 3);
2359        let paths = ModelPaths {
2360            transformer: shard_a.clone(),
2361            transformer_shards: vec![shard_a, shard_b],
2362            vae,
2363            spatial_upscaler: None,
2364            temporal_upscaler: None,
2365            distilled_lora: None,
2366            t5_encoder: None,
2367            clip_encoder: None,
2368            t5_tokenizer: None,
2369            clip_tokenizer: None,
2370            clip_encoder_2: None,
2371            clip_tokenizer_2: None,
2372            text_encoder_files: vec![qwen3_q3],
2373            text_tokenizer: None,
2374            decoder: None,
2375        };
2376        (dir, paths)
2377    }
2378
2379    #[test]
2380    fn preflight_allows_flux2_klein9b_bf16_on_24gb_when_sequential_budget_fits() {
2381        let (_dir, paths) = flux2_klein9b_bf16_paths();
2382        let hint = ActivationHint {
2383            width: 1024,
2384            height: 1024,
2385            batch: 1,
2386            dtype_bytes: 2,
2387            family: ActivationFamily::Flux2Dit,
2388        };
2389
2390        let result = preflight_memory_guard_with_available(
2391            "flux2-klein-9b:bf16",
2392            &paths,
2393            0,
2394            24 * GB,
2395            Some(hint),
2396        );
2397
2398        assert!(
2399            result.is_ok(),
2400            "Klein-9B BF16 should be admitted on a 24 GB card when the \
2401             sequential transformer/VAE phase plus activation budget fits; \
2402             Qwen3 can be quantized/dropped before denoise, got {result:?}"
2403        );
2404    }
2405
2406    #[test]
2407    fn preflight_rejects_flux2_klein9b_bf16_on_24gb_when_activation_budget_exceeds_cap() {
2408        let (_dir, paths) = flux2_klein9b_bf16_paths();
2409        let hint = ActivationHint {
2410            width: 2048,
2411            height: 2048,
2412            batch: 1,
2413            dtype_bytes: 2,
2414            family: ActivationFamily::Flux2Dit,
2415        };
2416
2417        let result = preflight_memory_guard_with_available(
2418            "flux2-klein-9b:bf16",
2419            &paths,
2420            0,
2421            24 * GB,
2422            Some(hint),
2423        );
2424
2425        assert!(
2426            result.is_err(),
2427            "Klein-9B BF16 should still reject when resolution-scaled \
2428             activation budget pushes the sequential phase past the 90% cap, got {result:?}"
2429        );
2430    }
2431
2432    #[test]
2433    fn server_load_strategy_degrades_flux2_klein9b_bf16_on_24gb_to_sequential() {
2434        let (_dir, paths) = flux2_klein9b_bf16_paths();
2435        let hint = ActivationHint {
2436            width: 1024,
2437            height: 1024,
2438            batch: 1,
2439            dtype_bytes: 2,
2440            family: ActivationFamily::Flux2Dit,
2441        };
2442
2443        let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
2444
2445        assert_eq!(
2446            strategy,
2447            mold_inference::LoadStrategy::Sequential,
2448            "server must use load-use-drop for Klein-9B BF16 on 24 GB so the \
2449             text encoder is not co-resident with the transformer"
2450        );
2451    }
2452
2453    #[test]
2454    fn server_load_strategy_degrades_large_flux2_bf16_even_with_quantized_encoder() {
2455        let (_dir, paths) = flux2_large_bf16_paths_with_quantized_encoder();
2456        let hint = ActivationHint {
2457            width: 1024,
2458            height: 1024,
2459            batch: 1,
2460            dtype_bytes: 2,
2461            family: ActivationFamily::Flux2Dit,
2462        };
2463
2464        let strategy = select_server_load_strategy_for_budget(&paths, Some(24 * GB), Some(hint));
2465
2466        assert_eq!(
2467            strategy,
2468            mold_inference::LoadStrategy::Sequential,
2469            "large Flux.2 BF16 transformer shards need load-use-drop on 24 GB \
2470             even when Qwen3 resolves to a small quantized encoder"
2471        );
2472    }
2473
2474    #[test]
2475    fn server_load_strategy_forces_klein9b_bf16_sequential_on_24gb_even_with_overgenerous_budget() {
2476        let (_dir, paths) = flux2_klein9b_bf16_paths();
2477        let hint = ActivationHint {
2478            width: 1024,
2479            height: 1024,
2480            batch: 1,
2481            dtype_bytes: 2,
2482            family: ActivationFamily::Flux2Dit,
2483        };
2484
2485        let strategy = select_server_load_strategy_for_device(
2486            &paths,
2487            Some(128 * GB),
2488            Some(24 * GB),
2489            Some(hint),
2490        );
2491
2492        assert_eq!(
2493            strategy,
2494            mold_inference::LoadStrategy::Sequential,
2495            "Klein-9B BF16 must not use eager loading on 24 GB cards even if \
2496             the live free-memory query is over-generous or falls back to \
2497             system memory"
2498        );
2499    }
2500
2501    #[test]
2502    fn server_load_strategy_caps_overgenerous_budget_for_klein_like_bf16_model() {
2503        let (_dir, paths) = flux2_klein9b_bf16_paths();
2504        let hint = ActivationHint {
2505            width: 1024,
2506            height: 1024,
2507            batch: 1,
2508            dtype_bytes: 2,
2509            family: ActivationFamily::Flux2Dit,
2510        };
2511
2512        let strategy = select_server_load_strategy_for_device(
2513            &paths,
2514            Some(128 * GB),
2515            Some(24 * GB),
2516            Some(hint),
2517        );
2518
2519        assert_eq!(
2520            strategy,
2521            mold_inference::LoadStrategy::Sequential,
2522            "Klein-9B-shaped BF16 loads must use device VRAM as the budget cap \
2523             even when the live available-memory reading falls back to a larger \
2524             system-memory value"
2525        );
2526    }
2527
2528    #[test]
2529    fn server_load_strategy_uses_device_total_when_live_available_missing() {
2530        let (_dir, paths) = flux2_klein9b_bf16_paths();
2531        let hint = ActivationHint {
2532            width: 1024,
2533            height: 1024,
2534            batch: 1,
2535            dtype_bytes: 2,
2536            family: ActivationFamily::Flux2Dit,
2537        };
2538
2539        let strategy =
2540            select_server_load_strategy_for_device(&paths, None, Some(24 * GB), Some(hint));
2541
2542        assert_eq!(
2543            strategy,
2544            mold_inference::LoadStrategy::Sequential,
2545            "when live free-memory probing is unavailable, the worker should still \
2546             use known device total VRAM instead of defaulting to eager"
2547        );
2548    }
2549
2550    /// Build LTX-2-shaped paths: a single 46 GB single-file checkpoint
2551    /// (transformer == vae) and a 25 GB Gemma TE in `text_encoder_files`.
2552    /// Mirrors cv:2752735 on disk.
2553    fn ltx2_shaped_paths_with_sizes(
2554        transformer_gb: u64,
2555        gemma_te_gb: u64,
2556    ) -> (tempfile::TempDir, ModelPaths) {
2557        let dir = tempfile::tempdir().expect("tempdir");
2558        let mk = |name: &str, sz: u64| {
2559            let p = dir.path().join(name);
2560            let f = std::fs::File::create(&p).unwrap();
2561            f.set_len(sz * GB).unwrap();
2562            p
2563        };
2564        let transformer = mk("ltx2_full.safetensors", transformer_gb);
2565        let gemma = mk("gemma_te.safetensors", gemma_te_gb);
2566        // LTX-2 catalog bridge sets vae == transformer (single-file
2567        // convention). The peak estimator detects this and avoids
2568        // double-counting.
2569        let paths = ModelPaths {
2570            transformer: transformer.clone(),
2571            transformer_shards: Vec::new(),
2572            vae: transformer,
2573            spatial_upscaler: None,
2574            temporal_upscaler: None,
2575            distilled_lora: None,
2576            t5_encoder: None,
2577            clip_encoder: None,
2578            t5_tokenizer: None,
2579            clip_tokenizer: None,
2580            clip_encoder_2: None,
2581            clip_tokenizer_2: None,
2582            text_encoder_files: vec![gemma],
2583            text_tokenizer: None,
2584            decoder: None,
2585        };
2586        (dir, paths)
2587    }
2588
2589    /// LTX-2 22B (cv:2752735) on a 24 GB 3090 must NOT be falsely rejected
2590    /// by the file-size-based preflight. The transformer streams blocks
2591    /// (`Ltx2AvTransformer3DModel::new_streaming`); only ~2 GB of weights
2592    /// are co-resident at peak. The activation hint marks the family as
2593    /// `Ltx2Video`, which routes through `streaming_transformer_peak`.
2594    #[test]
2595    fn preflight_accepts_ltx2_22b_on_24gb_card_via_streaming_peak() {
2596        let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 0);
2597        let hint = ActivationHint {
2598            width: 768,
2599            height: 512,
2600            batch: 1,
2601            dtype_bytes: 2,
2602            family: ActivationFamily::Ltx2Video,
2603        };
2604        let result =
2605            preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2606        assert!(
2607            result.is_ok(),
2608            "22B LTX-2 must fit on a 24 GB card under streaming-aware peak \
2609             (only ~2 blocks co-resident; runtime handles its own memory), \
2610            got {result:?}",
2611        );
2612    }
2613
2614    #[test]
2615    fn preflight_accepts_ltx2_22b_by_catalog_path_when_hint_is_missing() {
2616        let dir = tempfile::tempdir().expect("tempdir");
2617        let mk = |name: &str, sz: u64| {
2618            let p = dir.path().join(name);
2619            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2620            let f = std::fs::File::create(&p).unwrap();
2621            f.set_len(sz * GB).unwrap();
2622            p
2623        };
2624        let transformer = mk("ltx2/civitai/2752735/ltx23_full.safetensors", 46);
2625        let paths = ModelPaths {
2626            transformer: transformer.clone(),
2627            transformer_shards: Vec::new(),
2628            vae: transformer,
2629            spatial_upscaler: None,
2630            temporal_upscaler: None,
2631            distilled_lora: None,
2632            t5_encoder: None,
2633            clip_encoder: None,
2634            t5_tokenizer: None,
2635            clip_tokenizer: None,
2636            clip_encoder_2: None,
2637            clip_tokenizer_2: None,
2638            text_encoder_files: Vec::new(),
2639            text_tokenizer: None,
2640            decoder: None,
2641        };
2642        let result = preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, None);
2643
2644        assert!(
2645            result.is_ok(),
2646            "LTX-2 catalog paths should use the streaming-transformer peak even \
2647             when the multi-GPU worker cannot resolve a family hint, got {result:?}"
2648        );
2649    }
2650
2651    /// Without the streaming hint the same paths land on the file-size
2652    /// peak and reject — pinning the previous behavior so a regression
2653    /// that flips the hint plumbing back is caught.
2654    #[test]
2655    fn preflight_rejects_ltx2_22b_when_hint_marks_non_streaming() {
2656        let _guard = offload_env_guard("0");
2657        let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 0);
2658        // Use FluxDit family — same shape, no streaming flag — so the
2659        // preflight falls through to the file-size estimator and rejects
2660        // at 90 % of 24 GB.
2661        let hint = ActivationHint {
2662            width: 768,
2663            height: 512,
2664            batch: 1,
2665            dtype_bytes: 2,
2666            family: ActivationFamily::FluxDit,
2667        };
2668        let result =
2669            preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2670        assert!(
2671            result.is_err(),
2672            "without the LTX-2 streaming hint the file-size peak must reject \
2673             a 46 GB transformer on a 24 GB card — this anchors the regression \
2674             that landed before the streaming-aware path",
2675        );
2676    }
2677
2678    /// Encoder phase for LTX-2 still pays full encoder_total when the user
2679    /// pins the placement to GPU (`MOLD_LTX2_GEMMA_DEVICE=gpu`). With a
2680    /// 25 GB Gemma TE on a 24 GB card the encoder phase trips the 90 % cap
2681    /// even when the transformer is streamed — a real OOM the user should
2682    /// see from the runtime, but the preflight captures it up-front.
2683    #[test]
2684    fn preflight_rejects_ltx2_when_encoder_phase_exceeds_card() {
2685        let _guard = ltx2_gemma_env_guard("gpu");
2686        let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
2687        let hint = ActivationHint {
2688            width: 768,
2689            height: 512,
2690            batch: 1,
2691            dtype_bytes: 2,
2692            family: ActivationFamily::Ltx2Video,
2693        };
2694        let result =
2695            preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2696        assert!(
2697            result.is_err(),
2698            "25 GB Gemma TE alone exceeds 90 %% of 24 GB during the encoder \
2699             phase — preflight must surface this even when the transformer \
2700             is streamed, got {result:?}",
2701        );
2702    }
2703
2704    /// In auto mode the LTX-2 runtime may try the prompt encoder on GPU first,
2705    /// but CUDA OOM during that phase is recoverable: it reclaims the context
2706    /// and retries the prompt encoder on CPU before streamed transformer load.
2707    /// Preflight must admit that path instead of rejecting the request before
2708    /// the runtime fallback can run.
2709    #[test]
2710    fn preflight_admits_ltx2_auto_gemma_even_when_gpu_encoder_would_exceed_cap() {
2711        let _guard = ltx2_gemma_env_guard("auto");
2712        let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
2713        let hint = ActivationHint {
2714            width: 768,
2715            height: 512,
2716            batch: 1,
2717            dtype_bytes: 2,
2718            family: ActivationFamily::Ltx2Video,
2719        };
2720        let result =
2721            preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2722        assert!(
2723            result.is_ok(),
2724            "auto Gemma placement can fall back to CPU at runtime, so preflight \
2725             must not reject solely because a same-GPU prompt encoder phase \
2726             would exceed the hard cap, got {result:?}",
2727        );
2728    }
2729
2730    /// `MOLD_LTX2_GEMMA_DEVICE=cpu` shifts the Gemma TE to system RAM. The
2731    /// encoder phase no longer competes for VRAM; the streaming-aware peak
2732    /// collapses to "transformer streaming cap + activation + headroom" and
2733    /// the same 25 GB Gemma + 46 GB transformer paths admit on a 24 GB card.
2734    /// This is the load-bearing behavior on a single 3090 running cv:2752735.
2735    #[test]
2736    fn preflight_admits_ltx2_22b_with_25gb_gemma_when_resolver_picks_cpu() {
2737        let _guard = ltx2_gemma_env_guard("cpu");
2738        let (_dir, paths) = ltx2_shaped_paths_with_sizes(46, 25);
2739        let hint = ActivationHint {
2740            width: 768,
2741            height: 512,
2742            batch: 1,
2743            dtype_bytes: 2,
2744            family: ActivationFamily::Ltx2Video,
2745        };
2746        let result =
2747            preflight_memory_guard_with_available("cv:2752735", &paths, 0, 24 * GB, Some(hint));
2748        assert!(
2749            result.is_ok(),
2750            "with MOLD_LTX2_GEMMA_DEVICE=cpu the encoder phase should not \
2751             count against GPU VRAM, so cv:2752735 must admit on 24 GB even \
2752             with a 25 GB Gemma TE, got {result:?}",
2753        );
2754    }
2755
2756    /// `ActivationHint::from_request` picks the right family + batch for a
2757    /// real-shaped GenerateRequest.
2758    #[test]
2759    fn activation_hint_from_request_classifies_correctly() {
2760        let mut req = GenerateRequest {
2761            prompt: "test".into(),
2762            negative_prompt: None,
2763            model: "flux-dev:bf16".into(),
2764            width: 1024,
2765            height: 1024,
2766            steps: 20,
2767            guidance: 3.5,
2768            seed: None,
2769            batch_size: 1,
2770            output_format: Default::default(),
2771            embed_metadata: None,
2772            scheduler: None,
2773            cfg_plus: None,
2774            source_image: None,
2775            edit_images: None,
2776            strength: 1.0,
2777            mask_image: None,
2778            control_image: None,
2779            control_model: None,
2780            control_scale: 1.0,
2781            expand: None,
2782            original_prompt: None,
2783            lora: None,
2784            frames: None,
2785            fps: None,
2786            upscale_model: None,
2787            gif_preview: false,
2788            enable_audio: None,
2789            audio_file: None,
2790            audio_file_path: None,
2791            source_video: None,
2792            source_video_path: None,
2793            keyframes: None,
2794            pipeline: None,
2795            loras: None,
2796            retake_range: None,
2797            spatial_upscale: None,
2798            temporal_upscale: None,
2799            placement: None,
2800        };
2801
2802        // FLUX is guidance-distilled → batch=1 even with guidance > 1.
2803        let hint_flux = ActivationHint::from_request(&req, "flux");
2804        assert_eq!(hint_flux.family, ActivationFamily::FluxDit);
2805        assert_eq!(hint_flux.batch, 1);
2806
2807        // SDXL with CFG → batch=2.
2808        let hint_sdxl = ActivationHint::from_request(&req, "sdxl");
2809        assert_eq!(hint_sdxl.family, ActivationFamily::SdxlUnet);
2810        assert_eq!(hint_sdxl.batch, 2);
2811
2812        // SDXL with no CFG (LCM/Turbo) → batch=1.
2813        req.guidance = 1.0;
2814        let hint_sdxl_lcm = ActivationHint::from_request(&req, "sdxl");
2815        assert_eq!(hint_sdxl_lcm.batch, 1);
2816
2817        // Unknown family slug falls through to FluxDit.
2818        let hint_unknown = ActivationHint::from_request(&req, "totally-bogus");
2819        assert_eq!(hint_unknown.family, ActivationFamily::FluxDit);
2820    }
2821
2822    /// Synthesize a minimal safetensors at `path` that lists the given
2823    /// keys in the JSON header (each as a 1-element F32 tensor sharing the
2824    /// same 4-byte zero blob). Sufficient for the header-peek probe; no
2825    /// dep on the `safetensors` crate.
2826    fn write_safetensors_with_keys(path: &std::path::Path, keys: &[&str]) {
2827        use std::io::Write;
2828        let mut header = serde_json::Map::new();
2829        for key in keys {
2830            header.insert(
2831                (*key).to_string(),
2832                serde_json::json!({
2833                    "dtype": "F32",
2834                    "shape": [1],
2835                    "data_offsets": [0, 4],
2836                }),
2837            );
2838        }
2839        let header_json = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
2840        let mut f = std::fs::File::create(path).expect("create fixture");
2841        f.write_all(&(header_json.len() as u64).to_le_bytes())
2842            .unwrap();
2843        f.write_all(&header_json).unwrap();
2844        f.write_all(&[0u8; 4]).unwrap();
2845    }
2846
2847    fn flux_unet_only_catalog_entry(
2848        version_id: &str,
2849        file_name: &str,
2850    ) -> mold_catalog::entry::CatalogEntry {
2851        use mold_catalog::entry::{
2852            CatalogEntry, CatalogId, DownloadRecipe, FamilyRole, FileFormat, LicenseFlags,
2853            Modality, RecipeFile, Source, TokenKind,
2854        };
2855        use mold_catalog::families::Family;
2856
2857        CatalogEntry {
2858            id: CatalogId::from(format!("cv:{version_id}")),
2859            source: Source::Civitai,
2860            source_id: version_id.to_string(),
2861            name: "FLUX Unet-only fine-tune".into(),
2862            author: Some("someone".into()),
2863            family: Family::Flux,
2864            family_role: FamilyRole::Finetune,
2865            sub_family: None,
2866            modality: Modality::Image,
2867            kind: mold_catalog::entry::Kind::Checkpoint,
2868            file_format: FileFormat::Safetensors,
2869            bundling: mold_catalog::entry::Bundling::SingleFile,
2870            size_bytes: Some(12_000_000_000),
2871            download_count: 0,
2872            rating: None,
2873            likes: 0,
2874            nsfw: false,
2875            thumbnail_url: None,
2876            description: None,
2877            license: None,
2878            license_flags: LicenseFlags::default(),
2879            tags: vec![],
2880            companions: vec!["t5-v1_1-xxl".into(), "clip-l".into(), "flux-vae".into()],
2881            download_recipe: DownloadRecipe {
2882                files: vec![RecipeFile {
2883                    url: format!("https://civitai.com/api/download/models/{version_id}"),
2884                    dest: format!("{{family}}/civitai/{version_id}/{file_name}"),
2885                    sha256: Some("DEAD".repeat(16)),
2886                    size_bytes: Some(12_000_000_000),
2887                    role: None,
2888                }],
2889                needs_token: Some(TokenKind::Civitai),
2890            },
2891            engine_phase: 1,
2892            created_at: None,
2893            updated_at: None,
2894            added_at: 0,
2895            trained_words: vec![],
2896            page_url: None,
2897        }
2898    }
2899
2900    // ── Lazy intent / disk-aware resolution regression tests ───────────────
2901
2902    /// Stub the FLUX companion manifest entries (flux-vae, clip-l, t5)
2903    /// pointing at on-disk files inside `models_dir`. Mirrors what the
2904    /// real manifest installer would have done.
2905    fn stub_flux_companion_paths_in_dir(
2906        config: &mut mold_core::Config,
2907        models_dir: &std::path::Path,
2908        flux_vae_present: bool,
2909    ) {
2910        let vae_path = models_dir.join("flux-vae/ae.safetensors");
2911        std::fs::create_dir_all(vae_path.parent().unwrap()).unwrap();
2912        if flux_vae_present {
2913            std::fs::File::create(&vae_path).unwrap();
2914        }
2915        config.models.insert(
2916            "flux-vae".into(),
2917            mold_core::ModelConfig {
2918                family: Some("companion".into()),
2919                transformer: Some(vae_path.to_string_lossy().into_owned()),
2920                vae: Some(vae_path.to_string_lossy().into_owned()),
2921                ..Default::default()
2922            },
2923        );
2924        let clip_path = models_dir.join("clip-l/model.safetensors");
2925        std::fs::create_dir_all(clip_path.parent().unwrap()).unwrap();
2926        std::fs::File::create(&clip_path).unwrap();
2927        config.models.insert(
2928            "clip-l".into(),
2929            mold_core::ModelConfig {
2930                family: Some("companion".into()),
2931                transformer: Some(clip_path.to_string_lossy().into_owned()),
2932                vae: Some(clip_path.to_string_lossy().into_owned()),
2933                clip_tokenizer: Some(format!("{}/clip-l/tokenizer.json", models_dir.display())),
2934                ..Default::default()
2935            },
2936        );
2937        let t5_path = models_dir.join("t5-v1_1-xxl/t5xxl_fp16.safetensors");
2938        std::fs::create_dir_all(t5_path.parent().unwrap()).unwrap();
2939        std::fs::File::create(&t5_path).unwrap();
2940        config.models.insert(
2941            "t5-v1_1-xxl".into(),
2942            mold_core::ModelConfig {
2943                family: Some("companion".into()),
2944                transformer: Some(t5_path.to_string_lossy().into_owned()),
2945                vae: Some(t5_path.to_string_lossy().into_owned()),
2946                t5_tokenizer: Some(format!(
2947                    "{}/t5-v1_1-xxl/tokenizer.json",
2948                    models_dir.display()
2949                )),
2950                ..Default::default()
2951            },
2952        );
2953    }
2954
2955    /// Task 3 (Step 1): synthesis must produce the same intent regardless
2956    /// of whether the primary file is on disk yet. The disk-dependent
2957    /// part is moved to `resolve_intent_to_paths`, which is only called
2958    /// at engine-load time.
2959    #[test]
2960    fn synthesis_intent_is_consistent_before_and_after_download() {
2961        let dir = tempfile::tempdir().unwrap();
2962        let models_dir = dir.path();
2963        let entry =
2964            flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
2965
2966        let intent_absent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
2967
2968        let primary_path = models_dir
2969            .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
2970        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
2971        write_safetensors_with_keys(
2972            &primary_path,
2973            &[
2974                "double_blocks.0.img_attn.proj.weight",
2975                "single_blocks.0.linear1.weight",
2976                "img_in.weight",
2977            ],
2978        );
2979
2980        let intent_present =
2981            mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
2982
2983        assert_eq!(
2984            intent_absent, intent_present,
2985            "intent synthesis must be pure — independent of disk state"
2986        );
2987    }
2988
2989    /// Task 3 (Step 2 + Step 3): with the primary present as a
2990    /// transformer-only checkpoint, resolution picks the flux-vae
2991    /// companion for cfg.vae rather than the primary checkpoint.
2992    #[test]
2993    fn resolve_intent_picks_flux_vae_companion_when_primary_is_transformer_only() {
2994        let dir = tempfile::tempdir().unwrap();
2995        let models_dir = dir.path();
2996        let _saved = std::env::var("MOLD_HOME").ok();
2997        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
2998
2999        let primary_path = models_dir
3000            .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3001        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3002        write_safetensors_with_keys(
3003            &primary_path,
3004            &[
3005                "double_blocks.0.img_attn.proj.weight",
3006                "single_blocks.0.linear1.weight",
3007                "img_in.weight",
3008            ],
3009        );
3010
3011        let mut config = mold_core::Config {
3012            models_dir: models_dir.to_string_lossy().into_owned(),
3013            ..Default::default()
3014        };
3015        stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3016
3017        let entry =
3018            flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3019        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3020        let cfg = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
3021
3022        let vae_path = models_dir.join("flux-vae/ae.safetensors");
3023        assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
3024        assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
3025
3026        unsafe {
3027            match _saved {
3028                Some(v) => std::env::set_var("MOLD_HOME", v),
3029                None => std::env::remove_var("MOLD_HOME"),
3030            }
3031        }
3032    }
3033
3034    #[test]
3035    fn resolve_intent_preserves_flux_schnell_subfamily() {
3036        let dir = tempfile::tempdir().unwrap();
3037        let models_dir = dir.path();
3038        let _saved = std::env::var("MOLD_HOME").ok();
3039        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3040
3041        let primary_path = models_dir
3042            .join("cv-1153358/flux/civitai/1153358/agfluxSchnell_realistic23.safetensors");
3043        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3044        write_safetensors_with_keys(
3045            &primary_path,
3046            &[
3047                "model.diffusion_model.double_blocks.0.img_attn.proj.weight",
3048                "model.diffusion_model.img_in.weight",
3049            ],
3050        );
3051
3052        let mut config = mold_core::Config {
3053            models_dir: models_dir.to_string_lossy().into_owned(),
3054            ..Default::default()
3055        };
3056        stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3057
3058        let mut entry =
3059            flux_unet_only_catalog_entry("1153358", "agfluxSchnell_realistic23.safetensors");
3060        entry.sub_family = Some("flux1-s".into());
3061
3062        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3063        let cfg = resolve_intent_to_paths("cv:1153358", &intent, &config).unwrap();
3064
3065        assert_eq!(
3066            cfg.is_schnell,
3067            Some(true),
3068            "flux1-s catalog entries must select FLUX schnell config, not dev guidance config"
3069        );
3070
3071        unsafe {
3072            match _saved {
3073                Some(v) => std::env::set_var("MOLD_HOME", v),
3074                None => std::env::remove_var("MOLD_HOME"),
3075            }
3076        }
3077    }
3078
3079    #[test]
3080    fn resolve_intent_applies_flux_dev_subfamily_defaults() {
3081        let dir = tempfile::tempdir().unwrap();
3082        let models_dir = dir.path();
3083        let _saved = std::env::var("MOLD_HOME").ok();
3084        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3085
3086        let primary_path =
3087            models_dir.join("cv-2319074/flux/civitai/2319074/jibMixFlux_v12SRPO.safetensors");
3088        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3089        write_safetensors_with_keys(
3090            &primary_path,
3091            &[
3092                "double_blocks.0.img_attn.proj.weight",
3093                "single_blocks.0.linear1.weight",
3094                "img_in.weight",
3095            ],
3096        );
3097
3098        let mut config = mold_core::Config {
3099            models_dir: models_dir.to_string_lossy().into_owned(),
3100            default_steps: 4,
3101            ..Default::default()
3102        };
3103        stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3104
3105        let mut entry = flux_unet_only_catalog_entry("2319074", "jibMixFlux_v12SRPO.safetensors");
3106        entry.sub_family = Some("flux1-d".into());
3107
3108        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3109        let cfg = resolve_intent_to_paths("cv:2319074", &intent, &config).unwrap();
3110
3111        assert_eq!(cfg.is_schnell, Some(false));
3112        assert_eq!(cfg.default_steps, Some(25));
3113        assert_eq!(cfg.default_guidance, Some(3.5));
3114        assert_eq!(cfg.default_width, Some(1024));
3115        assert_eq!(cfg.default_height, Some(1024));
3116
3117        unsafe {
3118            match _saved {
3119                Some(v) => std::env::set_var("MOLD_HOME", v),
3120                None => std::env::remove_var("MOLD_HOME"),
3121            }
3122        }
3123    }
3124
3125    #[test]
3126    fn resolve_intent_populates_qwen_runtime_companion_paths() {
3127        let dir = tempfile::tempdir().unwrap();
3128        let models_dir = dir.path();
3129        let primary_path =
3130            models_dir.join("cv-2110043/qwen-image/civitai/2110043/qwenImage_fp8.safetensors");
3131        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3132        std::fs::File::create(&primary_path).unwrap();
3133
3134        let runtime_dir = models_dir.join("qwen-image-runtime");
3135        let vae_path = runtime_dir.join("vae/diffusion_pytorch_model.safetensors");
3136        let te_path = runtime_dir.join("text_encoder/model-00001-of-00004.safetensors");
3137        let tok_path = runtime_dir.join("tokenizer/tokenizer.json");
3138        for path in [&vae_path, &te_path, &tok_path] {
3139            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3140            std::fs::File::create(path).unwrap();
3141        }
3142
3143        let mut config = mold_core::Config {
3144            models_dir: models_dir.to_string_lossy().into_owned(),
3145            ..Default::default()
3146        };
3147        config.models.insert(
3148            "qwen-image-runtime".into(),
3149            mold_core::ModelConfig {
3150                family: Some("companion".into()),
3151                transformer: Some(vae_path.to_string_lossy().into_owned()),
3152                vae: Some(vae_path.to_string_lossy().into_owned()),
3153                text_encoder_files: Some(vec![te_path.to_string_lossy().into_owned()]),
3154                text_tokenizer: Some(tok_path.to_string_lossy().into_owned()),
3155                ..Default::default()
3156            },
3157        );
3158
3159        let mut entry = flux_unet_only_catalog_entry("2110043", "qwenImage_fp8.safetensors");
3160        entry.family = mold_catalog::families::Family::QwenImage;
3161        entry.companions = vec!["qwen-image-runtime".into()];
3162        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3163        let cfg = resolve_intent_to_paths("cv:2110043", &intent, &config).unwrap();
3164
3165        assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
3166        assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
3167        assert_eq!(
3168            cfg.text_encoder_files.as_deref(),
3169            Some(vec![te_path.to_string_lossy().into_owned()].as_slice())
3170        );
3171        assert_eq!(cfg.text_tokenizer.as_deref(), tok_path.to_str());
3172    }
3173
3174    #[test]
3175    fn resolve_intent_populates_wuerstchen_runtime_companion_paths() {
3176        let dir = tempfile::tempdir().unwrap();
3177        let models_dir = dir.path();
3178        let primary_path = models_dir.join(
3179            "hf-example/wuerstchen-prior/wuerstchen/example/wuerstchen-prior/prior.safetensors",
3180        );
3181        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3182        std::fs::File::create(&primary_path).unwrap();
3183
3184        let runtime_dir = models_dir.join("wuerstchen-runtime");
3185        let decoder_path = runtime_dir.join("decoder/diffusion_pytorch_model.safetensors");
3186        let vae_path = runtime_dir.join("vqgan/diffusion_pytorch_model.safetensors");
3187        let clip_path = runtime_dir.join("text_encoder/model.safetensors");
3188        let clip_tok_path = runtime_dir.join("tokenizer/tokenizer.json");
3189        let clip_g_path = runtime_dir.join("prior/text_encoder/model.safetensors");
3190        let clip_g_tok_path = runtime_dir.join("prior/tokenizer/tokenizer.json");
3191        for path in [
3192            &decoder_path,
3193            &vae_path,
3194            &clip_path,
3195            &clip_tok_path,
3196            &clip_g_path,
3197            &clip_g_tok_path,
3198        ] {
3199            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3200            std::fs::File::create(path).unwrap();
3201        }
3202
3203        let mut config = mold_core::Config {
3204            models_dir: models_dir.to_string_lossy().into_owned(),
3205            ..Default::default()
3206        };
3207        config.models.insert(
3208            "wuerstchen-runtime".into(),
3209            mold_core::ModelConfig {
3210                family: Some("companion".into()),
3211                transformer: Some(decoder_path.to_string_lossy().into_owned()),
3212                decoder: Some(decoder_path.to_string_lossy().into_owned()),
3213                vae: Some(vae_path.to_string_lossy().into_owned()),
3214                clip_encoder: Some(clip_path.to_string_lossy().into_owned()),
3215                clip_tokenizer: Some(clip_tok_path.to_string_lossy().into_owned()),
3216                clip_encoder_2: Some(clip_g_path.to_string_lossy().into_owned()),
3217                clip_tokenizer_2: Some(clip_g_tok_path.to_string_lossy().into_owned()),
3218                ..Default::default()
3219            },
3220        );
3221
3222        let mut entry = flux_unet_only_catalog_entry("unused", "prior.safetensors");
3223        entry.id = mold_catalog::entry::CatalogId::from("hf:example/wuerstchen-prior");
3224        entry.source = mold_catalog::entry::Source::Hf;
3225        entry.source_id = "example/wuerstchen-prior".into();
3226        entry.family = mold_catalog::families::Family::Wuerstchen;
3227        entry.companions = vec!["wuerstchen-runtime".into()];
3228        entry.download_recipe.files[0].dest = "{family}/{author}/{name}/prior.safetensors".into();
3229
3230        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3231        let cfg = resolve_intent_to_paths("hf:example/wuerstchen-prior", &intent, &config).unwrap();
3232
3233        assert_eq!(cfg.transformer.as_deref(), primary_path.to_str());
3234        assert_eq!(cfg.decoder.as_deref(), decoder_path.to_str());
3235        assert_eq!(cfg.vae.as_deref(), vae_path.to_str());
3236        assert_eq!(cfg.clip_encoder.as_deref(), clip_path.to_str());
3237        assert_eq!(cfg.clip_tokenizer.as_deref(), clip_tok_path.to_str());
3238        assert_eq!(cfg.clip_encoder_2.as_deref(), clip_g_path.to_str());
3239        assert_eq!(cfg.clip_tokenizer_2.as_deref(), clip_g_tok_path.to_str());
3240    }
3241
3242    #[test]
3243    fn resolve_intent_uses_zimage_recipe_text_encoder_and_shared_companion_vae() {
3244        use mold_catalog::entry::{
3245            Bundling, CatalogEntry, CatalogId, DownloadRecipe, FamilyRole, FileFormat,
3246            LicenseFlags, Modality, RecipeFile, RecipeFileRole, Source, TokenKind,
3247        };
3248        use mold_catalog::families::Family;
3249
3250        let dir = tempfile::tempdir().unwrap();
3251        let models_dir = dir.path();
3252        let _saved = std::env::var("MOLD_HOME").ok();
3253        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3254
3255        let mut config = mold_core::Config {
3256            models_dir: models_dir.to_string_lossy().into_owned(),
3257            ..Default::default()
3258        };
3259        let te_dir = models_dir.join("z-image-te");
3260        config.models.insert(
3261            "z-image-te".into(),
3262            mold_core::ModelConfig {
3263                family: Some("companion".into()),
3264                transformer: Some(
3265                    te_dir
3266                        .join("text_encoder/model-00001-of-00003.safetensors")
3267                        .to_string_lossy()
3268                        .into_owned(),
3269                ),
3270                vae: Some(
3271                    te_dir
3272                        .join("vae/diffusion_pytorch_model.safetensors")
3273                        .to_string_lossy()
3274                        .into_owned(),
3275                ),
3276                text_encoder_files: Some(vec![
3277                    te_dir
3278                        .join("text_encoder/model-00001-of-00003.safetensors")
3279                        .to_string_lossy()
3280                        .into_owned(),
3281                    te_dir
3282                        .join("text_encoder/model-00002-of-00003.safetensors")
3283                        .to_string_lossy()
3284                        .into_owned(),
3285                    te_dir
3286                        .join("text_encoder/model-00003-of-00003.safetensors")
3287                        .to_string_lossy()
3288                        .into_owned(),
3289                ]),
3290                text_tokenizer: Some(
3291                    te_dir
3292                        .join("tokenizer/tokenizer.json")
3293                        .to_string_lossy()
3294                        .into_owned(),
3295                ),
3296                ..Default::default()
3297            },
3298        );
3299        let entry = CatalogEntry {
3300            id: CatalogId::from("cv:2442439"),
3301            source: Source::Civitai,
3302            source_id: "2442439".into(),
3303            name: "Z Image Turbo".into(),
3304            author: Some("z".into()),
3305            family: Family::ZImage,
3306            family_role: FamilyRole::Finetune,
3307            sub_family: None,
3308            modality: Modality::Image,
3309            kind: mold_catalog::entry::Kind::Checkpoint,
3310            file_format: FileFormat::Safetensors,
3311            bundling: Bundling::SingleFile,
3312            size_bytes: Some(12_021_353_906),
3313            download_count: 0,
3314            rating: None,
3315            likes: 0,
3316            nsfw: false,
3317            thumbnail_url: None,
3318            description: None,
3319            license: None,
3320            license_flags: LicenseFlags::default(),
3321            tags: vec![],
3322            companions: vec!["z-image-te".into()],
3323            download_recipe: DownloadRecipe {
3324                files: vec![
3325                    RecipeFile {
3326                        url: "https://civitai.example/model".into(),
3327                        dest: "{family}/civitai/2442439/zImageTurbo_turbo.safetensors".into(),
3328                        sha256: None,
3329                        size_bytes: Some(12_021_353_906),
3330                        role: None,
3331                    },
3332                    RecipeFile {
3333                        url: "https://civitai.example/text".into(),
3334                        dest: "{family}/civitai/2442439/zImageTurbo_turbo_txt.safetensors".into(),
3335                        sha256: None,
3336                        size_bytes: Some(8_044_982_048),
3337                        role: Some(RecipeFileRole::TextEncoder),
3338                    },
3339                ],
3340                needs_token: Some(TokenKind::Civitai),
3341            },
3342            engine_phase: 1,
3343            created_at: None,
3344            updated_at: None,
3345            added_at: 0,
3346            trained_words: vec![],
3347            page_url: None,
3348        };
3349
3350        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3351        std::fs::create_dir_all(intent.primary_recipe_path.parent().unwrap()).unwrap();
3352        std::fs::write(&intent.primary_recipe_path, b"primary").unwrap();
3353        let cfg = resolve_intent_to_paths("cv:2442439", &intent, &config).unwrap();
3354
3355        let recipe_text_encoder =
3356            models_dir.join("cv-2442439/z-image/civitai/2442439/zImageTurbo_turbo_txt.safetensors");
3357        let shared_vae = te_dir.join("vae/diffusion_pytorch_model.safetensors");
3358        assert_eq!(cfg.vae.as_deref(), shared_vae.to_str());
3359        let expected_text_encoder_files = vec![recipe_text_encoder.to_string_lossy().into_owned()];
3360        assert_eq!(
3361            cfg.text_encoder_files.as_deref(),
3362            Some(expected_text_encoder_files.as_slice())
3363        );
3364
3365        unsafe {
3366            match _saved {
3367                Some(v) => std::env::set_var("MOLD_HOME", v),
3368                None => std::env::remove_var("MOLD_HOME"),
3369            }
3370        }
3371    }
3372
3373    /// Task 5 (Step 1): resolution surfaces the *specific* missing
3374    /// companion name rather than a generic "missing required components"
3375    /// blob. The CompanionConfigMissing variant fires when the manifest
3376    /// entry isn't in the user's Config.models — a real config bug.
3377    #[test]
3378    fn resolve_intent_returns_error_naming_missing_required_companion() {
3379        let dir = tempfile::tempdir().unwrap();
3380        let models_dir = dir.path();
3381
3382        let primary_path = models_dir
3383            .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3384        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3385        write_safetensors_with_keys(
3386            &primary_path,
3387            &["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
3388        );
3389
3390        let _saved = std::env::var("MOLD_HOME").ok();
3391        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3392
3393        // Empty config — none of t5, clip-l, flux-vae are installed.
3394        let config = mold_core::Config {
3395            models_dir: models_dir.to_string_lossy().into_owned(),
3396            ..Default::default()
3397        };
3398
3399        let entry =
3400            flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3401        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3402        let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
3403
3404        let msg = err.to_string();
3405        // The first missing companion in the FLUX list is t5-v1_1-xxl;
3406        // we just need the error to name *some* specific companion.
3407        assert!(
3408            msg.contains("t5-v1_1-xxl") || msg.contains("clip-l") || msg.contains("flux-vae"),
3409            "error must name a specific missing companion, got: {msg}"
3410        );
3411        assert!(matches!(err, ResolveError::CompanionConfigMissing { .. }));
3412
3413        unsafe {
3414            match _saved {
3415                Some(v) => std::env::set_var("MOLD_HOME", v),
3416                None => std::env::remove_var("MOLD_HOME"),
3417            }
3418        }
3419    }
3420
3421    /// Task 6: with the lazy intent / resolve flow, a second request
3422    /// after the file lands succeeds where the first might have raced.
3423    /// This exercises the no-stale-config invariant directly: both calls
3424    /// run the full intent + resolve pipeline; the second one sees the
3425    /// fresh disk state.
3426    #[test]
3427    fn cv_id_resolves_when_files_arrive_after_initial_request() {
3428        let dir = tempfile::tempdir().unwrap();
3429        let models_dir = dir.path();
3430        let _saved = std::env::var("MOLD_HOME").ok();
3431        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3432
3433        let entry =
3434            flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3435        let mut config = mold_core::Config {
3436            models_dir: models_dir.to_string_lossy().into_owned(),
3437            ..Default::default()
3438        };
3439        // Companions are present; primary file initially missing.
3440        stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3441
3442        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3443
3444        // First resolve — primary not yet on disk, so the catalog model
3445        // must not be advertised as loadable.
3446        let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
3447        assert!(matches!(err, ResolveError::PrimaryFileMissing { .. }));
3448        let primary_path = models_dir
3449            .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3450        let vae_path = models_dir.join("flux-vae/ae.safetensors");
3451
3452        // File arrives mid-flight as a transformer-only checkpoint.
3453        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3454        write_safetensors_with_keys(
3455            &primary_path,
3456            &["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
3457        );
3458
3459        // Second resolve sees the file: probe runs, declares "no bundled
3460        // VAE", flux-vae companion still wins. Same result, but this time
3461        // through the fully-armed disk-aware path.
3462        let cfg_second = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap();
3463        assert_eq!(cfg_second.transformer.as_deref(), primary_path.to_str());
3464        assert_eq!(cfg_second.vae.as_deref(), vae_path.to_str());
3465
3466        unsafe {
3467            match _saved {
3468                Some(v) => std::env::set_var("MOLD_HOME", v),
3469                None => std::env::remove_var("MOLD_HOME"),
3470            }
3471        }
3472    }
3473
3474    #[test]
3475    fn resolve_intent_rejects_truncated_sidecar_primary() {
3476        let dir = tempfile::tempdir().unwrap();
3477        let models_dir = dir.path();
3478        let _saved = std::env::var("MOLD_HOME").ok();
3479        unsafe { std::env::set_var("MOLD_HOME", models_dir.to_string_lossy().as_ref()) };
3480
3481        let primary_path = models_dir
3482            .join("cv-994561/flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors");
3483        std::fs::create_dir_all(primary_path.parent().unwrap()).unwrap();
3484        write_safetensors_with_keys(
3485            &primary_path,
3486            &["double_blocks.0.img_attn.proj.weight", "img_in.weight"],
3487        );
3488        let entry =
3489            flux_unet_only_catalog_entry("994561", "realHornyProV3_realHornyProV3Unet.safetensors");
3490        let sidecar = mold_catalog::sidecar::sidecar_from_entry(
3491            &entry,
3492            "flux/civitai/994561/realHornyProV3_realHornyProV3Unet.safetensors".into(),
3493        );
3494        let mut sidecar = sidecar;
3495        sidecar.size_bytes = Some(primary_path.metadata().unwrap().len() + 1);
3496        let sidecar_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, "cv:994561");
3497        mold_catalog::sidecar::write_sidecar(&sidecar_path, &sidecar).unwrap();
3498
3499        let mut config = mold_core::Config {
3500            models_dir: models_dir.to_string_lossy().into_owned(),
3501            ..Default::default()
3502        };
3503        stub_flux_companion_paths_in_dir(&mut config, models_dir, true);
3504        let intent = mold_catalog::synthesis::synthesize_intent(&entry, models_dir).unwrap();
3505
3506        let err = resolve_intent_to_paths("cv:994561", &intent, &config).unwrap_err();
3507        assert!(matches!(err, ResolveError::PrimaryFileMissing { .. }));
3508
3509        unsafe {
3510            match _saved {
3511                Some(v) => std::env::set_var("MOLD_HOME", v),
3512                None => std::env::remove_var("MOLD_HOME"),
3513            }
3514        }
3515    }
3516
3517    // ── InstallError translation tests (Task 4) ────────────────────────────
3518
3519    #[test]
3520    fn live_error_to_install_error_maps_404_to_not_found() {
3521        let upstream = mold_catalog::live::LiveSearchError::Upstream {
3522            host: "civitai.com",
3523            status: 404,
3524            body: "{\"error\": \"not found\"}".to_string(),
3525        };
3526        let mapped = live_error_to_install_error("cv:42", &upstream);
3527        assert!(matches!(mapped, mold_core::InstallError::NotFound(_)));
3528    }
3529
3530    #[test]
3531    fn live_error_to_install_error_maps_5xx_to_recipe_malformed() {
3532        let upstream = mold_catalog::live::LiveSearchError::Upstream {
3533            host: "civitai.com",
3534            status: 500,
3535            body: "internal".into(),
3536        };
3537        let mapped = live_error_to_install_error("cv:42", &upstream);
3538        assert!(matches!(
3539            mapped,
3540            mold_core::InstallError::RecipeMalformed(_)
3541        ));
3542    }
3543
3544    #[test]
3545    fn install_error_to_api_error_maps_network_to_502() {
3546        let err = mold_core::InstallError::Network("dns: civitai.com".into());
3547        let api = install_error_to_api_error(&err);
3548        // API error doesn't expose .status() publicly, but .code is
3549        // "INTERNAL_ERROR" with the BAD_GATEWAY status flag (see
3550        // ApiError::internal_with_status). The user-visible message must
3551        // carry "network unreachable" so they know what happened.
3552        assert!(api.error.contains("network unreachable"));
3553    }
3554
3555    #[test]
3556    fn install_error_to_api_error_maps_not_found_to_404() {
3557        let err = mold_core::InstallError::NotFound("cv:99999999".into());
3558        let api = install_error_to_api_error(&err);
3559        assert_eq!(api.code, "MODEL_NOT_FOUND");
3560    }
3561
3562    // ── preflight error message budget-cap correctness ───────────────────────
3563
3564    /// The rejection fraction used by `check_model_memory_budget`. Pinned so a
3565    /// future change to the factor forces a matching update to the error message
3566    /// (and this test).
3567    const BUDGET_FRACTION_NUMERATOR: u64 = 9;
3568    const BUDGET_FRACTION_DENOMINATOR: u64 = 10;
3569
3570    /// Compute the budget cap the way `check_model_memory_budget` does.
3571    fn expected_budget_cap(available: u64) -> u64 {
3572        available * BUDGET_FRACTION_NUMERATOR / BUDGET_FRACTION_DENOMINATOR
3573    }
3574
3575    /// The rejection error message must display the budget cap (the number that
3576    /// was actually compared against peak), not the raw available VRAM. This is
3577    /// the root-cause regression test for the "24.4 GB exceeds 25.3 GB" bug.
3578    #[test]
3579    fn preflight_error_message_states_correct_budget_cap() {
3580        // Mirrors the user's reported values: peak=24.4 GB, free=25.3 GB.
3581        // Cap = 25.3 × 0.9 = 22.77 GB. Peak (24.4) > cap (22.77) → reject.
3582        let peak: u64 = 24_400_000_000;
3583        let available: u64 = 25_300_000_000;
3584        let cap = expected_budget_cap(available);
3585
3586        // Sanity: the test scenario is actually a rejection.
3587        assert!(
3588            peak > cap,
3589            "test invariant: peak ({peak}) must exceed cap ({cap})"
3590        );
3591
3592        let result = check_model_memory_budget(
3593            "qwen-image:q8",
3594            peak,
3595            available,
3596            "Try a smaller variant (e.g. ':q5' / ':q4'), enable --offload (FLUX), or close other GPU apps.",
3597        );
3598        assert!(result.is_err(), "expected rejection, got Ok");
3599
3600        let err = result.unwrap_err();
3601        let msg = &err.error;
3602
3603        // The message must contain the cap, not just the raw available.
3604        let cap_gb = cap as f64 / 1_000_000_000.0;
3605        let cap_str = format!("{cap_gb:.1}");
3606        assert!(
3607            msg.contains("budget cap"),
3608            "error must mention 'budget cap', got: {msg}"
3609        );
3610        assert!(
3611            msg.contains(&cap_str),
3612            "error must contain the cap value ~{cap_str} GB, got: {msg}"
3613        );
3614
3615        // The message must NOT imply that peak < available (the original bug).
3616        // If the message says "exceeds X GB" where X > peak, the user will be confused.
3617        // We detect this by checking there's no bare available_gb with no "cap" context
3618        // that would make the inequality look false.
3619        let available_gb = available as f64 / 1_000_000_000.0;
3620        let available_str = format!("{available_gb:.1}");
3621        // available_gb should appear only as the input to the cap formula, not as the
3622        // comparison target. Presence of "budget cap" already anchors correct phrasing.
3623        let _ = available_str; // checked indirectly via the "budget cap" assertion above
3624    }
3625
3626    /// The "exceeds" target printed in the rejection message must always be
3627    /// strictly less than the printed peak. A table of (peak_gb, available_gb)
3628    /// rejection scenarios verifies no phrasing inverts the inequality.
3629    #[test]
3630    fn preflight_error_message_does_not_imply_peak_less_than_available() {
3631        let scenarios: &[(f64, f64)] = &[
3632            // (peak_gb, available_gb) — all must trigger rejection
3633            (24.4, 25.3), // user-reported case
3634            (19.0, 20.0), // 19 > 90% of 20 = 18
3635            (10.0, 10.5), // just over 90%
3636            (30.0, 32.0), // 30 > 90% of 32 = 28.8
3637            (9.1, 10.0),  // 9.1 > 90% of 10 = 9
3638        ];
3639        for &(peak_gb, available_gb) in scenarios {
3640            let peak = (peak_gb * 1_000_000_000.0) as u64;
3641            let available = (available_gb * 1_000_000_000.0) as u64;
3642            let cap = expected_budget_cap(available);
3643
3644            // Only test rejection scenarios.
3645            if peak <= cap {
3646                continue;
3647            }
3648
3649            let result =
3650                check_model_memory_budget("test-model", peak, available, "Try a smaller variant.");
3651            assert!(
3652                result.is_err(),
3653                "expected rejection for peak={peak_gb} available={available_gb}, got Ok"
3654            );
3655
3656            let msg = result.unwrap_err().error;
3657
3658            // The comparison target in the message ("budget cap") must be < peak.
3659            // We verify this by asserting the cap value appears in the message.
3660            let cap_gb = cap as f64 / 1_000_000_000.0;
3661            let cap_str = format!("{cap_gb:.1}");
3662            assert!(
3663                msg.contains("budget cap"),
3664                "scenario peak={peak_gb} available={available_gb}: \
3665                 message must say 'budget cap', got: {msg}"
3666            );
3667            assert!(
3668                msg.contains(&cap_str),
3669                "scenario peak={peak_gb} available={available_gb}: \
3670                 message must include cap={cap_str}, got: {msg}"
3671            );
3672        }
3673    }
3674
3675    // ── LTX-Video preflight regression (Part 1) ──────────────────────────────
3676
3677    /// Build LTX-Video-shaped paths: separate transformer (13B BF16 ≈ 26 GB),
3678    /// VAE (~0.5 GB), and T5 encoder (~9.5 GB). Mirrors the on-disk layout of
3679    /// `ltx-video-0.9.8-13b-dev:bf16` pulled via `mold pull`.
3680    fn ltx_video_13b_paths(
3681        transformer_gb: u64,
3682        vae_gb: u64,
3683        t5_gb: u64,
3684    ) -> (tempfile::TempDir, ModelPaths) {
3685        let dir = tempfile::tempdir().expect("tempdir");
3686        let mk = |name: &str, sz: u64| {
3687            let p = dir.path().join(name);
3688            let f = std::fs::File::create(&p).unwrap();
3689            f.set_len(sz * GB).unwrap();
3690            p
3691        };
3692        let transformer = mk("ltx-video-0.9.8-13b-dev_fp16.safetensors", transformer_gb);
3693        let vae = mk("ltx-video-vae.safetensors", vae_gb);
3694        let t5 = mk("t5xxl_fp16.safetensors", t5_gb);
3695        let paths = ModelPaths {
3696            transformer,
3697            transformer_shards: Vec::new(),
3698            vae,
3699            spatial_upscaler: None,
3700            temporal_upscaler: None,
3701            distilled_lora: None,
3702            t5_encoder: Some(t5),
3703            clip_encoder: None,
3704            t5_tokenizer: None,
3705            clip_tokenizer: None,
3706            clip_encoder_2: None,
3707            clip_tokenizer_2: None,
3708            text_encoder_files: Vec::new(),
3709            text_tokenizer: None,
3710            decoder: None,
3711        };
3712        (dir, paths)
3713    }
3714
3715    /// Regression: `ltx-video-0.9.8-13b-dev:bf16` at 768×512×25 on a 24 GB
3716    /// card MUST be rejected by the preflight. The transformer is ~26 GB BF16
3717    /// (non-streaming, loaded whole), which alone exceeds 24 GB VRAM. Before
3718    /// the fix, `activation_family_for("ltx-video")` returned `Ltx2Video`
3719    /// which triggered the streaming cap path (6 GB) and let the load through,
3720    /// causing a hard OOM during `load_transformer`.
3721    #[test]
3722    fn preflight_rejects_ltx_video_13b_at_768x512x25_on_24gb_card() {
3723        // 26 GB transformer + 0.5 GB VAE + 9.5 GB T5 (mirrors real disk layout).
3724        // Sequential peak = max(T5=9.5, transformer+VAE=26.5) + 2 GB headroom
3725        //                 = 26.5 + 2 = 28.5 GB > 90% of 24 GB (21.6 GB) → reject.
3726        let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
3727        let hint = ActivationHint {
3728            width: 768,
3729            height: 512,
3730            batch: 1,
3731            dtype_bytes: 2,
3732            family: ActivationFamily::LtxVideo,
3733        };
3734        let result = preflight_memory_guard_with_available(
3735            "ltx-video-0.9.8-13b-dev:bf16",
3736            &paths,
3737            0,
3738            24 * GB,
3739            Some(hint),
3740        );
3741        assert!(
3742            result.is_err(),
3743            "13B LTX-Video BF16 (26 GB transformer) must be rejected on a 24 GB card — \
3744             the transformer is not streamed and its full weight must be counted, \
3745             got {result:?}",
3746        );
3747        let err = result.unwrap_err();
3748        // Message must surface the LTX-Video-specific mitigation hints.
3749        assert!(
3750            err.error.contains("frames") || err.error.contains("width"),
3751            "rejection message must suggest reducing frames or resolution, got: {}",
3752            err.error,
3753        );
3754    }
3755
3756    /// Golden: the preflight estimate for LTX-Video 13B BF16 must be within
3757    /// ~3 GB of the expected peak (Sequential: max(T5, transformer+VAE) +
3758    /// 2 GB headroom = max(9.5, 26.5) + 2 = 28.5 GB). We accept ±3 GB to
3759    /// account for rounding in file sizes; the key invariant is that it's
3760    /// never so low that a 24 GB card incorrectly admits the load.
3761    #[test]
3762    fn preflight_estimate_for_ltx_video_13b_within_expected_range() {
3763        let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
3764        // Sequential peak = max(T5=10, transformer+VAE=27) + 2 headroom = 29 GB.
3765        // (We use 10 GB for T5 to keep nice round numbers; real T5 is ~9.5 GB.)
3766        let expected_gb = 29u64;
3767        let peak = mold_inference::device::estimate_peak_memory(
3768            &paths,
3769            mold_inference::LoadStrategy::Sequential,
3770        );
3771        let peak_gb = peak / GB;
3772        assert!(
3773            peak_gb >= expected_gb.saturating_sub(3),
3774            "peak estimate ({peak_gb} GB) is unexpectedly low — LTX-Video 13B BF16 \
3775             sequential estimate should be ≥ {} GB",
3776            expected_gb.saturating_sub(3),
3777        );
3778        assert!(
3779            peak_gb <= expected_gb + 3,
3780            "peak estimate ({peak_gb} GB) is unexpectedly high for 26+1+10 GB layout \
3781             — should be ≤ {} GB",
3782            expected_gb + 3,
3783        );
3784    }
3785
3786    /// `activation_family_for("ltx-video")` must return `LtxVideo` (non-streaming),
3787    /// not `Ltx2Video`. Before the fix both slugs returned `Ltx2Video`, which
3788    /// caused the preflight to apply the streaming cap and admit an OOM load.
3789    #[test]
3790    fn activation_family_for_ltx_video_is_non_streaming() {
3791        let family = mold_inference::device::activation_family_for("ltx-video");
3792        assert_eq!(
3793            family,
3794            ActivationFamily::LtxVideo,
3795            "ltx-video slug must map to LtxVideo (non-streaming, full-weight load)"
3796        );
3797        // Verify it does NOT trigger the streaming cap path.
3798        assert!(
3799            !family.streaming_transformer(),
3800            "LtxVideo must NOT be treated as a streaming transformer — \
3801             it loads the entire weight file into VRAM at generate time"
3802        );
3803        // Verify ltx2 still maps to the streaming variant.
3804        assert!(
3805            mold_inference::device::activation_family_for("ltx2").streaming_transformer(),
3806            "ltx2 must still map to the streaming family"
3807        );
3808    }
3809
3810    /// The rejection message for an OOM-at-preflight LTX-Video load must
3811    /// mention reducing frames or resolution (not `--offload`, which is a
3812    /// FLUX-specific flag and not applicable to LTX-Video).
3813    #[test]
3814    fn preflight_rejection_message_for_ltx_video_suggests_frames_or_resolution() {
3815        let (_dir, paths) = ltx_video_13b_paths(26, 1, 10);
3816        let hint = ActivationHint {
3817            width: 768,
3818            height: 512,
3819            batch: 1,
3820            dtype_bytes: 2,
3821            family: ActivationFamily::LtxVideo,
3822        };
3823        let result = preflight_memory_guard_with_available(
3824            "ltx-video-0.9.8-13b-dev:bf16",
3825            &paths,
3826            0,
3827            24 * GB,
3828            Some(hint),
3829        );
3830        let err = result.expect_err("must reject");
3831        assert!(
3832            err.error.contains("frames") || err.error.contains("width"),
3833            "LTX-Video rejection message must suggest reducing frames or \
3834             width/height (not --offload), got: {}",
3835            err.error,
3836        );
3837        // Must NOT suggest --offload (that's a FLUX flag, not applicable here).
3838        assert!(
3839            !err.error.contains("--offload"),
3840            "LTX-Video rejection must not mention --offload (FLUX-only flag), \
3841             got: {}",
3842            err.error,
3843        );
3844    }
3845
3846    #[test]
3847    fn preflight_rejection_message_for_image_suggests_resolution_not_frames() {
3848        let hint = ActivationHint {
3849            width: 1024,
3850            height: 1024,
3851            batch: 2,
3852            dtype_bytes: 2,
3853            family: ActivationFamily::SdxlUnet,
3854        };
3855        let suggestion = rejection_suggestion(Some(hint));
3856
3857        assert!(
3858            suggestion.contains("--width/--height"),
3859            "image preflight suggestion should mention resolution; got: {suggestion}"
3860        );
3861        assert!(
3862            suggestion.contains("--batch"),
3863            "image preflight suggestion should mention batch; got: {suggestion}"
3864        );
3865        assert!(
3866            !suggestion.contains("--frames"),
3867            "image preflight suggestion must not mention video frames; got: {suggestion}"
3868        );
3869    }
3870}