"""
WandB model download tool for engineering design models.
This tool allows downloading pre-trained generative models from WandB (Weights & Biases)
that can be used for inverse design tasks. The models are trained using the engiopt
library and are available on the engibench WandB project.
"""
import logging
import os
import re
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Any, Literal, cast
from langchain_core.tools import tool
from src.tools.algorithms import SUPPORTED_ALGORITHMS
from src.tools.problems import (
PROBLEM_CLASSES,
ProblemId,
SUPPORTED_PROBLEMS,
harness_problem_id,
)
logger = logging.getLogger(__name__)
# ======================================================================
# Dataclasses
# ======================================================================
[docs]
@dataclass
class HPCContext:
algorithm: str
problem_id: str
epochs: int
gpu_count: int
total_hours: float
cpu_count: int
mem_value: float
[docs]
@dataclass
class TrainingConfig:
algorithm: str = "cgan_cnn_2d"
epochs: int = 200
seed: int = 1
wandb_entity: str | None = None
problem_id: ProblemId = "beams2d"
gpus: int | None = None
time_hours: float | None = None
def _get_artifact_info(
problem_id: str, algorithm: str, model_type: str
) -> dict[str, str]:
"""
Get the correct artifact name and checkpoint filename based on algorithm type.
Args:
problem_id: Problem identifier (e.g., "beams2d")
algorithm: Algorithm name (e.g., "cgan_cnn_2d", "diffusion_2d_cond")
model_type: Model type requested (e.g., "generator", "discriminator")
Returns:
dict with "artifact_name" and "checkpoint_filename"
"""
# For diffusion models, use "model" nomenclature
if algorithm == "diffusion_2d_cond":
artifact_name = f"{problem_id}_{algorithm}_model"
checkpoint_filename = "model.pth"
# For GAN models, use generator/discriminator nomenclature
else:
artifact_name = f"{problem_id}_{algorithm}_{model_type}"
checkpoint_filename = f"{model_type}.pth"
return {
"artifact_name": artifact_name,
"checkpoint_filename": checkpoint_filename,
}
@tool
def download_wandb_model( # noqa: PLR0913
problem_id: ProblemId = "beams2d",
algorithm: str = "cgan_cnn_2d",
seed: int = 1,
model_type: Literal["discriminator", "generator"] = "generator",
wandb_project: str | None = None,
download_dir: str | None = None,
) -> dict[str, Any]:
"""
Download a pre-trained generative model from WandB for engineering design.
Use this tool when the user asks to "download a model", "get a model from wandb",
"download the generator", or similar requests to download trained models.
This tool downloads models trained with the engiopt library that can be used
for inverse design tasks. By default, it searches the user's personal models first,
then falls back to official benchmark models. No project specification needed!
Args:
problem_id: Engineering problem identifier. Supported: "beams2d", "photonics2d"
algorithm: Model architecture to download. Options:
- cgan_cnn_2d: Conditional GAN + CNN (2D) [default]
- diffusion_2d_cond: Conditional Diffusion (2D)
seed: Random seed / version number of the model to download.
IMPORTANT: If the user specifies a seed number (e.g., "seed 198", "with seed 198"),
you MUST pass that number here. Default: 1
model_type: Type of model to download. Options:
- generator: Download generator model [default] (for GANs)
- discriminator: Download discriminator model (for GANs)
Note: For diffusion models, this parameter is ignored as they use a single "model" artifact
wandb_project: WandB project path in format "organization/project" [OPTIONAL]
DEFAULT BEHAVIOR (None): Automatically searches:
1. Personal models: built from Settings page (WandB Entity / WandB Project)
2. Official models: hardcoded "engibench/engiopt" benchmark project
ADVANCED: Specify explicit project to search only that one (e.g., "username/project")
download_dir: Directory to download the model to. If None, uses WandB's default cache.
Returns:
dict with download information:
- success: bool indicating if download succeeded
- message: str with formatted success message - ALWAYS show this to the user verbatim
- artifact_path: str with full WandB artifact path
- download_path: str with local path to downloaded model
- checkpoint_path: str with path to model checkpoint file
- model_type: str indicating model type ('generator', 'discriminator', or 'model')
- algorithm_info: dict with algorithm properties (dimensions, conditional, etc.)
- run_config: dict with training configuration (if available)
- error: str with error message (only if success=False)
IMPORTANT: Always display the 'message' field to users as it contains important
information about seed mismatches and download details.
Example:
>>> # Download a discriminator model (default for GANs)
>>> # Tries personal project first, then official
>>> result = download_wandb_model(
... problem_id="beams2d",
... algorithm="cgan_cnn_2d",
... seed=1
... )
>>> if result['success']:
... print(f"Model downloaded to: {result['checkpoint_path']}")
... print(f"Model type: {result['model_type']}")
>>> # Download a generator model from specific project
>>> result = download_wandb_model(
... problem_id="beams2d",
... algorithm="cgan_cnn_2d",
... seed=1,
... model_type="generator",
... wandb_project="engibench/engiopt"
... )
>>> # Download a diffusion model (tries personal, then official)
>>> result = download_wandb_model(
... problem_id="beams2d",
... algorithm="diffusion_2d_cond",
... seed=1
... )
>>> # Diffusion models use a single "model" artifact, model_type is ignored
Note:
- Requires wandb to be installed: pip install wandb
- Requires USE_WANDB environment variable to be set to "True"
- You may need to authenticate with WandB using: wandb login
- For diffusion models, the model_type parameter is ignored
- Personal project comes from the Settings page (WandB Entity + WandB Project),
with WANDB_ENTITY / WANDB_PROJECT env vars as a fallback for non-UI contexts.
- Official project is hardcoded to "engibench/engiopt".
"""
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info("=" * 60)
logger.info("download_wandb_model called with:")
logger.info(f" problem_id: {problem_id}")
logger.info(f" algorithm: {algorithm}")
logger.info(f" seed: {seed}")
logger.info(f" model_type: {model_type}")
logger.info(f" wandb_project: {wandb_project}")
logger.info(f" download_dir: {download_dir}")
logger.info("=" * 60)
# Validation checks
error_response = _validate_download_inputs(problem_id, algorithm)
if error_response:
return error_response
# If no project specified, try multiple projects in order
if wandb_project is None:
from config import get_setting_from_db
# Personal project: build "entity/project" from Settings page values
# (falls back to env vars for non-UI contexts).
personal_entity = get_setting_from_db(
"slurm_wandb_entity", os.getenv("WANDB_ENTITY", "")
)
personal_project_name = get_setting_from_db(
"slurm_wandb_project", os.getenv("WANDB_PROJECT", "")
)
personal_project = (
f"{personal_entity}/{personal_project_name}"
if personal_entity and personal_project_name
else ""
)
# Official project: hardcoded benchmark models.
official_project = "engibench/engiopt"
projects_to_try = [
p
for p in [personal_project, official_project]
if p # Skip unconfigured projects
]
if not projects_to_try:
return {
"success": False,
"error": "No W&B projects configured. Set WandB Entity and "
"WandB Project on the Settings page, or set the "
"WANDB_ENTITY and WANDB_PROJECT environment variables.",
}
last_error = None
for project in projects_to_try:
result = _download_from_wandb(
problem_id=problem_id,
algorithm=algorithm,
seed=seed,
model_type=model_type,
wandb_project=project,
download_dir=download_dir,
)
if result.get("success"):
return result
last_error = result.get("error", "Unknown error")
logger.warning(f"Not found in {project}, trying next project...")
# If all projects failed, return the last error
return {
"success": False,
"error": f"Model not found in any W&B project. Last error: {last_error}",
}
# Download from specified project
return _download_from_wandb(
problem_id=problem_id,
algorithm=algorithm,
seed=seed,
model_type=model_type,
wandb_project=wandb_project,
download_dir=download_dir,
)
def _validate_download_inputs(problem_id: str, algorithm: str) -> dict[str, Any] | None:
"""Validate inputs for download_wandb_model."""
# Check if USE_WANDB is enabled
use_wandb = os.getenv("USE_WANDB") == "True"
if not use_wandb:
return {
"success": False,
"error": "WandB is not enabled. Set USE_WANDB=True in your environment variables.",
}
# Validate algorithm
if algorithm not in SUPPORTED_ALGORITHMS:
return {
"success": False,
"error": f"Unsupported algorithm '{algorithm}'. Supported algorithms: {', '.join(SUPPORTED_ALGORITHMS.keys())}",
}
# Validate problem_id
if problem_id not in SUPPORTED_PROBLEMS:
return {
"success": False,
"error": f"Unsupported problem_id '{problem_id}'. Supported problems: {', '.join(SUPPORTED_PROBLEMS)}",
}
return None
def _download_from_wandb( # noqa: PLR0913, PLR0912, PLR0915
problem_id: str,
algorithm: str,
seed: int,
model_type: str,
wandb_project: str,
download_dir: str | None,
) -> dict[str, Any]:
"""Download model artifact from WandB."""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[DOWNLOAD] _download_from_wandb called for project: {wandb_project}")
try:
# Use explicit import to avoid lazy loading issues
import wandb.apis.public
api_class = wandb.apis.public.Api
logger.info("[DOWNLOAD] Importing wandb API - success")
except (ImportError, AttributeError) as e:
logger.exception("[DOWNLOAD] Failed to import wandb Api")
return {
"success": False,
"error": f"Failed to import wandb Api: {e}",
}
try:
logger.info(
f"[DOWNLOAD] Getting artifact info for {problem_id}/{algorithm}/{model_type}"
)
# Get correct artifact naming based on algorithm
artifact_info = _get_artifact_info(problem_id, algorithm, model_type)
artifact_name = artifact_info["artifact_name"]
checkpoint_filename = artifact_info["checkpoint_filename"]
logger.info(
f"[DOWNLOAD] Artifact name: {artifact_name}, checkpoint: {checkpoint_filename}"
)
# Construct the artifact path
# Try seed_X format first, then fall back to vX or latest
artifact_version = f"seed_{seed}"
artifact_path = f"{wandb_project}/{artifact_name}:{artifact_version}"
logger.info(f"[DOWNLOAD] Full artifact path: {artifact_path}")
# Initialize WandB API
logger.info("[DOWNLOAD] Initializing WandB API...")
api = api_class()
logger.info("[DOWNLOAD] WandB API initialized successfully")
# Determine display name for logging
display_name = "model" if algorithm == "diffusion_2d_cond" else model_type
# Download the artifact - try seed_X first, then v{X}, then latest
logger.info(f"[DOWNLOAD] Attempting to fetch artifact: {artifact_path}")
logger.info(
f"Downloading {display_name} ({algorithm}) from WandB: {artifact_path}"
)
try:
artifact = api.artifact(artifact_path, type="model")
except Exception as e:
# Try vX format as fallback
logger.debug(f"Version 'seed_{seed}' not found: {e}")
logger.info(f"Trying 'v{seed}' format...")
try:
artifact_path = f"{wandb_project}/{artifact_name}:v{seed}"
artifact = api.artifact(artifact_path, type="model")
except Exception as e2:
# Try latest as last resort
logger.debug(f"Version 'v{seed}' not found: {e2}")
logger.info("Trying 'latest' version...")
artifact_path = f"{wandb_project}/{artifact_name}:latest"
artifact = api.artifact(artifact_path, type="model")
# Download to specified directory or default cache
logger.info(f"Found artifact: {artifact_path}")
logger.info("Downloading files...")
if download_dir:
artifact_dir = artifact.download(root=download_dir)
else:
artifact_dir = artifact.download()
# Construct checkpoint path
checkpoint_path = Path(artifact_dir) / checkpoint_filename
# Verify checkpoint exists
if not checkpoint_path.exists():
return {
"success": False,
"error": f"Checkpoint file not found at {checkpoint_path}",
}
logger.info(f"Downloaded successfully to: {checkpoint_path}")
# Get run configuration if available
run_config = {}
try:
run = artifact.logged_by()
if run:
# Handle different run.config types
if isinstance(run.config, str):
# Parse JSON string
import json
config_data = json.loads(run.config)
# Extract values from WandB's nested structure
run_config = {
k: v.get("value") if isinstance(v, dict) else v
for k, v in config_data.items()
if k != "_wandb"
}
elif hasattr(run.config, "items"):
run_config = dict(run.config)
elif isinstance(run.config, dict):
run_config = run.config
else:
# Try to convert to dict
try:
run_config = dict(run.config)
except (TypeError, ValueError):
logger.warning(
f"Could not convert run.config (type: {type(run.config)})"
)
run_config = {}
# Display hyperparameters if we have them
if run_config:
logger.info("=" * 60)
logger.info("Model Hyperparameters from WandB:")
logger.info("=" * 60)
for key, value in run_config.items():
logger.info(f" {key}: {value}")
logger.info("=" * 60)
except Exception as e:
# Silently skip if run config is not available - will use defaults
logger.debug(f"Note: Could not retrieve run configuration: {e}")
# Determine actual model type for response
actual_model_type = "model" if algorithm == "diffusion_2d_cond" else model_type
# Extract the actual version from the artifact path (e.g., "seed_123", "v12", "latest")
actual_version = (
artifact_path.split(":")[-1] if ":" in artifact_path else "unknown"
)
# Extract actual seed from run_config if available
actual_seed = run_config.get("seed", "unknown") if run_config else "unknown"
# Build success message with clear indication if seed differs
seed_mismatch = str(seed) != str(actual_seed) and actual_seed != "unknown"
if seed_mismatch:
seed_info = (
f"\n⚠️ **SEED MISMATCH DETECTED** ⚠️\n"
f"- Requested: seed {seed}\n"
f"- Actual: seed {actual_seed}\n"
f"- Reason: Model with seed {seed} does not exist in WandB\n"
f"- Action: Downloaded '{actual_version}' version instead (trained with seed {actual_seed})\n"
)
else:
seed_info = f"\n- Seed: {seed}"
success_message = (
f"✅ **Successfully downloaded {display_name} model!**\n\n"
f"**Details:**\n"
f"- Algorithm: `{algorithm}`\n"
f"- Problem: `{problem_id}`"
f"{seed_info}\n"
f"- Artifact version: `{actual_version}`\n"
f"- WandB artifact: `{artifact_path}`\n"
f"- Local path: `{checkpoint_path}`\n"
f"- File size: {checkpoint_path.stat().st_size / (1024 * 1024):.1f} MB"
)
return {
"success": True,
"artifact_path": artifact_path,
"download_path": artifact_dir,
"checkpoint_path": str(checkpoint_path),
"model_type": actual_model_type,
"algorithm_info": SUPPORTED_ALGORITHMS[algorithm],
"run_config": run_config,
"message": success_message,
}
except Exception as e:
logger.exception("[DOWNLOAD] Exception in download function")
return {
"success": False,
"error": f"Failed to download model from WandB: {e!s}",
}
@tool
def list_available_algorithms() -> dict[str, Any]:
"""
List all available algorithms for model download.
Returns:
dict with:
- algorithms: list of algorithm names
- details: dict mapping algorithm names to their properties
Example:
>>> info = list_available_algorithms()
>>> for algo in info['algorithms']:
... props = info['details'][algo]
... print(f"{algo}: {props['model']} ({props['dimensions']})")
"""
return {
"algorithms": list(SUPPORTED_ALGORITHMS.keys()),
"details": SUPPORTED_ALGORITHMS,
"message": f"Found {len(SUPPORTED_ALGORITHMS)} available algorithms",
}
def _resolve_inference_device( # noqa: PLR0911
requested_device: str | None,
) -> tuple[str, str | None]:
"""
Resolve the device to use for local model inference.
Device selection order:
1. Explicit device request ("cpu", "cuda", "mps")
2. ``ML_INFERENCE_DEVICE`` environment variable when the request is unset/"auto"
3. Automatic detection: ``cuda`` -> ``mps`` -> ``cpu``
Args:
requested_device: Device requested by the caller
Returns:
Tuple of (resolved_device, error_message). On success, error_message is None.
"""
import torch as th
valid_devices = {"auto", "cpu", "cuda", "mps"}
normalized_requested = (requested_device or "").strip().lower()
if normalized_requested in {"", "auto"}:
normalized_requested = (
os.getenv("ML_INFERENCE_DEVICE", "auto").strip().lower() or "auto"
)
if normalized_requested not in valid_devices:
valid = ", ".join(sorted(valid_devices))
return "", f"Invalid device '{normalized_requested}'. Expected one of: {valid}"
cuda_available = th.cuda.is_available()
mps_backend = getattr(th.backends, "mps", None)
mps_available = bool(mps_backend and mps_backend.is_available())
if normalized_requested == "auto":
if cuda_available:
return "cuda", None
if mps_available:
return "mps", None
return "cpu", None
if normalized_requested == "cuda" and not cuda_available:
return "", (
"CUDA was requested for inference, but no CUDA device is available in "
"this runtime."
)
if normalized_requested == "mps" and not mps_available:
return "", (
"MPS was requested for inference, but no Apple Metal (MPS) device is "
"available in this runtime."
)
return normalized_requested, None
@tool
def load_wandb_model( # noqa: PLR0913
checkpoint_path: str,
problem_id: ProblemId = "beams2d",
algorithm: str = "cgan_cnn_2d",
model_type: Literal["discriminator", "generator"] = "discriminator",
run_config: dict[str, Any] | None = None,
device: str = "auto",
) -> dict[str, Any]:
"""
Load a downloaded WandB model checkpoint into memory.
This tool loads the PyTorch model from a checkpoint file and prepares it for inference.
It requires the model architecture to match the algorithm type.
Args:
checkpoint_path: Path to the model checkpoint file
(e.g., discriminator.pth, generator.pth, or model.pth for diffusion)
problem_id: Engineering problem identifier. Supported: "beams2d", "photonics2d"
algorithm: Model architecture type (default: "cgan_cnn_2d")
model_type: Type of model to load:
- discriminator: Load discriminator model [default] (for GANs)
- generator: Load generator model (for GANs)
Note: For diffusion models, this parameter is informational only
run_config: Training configuration dict with model hyperparameters.
If None, will use default values.
device: Device to load model on: "auto", "cpu", "cuda", or "mps"
(default: "auto"). ``auto`` prefers ``cuda`` when available, then
``mps``, then ``cpu``.
Returns:
dict with:
- success: bool indicating if load succeeded
- model_ready: bool indicating if model is ready for inference
- model_type: str indicating model type ('generator', 'discriminator', or 'model')
- device: str with device model is loaded on
- model_info: dict with model details
- error: str with error message (only if success=False)
Example:
>>> # First download the discriminator model (default for GANs)
>>> download_result = download_wandb_model(algorithm="cgan_cnn_2d", seed=1)
>>> # Then load it
>>> load_result = load_wandb_model(
... checkpoint_path=download_result['checkpoint_path'],
... model_type="discriminator",
... run_config=download_result['run_config'],
... device="auto"
... )
>>> # Or download and load the generator model (for GANs)
>>> download_result = download_wandb_model(
... algorithm="cgan_cnn_2d",
... seed=1,
... model_type="generator"
... )
>>> load_result = load_wandb_model(
... checkpoint_path=download_result['checkpoint_path'],
... model_type="generator",
... run_config=download_result['run_config'],
... device="auto"
... )
>>> # Download and load a diffusion model
>>> download_result = download_wandb_model(
... algorithm="diffusion_2d_cond",
... seed=1
... )
>>> load_result = load_wandb_model(
... checkpoint_path=download_result['checkpoint_path'],
... algorithm="diffusion_2d_cond",
... run_config=download_result['run_config'],
... device="auto"
... )
Note:
- Requires PyTorch: pip install torch
- Requires the corresponding model architecture from engiopt
- Model is set to eval mode after loading
- For diffusion models, the model_type parameter is informational only
"""
import logging
import torch as th
logger = logging.getLogger(__name__)
# Validate checkpoint path
checkpoint_file = Path(checkpoint_path)
if not checkpoint_file.exists():
return {
"success": False,
"error": f"Checkpoint file not found: {checkpoint_path}",
}
# Validate algorithm
if algorithm not in SUPPORTED_ALGORITHMS:
return {
"success": False,
"error": f"Unsupported algorithm '{algorithm}'. Supported algorithms: {', '.join(SUPPORTED_ALGORITHMS.keys())}",
}
try:
resolved_device, device_error = _resolve_inference_device(device)
if device_error:
return {"success": False, "error": device_error}
# Load checkpoint
ckpt = th.load(checkpoint_path, map_location=resolved_device)
# Display hyperparameters if provided
if run_config:
logger.info("=" * 60)
logger.info("Model Hyperparameters:")
logger.info("=" * 60)
for key, value in run_config.items():
logger.info(f" {key}: {value}")
logger.info("=" * 60)
# Get model configuration
if run_config and "latent_dim" in run_config:
latent_dim = run_config["latent_dim"]
else:
# Default latent dimension
latent_dim = 32
return {
"success": True,
"model_ready": True,
"model_type": model_type,
"device": resolved_device,
"model_info": {
"algorithm": algorithm,
"problem_id": problem_id,
"model_type": model_type,
"latent_dim": latent_dim,
"checkpoint_keys": list(ckpt.keys()),
},
"message": f"{model_type.capitalize()} model structure loaded. Note: Full model initialization requires engiopt library and problem instance.",
"note": f"To fully initialize the {model_type} model, you need to import the appropriate {model_type.capitalize()} class from engiopt and create it with the problem's design space shape.",
}
except Exception as e:
return {
"success": False,
"error": f"Failed to load model checkpoint: {e!s}",
}
def _validate_sampling_inputs(
algorithm: str,
problem_id: str,
conditions: list[dict[str, float | bool]] | None,
n_samples: int,
) -> dict[str, Any] | None:
"""Validate inputs for design sampling. Returns error dict or None if valid."""
# Validate algorithm
if algorithm not in SUPPORTED_ALGORITHMS:
return {
"success": False,
"error": f"Unsupported algorithm '{algorithm}'. Supported algorithms: {', '.join(SUPPORTED_ALGORITHMS.keys())}",
}
# Validate problem_id
if problem_id not in SUPPORTED_PROBLEMS:
return {
"success": False,
"error": f"Unsupported problem_id '{problem_id}'. Supported problems: {', '.join(SUPPORTED_PROBLEMS)}",
}
# Validate conditions type and structure
if conditions is not None:
# Determine error message based on validation failures
error_msg = None
if not isinstance(conditions, list):
# Check if it's a dataclass (like Conditions object from problem.conditions)
if hasattr(conditions, "__dataclass_fields__"):
error_msg = (
"'Conditions' object is not iterable. "
"Please pass conditions as a list of dictionaries, not as a Conditions object. "
f"Expected: list[dict[str, float | bool]], got: {type(conditions).__name__}. "
"Example: conditions=[{{'volfrac': 0.35, 'rmin': 2.0, 'overhang_constraint': False}}] "
"or leave as None to use default conditions."
)
else:
error_msg = f"conditions must be a list of dictionaries or None. Got: {type(conditions).__name__}"
elif conditions and not isinstance(conditions[0], dict):
error_msg = f"conditions must be a list of dictionaries. Got list of: {type(conditions[0]).__name__}"
elif len(conditions) != n_samples:
error_msg = f"Number of conditions ({len(conditions)}) must match n_samples ({n_samples})"
if error_msg:
return {"success": False, "error": error_msg}
return None
def _import_generator_class(
algorithm: str,
) -> tuple[type | None, dict[str, Any] | None]:
"""Import the appropriate Generator class for GAN algorithms. Returns (class, error_dict)."""
# ruff: noqa: I001, PLC0415
try:
if algorithm == "cgan_cnn_2d":
from engiopt.generators.cgan_cnn_2d.cgan_cnn_2d import Generator # type: ignore[import-untyped]
return Generator, None
else:
return None, {
"success": False,
"error": f"Generator class not applicable for algorithm: {algorithm}. Use diffusion-specific loading.",
}
except ImportError as e:
return None, {
"success": False,
"error": f"Failed to import Generator class for {algorithm}: {e}. Install engiopt library.",
}
# A checkpoint's conditioning width is baked into its weights: the cGAN
# generator feeds the condition vector through ``c_path`` and the diffusion UNet
# through ``encoder_hid_proj``, so the *input* dim of either tensor is the
# ``n_conds`` that model was trained with. Recovering it matters because the
# width we would otherwise build with comes from ``problem.conditions``, which
# moves when engibench bumps a problem version -- photonics2d v1 grew from 3
# conditions to 6 -- and the strict ``load_state_dict`` below then fails with a
# bare shape error that says nothing about the actual cause.
#
# The axis differs per layer type and is easy to get backwards: ``c_path.0`` is a
# ConvTranspose2d, whose weight is (in_channels, out_channels, kH, kW), so
# n_conds is axis 0; ``encoder_hid_proj`` is a Linear, whose weight is
# (out_features, in_features), so n_conds is axis 1.
_N_CONDS_TENSOR_AXES = {
"c_path.0.weight": 0,
"encoder_hid_proj.weight": 1,
}
def _model_n_conds(problem: Any) -> int:
"""Width of the condition tensor a generator takes for ``problem``.
Not ``len(problem.conditions)``: a problem's declared conditions can include
values no model can be conditioned on. photonics2d v1 declares six but only
three are dataset columns -- the rest are solver settings that do not vary
per design. Building a six-wide model against a three-wide checkpoint is a
shape error at load time, which is what `_checkpoint_n_conds` exists to
report; this makes it not happen in the first place.
"""
from benchmarks.shared.problem_contracts import (
PROBLEM_MANIFEST,
)
contract = PROBLEM_MANIFEST.get(getattr(problem, "problem_id", "") or "")
if contract is not None:
return contract.n_conds
keys = getattr(problem, "conditions_keys", None)
if keys is not None:
try:
columns = set(problem.dataset["train"].column_names)
except Exception:
return len(fields(problem.conditions))
return sum(1 for k in keys if k in columns)
return len(fields(problem.conditions))
def _checkpoint_n_conds(state_dict: Any) -> int | None:
"""Conditioning width baked into ``state_dict``.
``None`` when the width is not recoverable -- an unconditional model, or an
architecture whose conditioning does not pass through one of the tensors
above. Callers treat that as "cannot check" and proceed, so a model this
does not recognise keeps its previous behaviour instead of being rejected.
"""
if not hasattr(state_dict, "get"):
return None
for name, axis in _N_CONDS_TENSOR_AXES.items():
tensor = state_dict.get(name)
shape = getattr(tensor, "shape", None)
if shape is not None and len(shape) > axis:
return int(shape[axis])
return None
def _conditioning_mismatch_error(
problem: Any,
checkpoint_path: str,
ckpt_n_conds: int,
problem_n_conds: int,
) -> dict[str, Any]:
"""Error dict for a checkpoint trained against a different problem version."""
module = type(problem).__module__
keys = ", ".join(str(k) for k in problem.conditions_keys)
return {
"success": False,
"error": (
f"Checkpoint '{checkpoint_path}' was trained with {ckpt_n_conds} "
f"conditions, but the installed {module} defines {problem_n_conds} "
f"({keys}). The checkpoint was trained against a different version "
"of this problem, so its weights do not fit and its conditions do "
"not carry the same meaning. Retrain the model against the "
"installed problem version, or install the engibench version the "
"checkpoint was trained with."
),
}
def _load_diffusion_model(
ckpt_path: str,
device: str,
problem: Any,
) -> tuple[Any, Any, int, dict[str, Any] | None]:
"""
Load a diffusion model from checkpoint.
Returns: (model, sampler, num_timesteps, error_dict)
"""
import logging
logger = logging.getLogger(__name__)
try:
import torch as th
from diffusers import UNet2DConditionModel # type: ignore[import-untyped]
from engiopt.generators.diffusion_2d_cond.diffusion_2d_cond import ( # type: ignore[import-untyped]
beta_schedule,
DiffusionSampler,
)
except ImportError as e:
return (
None,
None,
0,
{
"success": False,
"error": f"Required library not installed: {e}. Install with: pip install diffusers engiopt",
},
)
try:
# Load checkpoint
ckpt = th.load(ckpt_path, map_location=device)
# Get run config from checkpoint if available
run_config = ckpt.get("config", {})
# Display hyperparameters if available
if run_config:
logger.info("=" * 60)
logger.info("Diffusion Model Hyperparameters:")
logger.info("=" * 60)
for key, value in run_config.items():
logger.info(f" {key}: {value}")
logger.info("=" * 60)
# Set defaults if not in checkpoint
layers_per_block = run_config.get("layers_per_block", 2)
num_timesteps = run_config.get("num_timesteps", 1000)
noise_schedule = run_config.get("noise_schedule", "linear")
# Refuse a checkpoint whose conditioning width no longer matches the
# problem's, rather than letting load_state_dict raise a bare shape error.
problem_n_conds = _model_n_conds(problem)
ckpt_n_conds = _checkpoint_n_conds(ckpt.get("model"))
if ckpt_n_conds is not None and ckpt_n_conds != problem_n_conds:
return (
None,
None,
0,
_conditioning_mismatch_error(
problem, ckpt_path, ckpt_n_conds, problem_n_conds
),
)
# Initialize the UNet2D model
model = UNet2DConditionModel(
sample_size=problem.design_space.shape,
in_channels=1,
out_channels=1,
cross_attention_dim=64,
block_out_channels=(32, 64, 128, 256),
down_block_types=(
"CrossAttnDownBlock2D",
"CrossAttnDownBlock2D",
"CrossAttnDownBlock2D",
"DownBlock2D",
),
up_block_types=(
"UpBlock2D",
"CrossAttnUpBlock2D",
"CrossAttnUpBlock2D",
"CrossAttnUpBlock2D",
),
layers_per_block=layers_per_block,
transformer_layers_per_block=1,
encoder_hid_dim=_model_n_conds(problem),
only_cross_attention=True,
).to(device) # type: ignore[attr-defined]
# Load model weights
model.load_state_dict(ckpt["model"])
model.eval()
# Set up noise schedule
options = {
"cosine": noise_schedule == "cosine",
"exp_biasing": noise_schedule == "exp",
"exp_bias_factor": 1,
}
betas = beta_schedule(
t=num_timesteps,
start=1e-4,
end=0.02,
scale=1.0,
options=options,
)
# Create diffusion sampler
sampler = DiffusionSampler(num_timesteps, betas)
except Exception as e:
return (
None,
None,
0,
{
"success": False,
"error": f"Failed to load diffusion model: {e}",
},
)
else:
return model, sampler, num_timesteps, None
def _generate_designs(
model: Any,
config: dict[str, Any],
) -> Any:
"""Generate designs using a GAN model.
Args:
model: The generator model
config: Dict with keys: n_samples, latent_dim, device, is_conditional, conditions_tensor
"""
import torch as th
# Sample noise as generator input
z = th.randn(
(config["n_samples"], config["latent_dim"], 1, 1),
device=config["device"],
dtype=th.float,
)
# Generate designs
with th.no_grad():
gen_designs = (
model(z, config["conditions_tensor"])
if config["is_conditional"] and config["conditions_tensor"] is not None
else model(z)
)
return gen_designs
def _generate_designs_diffusion(
model: Any,
sampler: Any,
config: dict[str, Any],
) -> Any:
"""Generate designs using a diffusion model.
Args:
model: The UNet2D diffusion model
sampler: The DiffusionSampler instance
config: Dict with keys: n_samples, device, conditions_tensor, design_shape, num_timesteps
"""
import torch as th
n_samples = config["n_samples"]
device = config["device"]
conditions_tensor = config["conditions_tensor"]
design_shape = config["design_shape"]
num_timesteps = config["num_timesteps"]
# Start with random noise
gen_designs = th.randn((n_samples, 1, *design_shape), device=device)
# Iteratively denoise using the diffusion sampler
with th.no_grad():
for i in reversed(range(num_timesteps)):
t = th.full((n_samples,), i, device=device, dtype=th.long)
gen_designs = sampler.sample_timestep(
model, gen_designs, t, conditions_tensor
)
return gen_designs
def _save_designs( # noqa: PLR0913
gen_designs: Any,
n_samples: int,
output_path: Path,
problem: Any,
problem_id: str = "unknown",
conditions: list[dict[str, float | bool]] | None = None,
) -> tuple[list[str], list[str]]:
"""Save generated designs as .npy and .png files. Returns (design_files, render_files)."""
import numpy as np
import matplotlib.pyplot as plt
import logging
import datetime
logger = logging.getLogger(__name__)
design_files = []
render_files = []
# Create timestamp for this batch of designs
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
for i in range(n_samples):
design = gen_designs[i]
# Log design statistics for debugging
logger.info(f"Design {i} statistics:")
logger.info(f" Shape: {design.shape}")
logger.info(f" Min: {design.min():.4f}, Max: {design.max():.4f}")
logger.info(f" Mean: {design.mean():.4f}, Std: {design.std():.4f}")
logger.info(f" Non-zero elements: {np.count_nonzero(design)}/{design.size}")
# Save as numpy array with descriptive filename
design_filename = (
output_path / f"{problem_id}_design_generated_{i}_{timestamp}.npy"
)
np.save(design_filename, design)
design_files.append(str(design_filename))
# Update problem conditions if provided (important for problems with variable conditions)
# If conditions are provided, create a new problem instance with specific conditions
render_problem = problem
if conditions and i < len(conditions):
try:
# Get problem class from registry
problem_class = PROBLEM_CLASSES[problem_id]
render_problem = problem_class(seed=0, config=conditions[i])
except Exception as e:
logger.warning(
f" Failed to create problem with conditions: {e}, using default"
)
# Render and save visualization with descriptive filename
render_filename = (
output_path / f"{problem_id}_design_generated_{i}_{timestamp}.png"
)
try:
render_result = render_problem.render(design)
# Extract figure from render result (handles both tuple and single figure)
if isinstance(render_result, tuple):
# Beams2D returns (fig, ax)
fig, _ = render_result
else:
# ThermoElastic2D returns just fig
fig = render_result
# Save to file
fig.savefig(str(render_filename), dpi=150, bbox_inches="tight")
render_files.append(str(render_filename))
plt.close(fig) # Close figure to free memory
logger.info(f" Saved render to: {render_filename}")
except Exception:
logger.exception(f" Failed to render design {i}")
render_files.append("ERROR: Rendering failed")
return design_files, render_files
# ======================================================================
# Parsing helpers
# ======================================================================
def _parse_hpc_inputs(cfg: HPCInputs):
"""Parse and normalize raw SLURM resource strings into numeric values."""
errors = []
# GPU count
try:
gpu_count = (
int(cfg.slurm_gpus.split(":")[-1])
if ":" in cfg.slurm_gpus
else int(cfg.slurm_gpus)
)
except (ValueError, IndexError):
gpu_count = 1
errors.append(f"Invalid GPU specification: {cfg.slurm_gpus}")
# Time (HH:MM:SS)
try:
parts = cfg.slurm_time.split(":")
hours = int(parts[0])
minutes = int(parts[1]) if len(parts) > 1 else 0
total_hours = hours + minutes / 60.0
except (ValueError, IndexError):
hours = 0
total_hours = 0.0
errors.append(f"Invalid time format: {cfg.slurm_time}. Expected HH:MM:SS")
# CPU count
try:
cpu_count = int(cfg.slurm_cpus_per_task)
except ValueError:
cpu_count = 4
errors.append(f"Invalid CPU count: {cfg.slurm_cpus_per_task}")
# Memory per CPU
try:
mem_str = cfg.slurm_mem_per_cpu.upper()
mem_value_raw = int("".join(filter(str.isdigit, mem_str)))
mem_value = mem_value_raw / 1024 if "M" in mem_str else float(mem_value_raw)
except (ValueError, IndexError):
mem_value = 7.0
errors.append(f"Invalid memory format: {cfg.slurm_mem_per_cpu}")
return gpu_count, total_hours, hours, cpu_count, mem_value, errors
# ======================================================================
# Hard limit validation
# ======================================================================
def _check_hard_limits(gpu_count, hours, cpu_count, mem_value):
"""Check against absolute cluster resource limits."""
errors = []
max_gpus = int(os.getenv("SLURM_MAX_GPUS", "4"))
max_time_hours = int(os.getenv("SLURM_MAX_TIME_HOURS", "24"))
max_cpus = int(os.getenv("SLURM_MAX_CPUS", "16"))
max_mem_per_cpu_gb = int(os.getenv("SLURM_MAX_MEM_PER_CPU_GB", "16"))
if gpu_count > max_gpus:
errors.append(f"GPU count ({gpu_count}) exceeds maximum allowed ({max_gpus}).")
if hours > max_time_hours:
errors.append(
f"Time limit ({hours}h) exceeds maximum allowed ({max_time_hours}h)."
)
if cpu_count > max_cpus:
errors.append(f"CPU count ({cpu_count}) exceeds maximum allowed ({max_cpus}).")
if mem_value > max_mem_per_cpu_gb:
errors.append(
f"Memory per CPU ({mem_value}GB) exceeds maximum allowed ({max_mem_per_cpu_gb}GB)."
)
return errors
# ======================================================================
# Contextual validation helpers
# ======================================================================
def _check_algorithm_recommendations(ctx: HPCContext):
"""Model- and problem-specific validation."""
warnings, recommendations = [], []
if ctx.problem_id == "beams2d":
if ctx.algorithm == "cgan_cnn_2d":
if ctx.gpu_count > 1:
warnings.append("cGAN CNN 2D typically needs only 1 GPU.")
recommendations.append("Set SLURM_GPUS=rtx_4090:1 in .env")
if ctx.total_hours > 4: # noqa: PLR2004
warnings.append("cGAN CNN 2D usually trains in 1-4 hours.")
recommendations.append("Set SLURM_TIME=04:00:00 in .env")
elif ctx.total_hours < 0.5: # noqa: PLR2004
warnings.append("Very short time limit (<0.5h). Use ≥1h.")
elif ctx.algorithm == "diffusion_2d_cond":
if ctx.gpu_count > 2: # noqa: PLR2004
warnings.append("Diffusion 2D typically needs 1-2 GPUs.")
recommendations.append("Set SLURM_GPUS=rtx_4090:1 or :2 in .env")
if ctx.total_hours < 2: # noqa: PLR2004
warnings.append("Diffusion models need more 2-4 hours.")
recommendations.append("Set SLURM_TIME=04:00:00 in .env")
elif ctx.total_hours > 8: # noqa: PLR2004
warnings.append("Training time >8h may be excessive.")
return warnings, recommendations
def _check_general_recommendations(ctx: HPCContext):
"""General rules independent of model/problem."""
warnings, recommendations = [], []
# Epochs
if ctx.epochs > 500: # noqa: PLR2004
warnings.append("Too many epochs (>500). Reduce to 200-300.")
recommendations.append("Set epochs=200-300.")
elif ctx.epochs < 50: # noqa: PLR2004
warnings.append("Too few epochs (<50). Increase to 100-200.")
recommendations.append("Set epochs=100-200.")
# CPU count
if ctx.cpu_count < 4: # noqa: PLR2004
warnings.append("Few CPUs (<4). Data loading may be slow.")
recommendations.append("Set SLURM_CPUS_PER_TASK=4 or 8.")
elif ctx.cpu_count > 8: # noqa: PLR2004
warnings.append("Many CPUs (>8) may be unnecessary.")
recommendations.append("Set SLURM_CPUS_PER_TASK=4-8.")
# Memory
if ctx.mem_value < 4: # noqa: PLR2004
warnings.append("Low memory (<4GB per CPU).")
recommendations.append("Set SLURM_MEM_PER_CPU=7GB.")
return warnings, recommendations
def _check_contextual_recommendations(ctx: HPCContext):
"""Aggregate contextual warnings and recommendations."""
algo_warnings, algo_recs = _check_algorithm_recommendations(ctx)
gen_warnings, gen_recs = _check_general_recommendations(ctx)
return (
algo_warnings + gen_warnings,
algo_recs + gen_recs,
)
# ======================================================================
# Main entrypoint
# ======================================================================
def _validate_hpc_resources(cfg: HPCInputs) -> dict[str, Any]:
"""
Validate HPC resource requests for training jobs.
Returns:
dict with:
- has_errors: bool
- has_warnings: bool
- errors: list[str]
- warnings: list[str]
- recommendations: list[str]
"""
warnings, errors, recommendations = [], [], []
gpu_count, total_hours, hours, cpu_count, mem_value, parse_errors = (
_parse_hpc_inputs(cfg)
)
errors.extend(parse_errors)
errors.extend(_check_hard_limits(gpu_count, hours, cpu_count, mem_value))
ctx = HPCContext(
algorithm=cfg.algorithm,
problem_id=cfg.problem_id,
epochs=cfg.epochs,
gpu_count=gpu_count,
total_hours=total_hours,
cpu_count=cpu_count,
mem_value=mem_value,
)
ctx_warnings, ctx_recommendations = _check_contextual_recommendations(ctx)
warnings.extend(ctx_warnings)
recommendations.extend(ctx_recommendations)
return {
"has_errors": bool(errors),
"has_warnings": bool(warnings),
"errors": errors,
"warnings": warnings,
"recommendations": recommendations,
}
def _find_or_download_model(
checkpoint_path: str | None,
problem_id: str,
algorithm: str,
seed: int = 1,
) -> tuple[str | None, dict[str, Any] | None]:
"""
Find a model checkpoint or download one if needed.
Args:
checkpoint_path: Optional path to checkpoint. If None, will search or download.
problem_id: Engineering problem identifier
algorithm: Model architecture type
seed: Random seed / version number for the model (default: 1)
Returns:
Tuple of (checkpoint_path, error_dict). Error dict is None on success.
"""
import logging
import random
logger = logging.getLogger(__name__)
# Local models sometimes serialize an omitted optional argument as text.
if isinstance(checkpoint_path, str) and checkpoint_path.strip().lower() in {
"",
"none",
"null",
}:
checkpoint_path = None
# If path provided, just validate it exists
if checkpoint_path:
checkpoint_file = Path(checkpoint_path)
if not checkpoint_file.exists():
return None, {
"success": False,
"error": f"Checkpoint file not found: {checkpoint_path}",
}
return str(checkpoint_file), None
# Search for existing models in artifacts folder
artifacts_dir = Path("artifacts")
if artifacts_dir.exists():
# Determine the expected checkpoint filename based on algorithm
if algorithm == "diffusion_2d_cond":
checkpoint_filename = "model.pth"
pattern = f"{problem_id}_{algorithm}_model:v*"
else:
checkpoint_filename = "generator.pth"
pattern = f"{problem_id}_{algorithm}_generator:v*"
# Look for checkpoint files matching the problem and algorithm
matching_dirs = list(artifacts_dir.glob(pattern))
if matching_dirs:
# Randomly select one of the matching models
selected_dir = random.choice(matching_dirs)
checkpoint = selected_dir / checkpoint_filename
if checkpoint.exists():
return str(checkpoint), None
# No existing model found, download one
logger.info(
f"No local model found for {problem_id}/{algorithm}. Downloading from WandB..."
)
download_result = download_wandb_model.invoke(
{
"problem_id": problem_id,
"algorithm": algorithm,
"seed": seed,
}
)
if not download_result.get("success"):
return None, {
"success": False,
"error": f"Failed to download model: {download_result.get('error', 'Unknown error')}",
}
return download_result["checkpoint_path"], None
@tool("ml_gan_inference")
def sample_designs_from_model( # noqa: PLR0913, PLR0911, PLR0915, PLR0912
checkpoint_path: str | None = None,
problem_id: ProblemId = "beams2d",
algorithm: str = "cgan_cnn_2d",
conditions: list[dict[str, float | bool]] | None = None,
n_samples: int = 3,
latent_dim: int = 32,
device: str = "auto",
output_dir: str = "outputs",
seed: int = 1,
) -> dict[str, Any]:
"""
⚠️ WARNING: DO NOT use this tool for optimization tasks!
If the user says "optimize", "topology optimization", "minimize compliance" → use optimize_design instead!
This tool is ONLY for sampling from pre-trained GAN/Diffusion models.
Use ONLY when user explicitly asks for: "GAN", "sample from model", "generative model", "ML inference".
This tool generates designs using pre-trained generative models (GAN, Diffusion).
It does NOT run physics-based optimization - it samples from learned distributions.
If no checkpoint_path is provided, the tool will automatically:
1. Search for existing models in the artifacts folder
2. Randomly select one if multiple are found
3. Download a model from WandB if none exist locally
Args:
checkpoint_path: Path to the generator.pth checkpoint file. Omit this argument
or pass JSON null to auto-select a local model before trying a download.
problem_id: Engineering problem identifier. Supported: "beams2d", "photonics2d"
algorithm: Model architecture type (default: "cgan_cnn_2d")
conditions: List of condition dictionaries for conditional models. Each dict should have:
For beams2d: volfrac, rmin, forcedist, overhang_constraint (bool)
For photonics2d: lambda1, lambda2, blur_radius, num_elems_x, num_elems_y, num_optimization_steps
IMPORTANT: Must be a list[dict[str, float | bool]], NOT a Conditions object.
Do NOT pass problem.conditions directly - convert to list of dicts first.
If None, will use default conditions. Length should match n_samples.
n_samples: Number of designs to generate (default: 3)
latent_dim: Latent dimension of the model (default: 32)
device: Device to run on: "auto", "cpu", "cuda", or "mps"
(default: "auto"). ``auto`` prefers ``cuda`` when available, then
``mps``, then ``cpu``.
output_dir: Directory to save generated designs (default: "outputs")
seed: Model version/seed to download if checkpoint_path is None. Will try versions
in order: seed_{X}, v{X}, latest (default: 1)
NOTE: This is for MODEL SELECTION, not generation randomness. Each generation
will produce different designs automatically using a time-based random seed.
Returns:
dict with:
- success: bool indicating if sampling succeeded
- designs: list of generated design arrays (if successful)
- design_files: list of saved .npy file paths
- render_files: list of saved .png visualization files
- conditions_used: list of conditions used for generation
- n_samples: number of designs generated
- checkpoint_path: path to the checkpoint used
- error: str with error message (only if success=False)
Example:
>>> # Auto-select/download model with default conditions
>>> result = sample_designs_from_model.invoke({
... "n_samples": 3
... })
>>> # Use specific checkpoint
>>> result = sample_designs_from_model.invoke({
... "checkpoint_path": "/path/to/generator.pth",
... "n_samples": 3
... })
>>> # Generate with specific conditions
>>> conditions = [
... {"volfrac": 0.35, "rmin": 2.0, "forcedist": 0.2, "overhang_constraint": 0.0},
... {"volfrac": 0.45, "rmin": 2.0, "forcedist": 0.2, "overhang_constraint": 0.0},
... ]
>>> result = sample_designs_from_model.invoke({
... "checkpoint_path": "/path/to/generator.pth",
... "conditions": conditions,
... "n_samples": 2
... })
Note:
- Requires PyTorch, engiopt, and engibench to be installed
- For diffusion models (diffusion_2d_cond), requires diffusers library
- For conditional models, conditions will be used during generation
- For non-conditional models, conditions are ignored
- Generated designs are automatically clipped to [0, 1] range
- Designs are saved as .npy files and visualized as .png images
- Diffusion models use iterative denoising (slower but higher quality)
- GAN models use single-shot generation (faster)
"""
import logging
import torch as th
import time
logger = logging.getLogger(__name__)
resolved_device, device_error = _resolve_inference_device(device)
if device_error:
return {"success": False, "error": device_error}
logger.info(
"Resolved local inference device: requested=%s resolved=%s",
device,
resolved_device,
)
# Set random seed for reproducibility and diversity
# Use current time in nanoseconds to ensure different designs each time
generation_seed = int(time.time_ns() % (2**32))
th.manual_seed(generation_seed)
if th.cuda.is_available():
th.cuda.manual_seed_all(generation_seed)
logger.info(f"Using generation seed: {generation_seed}")
# Validate inputs
error = _validate_sampling_inputs(algorithm, problem_id, conditions, n_samples)
if error:
return error
# Find or download a model if no checkpoint path provided
resolved_checkpoint_path, error = _find_or_download_model(
checkpoint_path, problem_id, algorithm, seed
)
if error:
return error
assert resolved_checkpoint_path is not None
try:
# Import required libraries
try:
import numpy as np
# Get problem class from registry (no hardcoded imports needed!)
if problem_id not in PROBLEM_CLASSES:
return {
"success": False,
"error": f"Unsupported problem_id: {problem_id}. Supported: {', '.join(SUPPORTED_PROBLEMS)}",
}
problem_class = PROBLEM_CLASSES[problem_id]
problem = problem_class()
except ImportError as e:
return {
"success": False,
"error": f"Required library not installed: {e}. Install with: pip install engibench numpy",
}
# Reset problem instance
problem.reset(seed=0)
# Set default conditions if not provided - extract from problem dynamically
if conditions is None:
import random
from dataclasses import asdict
# Get default conditions from the problem
# Note: problem.conditions is a dataclass, need to convert to dict
default_conditions = asdict(problem.conditions)
condition_keys = problem.conditions_keys
logger.info(
f"Generating varied conditions from problem defaults: {condition_keys}"
)
conditions = []
for _ in range(n_samples):
# Create varied conditions by adding small variations to defaults
varied_cond: dict[str, float | bool] = {}
for key in condition_keys:
base_value = default_conditions[key]
# Check for boolean first (before int/float, since bool is subclass of int)
if isinstance(base_value, bool):
# Keep boolean values as-is (no variation)
varied_cond[key] = base_value
elif isinstance(base_value, (int, float)):
# Add 10% variation for numeric values
variation = base_value * 0.1 if base_value != 0 else 0.1
new_value = base_value + random.uniform(-variation, variation)
# Clip to valid ranges based on parameter name
if key == "volfrac":
# Volume fraction must be in [0, 1], keep reasonable range
new_value = max(0.1, min(0.8, new_value))
elif key == "forcedist":
# Force distance must be in [0, 1]
new_value = max(0.0, min(1.0, new_value))
elif key == "rmin":
# Filter radius must be positive, keep reasonable
new_value = max(0.5, min(5.0, new_value))
elif key in ["weight", "lambda1", "lambda2"]:
# These are typically in [0, 1] or positive
new_value = max(0.0, min(1.0, new_value))
elif key == "blur_radius":
# Positive value
new_value = max(0.1, new_value)
varied_cond[key] = new_value
else:
# Keep other non-numeric values as-is
varied_cond[key] = base_value
conditions.append(varied_cond)
# Log generated conditions dynamically
logger.info(f"Generated varied conditions for {n_samples} designs:")
for i, cond in enumerate(conditions):
# Format condition display dynamically
cond_str = ", ".join(
[
f"{k}={v:.3f}" if isinstance(v, float) else f"{k}={v}"
for k, v in cond.items()
]
)
logger.info(f" Design {i}: {cond_str}")
# Branch based on algorithm type
if algorithm == "diffusion_2d_cond":
# Handle diffusion models
model, sampler, num_timesteps, error = _load_diffusion_model(
resolved_checkpoint_path, resolved_device, problem
)
if error:
return error
# Prepare conditions tensor for diffusion (different format than GAN)
conditions_tensor = th.tensor(
[list(c.values()) for c in conditions],
device=resolved_device,
dtype=th.float,
).unsqueeze(1) # Add channel dim
# Generate designs using diffusion
gen_config = {
"n_samples": n_samples,
"device": resolved_device,
"conditions_tensor": conditions_tensor,
"design_shape": problem.design_space.shape,
"num_timesteps": num_timesteps,
}
gen_designs = _generate_designs_diffusion(model, sampler, gen_config)
else:
# Handle GAN models
generator_class, error = _import_generator_class(algorithm)
if error:
return error
# At this point, generator_class cannot be None
assert generator_class is not None
# Load checkpoint
ckpt = th.load(resolved_checkpoint_path, map_location=resolved_device)
# Display hyperparameters if available in checkpoint, and recover
# latent_dim from the training config so the Generator is built
# with the same shape the checkpoint was saved with.
if "config" in ckpt:
logger.info("=" * 60)
logger.info("GAN Model Hyperparameters:")
logger.info("=" * 60)
for key, value in ckpt["config"].items():
logger.info(f" {key}: {value}")
logger.info("=" * 60)
ckpt_latent_dim = ckpt["config"].get("latent_dim")
if ckpt_latent_dim is not None and ckpt_latent_dim != latent_dim:
logger.info(
f"Overriding latent_dim from checkpoint config: "
f"{latent_dim} -> {ckpt_latent_dim}"
)
latent_dim = ckpt_latent_dim
else:
# Display the parameters we're using
logger.info("=" * 60)
logger.info("GAN Model Parameters (using defaults):")
logger.info("=" * 60)
logger.info(f" latent_dim: {latent_dim}")
logger.info(f" n_conds: {_model_n_conds(problem)}")
logger.info(f" design_shape: {problem.design_space.shape}")
logger.info("=" * 60)
# Initialize the model
is_conditional = SUPPORTED_ALGORITHMS[algorithm]["conditional"]
n_conds = _model_n_conds(problem) if is_conditional else 0
ckpt_n_conds = _checkpoint_n_conds(ckpt.get("generator"))
if is_conditional and ckpt_n_conds is not None and ckpt_n_conds != n_conds:
return _conditioning_mismatch_error(
problem, resolved_checkpoint_path, ckpt_n_conds, n_conds
)
model = generator_class(
latent_dim=latent_dim,
n_conds=n_conds,
design_shape=problem.design_space.shape,
)
model.load_state_dict(ckpt["generator"])
model.eval()
model.to(resolved_device)
# Prepare conditions tensor for GAN
conditions_tensor = None
if is_conditional:
conditions_tensor = (
th.tensor(
[list(c.values()) for c in conditions],
device=resolved_device,
dtype=th.float,
)
.unsqueeze(-1)
.unsqueeze(-1)
)
# Generate designs using GAN
gen_config = {
"n_samples": n_samples,
"latent_dim": latent_dim,
"device": resolved_device,
"is_conditional": is_conditional,
"conditions_tensor": conditions_tensor,
}
gen_designs = _generate_designs(model, gen_config)
# Post-process
gen_designs = gen_designs.detach().cpu().numpy().squeeze()
if n_samples == 1:
gen_designs = np.expand_dims(gen_designs, axis=0)
np.clip(gen_designs, 0, 1, out=gen_designs)
# Save designs
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
design_files, render_files = _save_designs(
gen_designs, n_samples, output_path, problem, problem_id, conditions
)
# Build message with just conditions (no images to avoid context overflow)
# Format conditions dynamically based on what the problem actually uses
message_parts = []
for i in range(n_samples):
message_parts.append(f"**Design {i}:**")
# Display conditions dynamically
for key, value in conditions[i].items():
# Format the key name nicely (convert snake_case to Title Case)
display_name = key.replace("_", " ").title()
# Format value based on type
if isinstance(value, float):
# Add special units for known parameters
if "lambda" in key.lower():
message_parts.append(f"- {display_name} (μm): {value:.3f}")
else:
message_parts.append(f"- {display_name}: {value:.3f}")
else:
message_parts.append(f"- {display_name}: {value}")
message_parts.append(f"- Render: {render_files[i]}")
if i < n_samples - 1:
message_parts.append("") # Empty line between designs
return {
"success": True,
"n_samples": n_samples,
"design_files": design_files,
"render_files": render_files,
"conditions_used": conditions,
"algorithm": algorithm,
"problem_id": problem_id,
"device": resolved_device,
"checkpoint_path": resolved_checkpoint_path,
"output_dir": str(output_path),
"message": "\n".join(message_parts),
}
except Exception as e:
return {
"success": False,
"error": f"Failed to generate designs: {e!s}",
}
# ============================================================
# Helper: load environment variables and resolve overrides
# ============================================================
def _resolve_slurm_config(cfg: TrainingConfig) -> dict[str, str]:
"""Load and normalize SLURM settings, applying overrides."""
from config import get_setting_from_db
# Get email from settings database (fresh read)
slurm_email = get_setting_from_db("slurm_email_user", "alpha@gmail.com")
slurm = {
"ntasks": os.getenv("SLURM_NTASKS", "1"),
"cpus_per_task": os.getenv("SLURM_CPUS_PER_TASK", "4"),
"mem_per_cpu": os.getenv("SLURM_MEM_PER_CPU", "7GB"),
"email_user": slurm_email or "alpha@gmail.com",
}
# GPUs
if cfg.gpus is not None:
gpu_type = os.getenv("SLURM_GPUS", "rtx_4090:1").split(":")[0]
slurm["gpus"] = f"{gpu_type}:{cfg.gpus}"
else:
slurm["gpus"] = os.getenv("SLURM_GPUS", "rtx_4090:1")
# Time (convert hours to HH:MM:SS)
if cfg.time_hours is not None:
hours = int(cfg.time_hours)
minutes = int((cfg.time_hours - hours) * 60)
slurm["time"] = f"{hours:02d}:{minutes:02d}:00"
else:
slurm["time"] = os.getenv("SLURM_TIME", "00:45:00")
return slurm
# ============================================================
# Helper: generate SLURM script (fixes PLR0915)
# ============================================================
def _build_slurm_script(
cfg: TrainingConfig, slurm: dict[str, str], command: str
) -> str:
"""Return formatted SLURM script for the given command."""
from config import get_setting_from_db
modules = {
"stack": os.getenv("SLURM_STACK_MODULE", "stack/2024-06"),
"gcc": os.getenv("SLURM_GCC_MODULE", "gcc/12.2.0"),
"python": os.getenv("SLURM_PYTHON_MODULE", "python_cuda/3.11.6"),
"cuda": os.getenv("SLURM_CUDA_MODULE", "cuda/12.4.1"),
}
# Get fresh values from database at time of use
paths = {
"venv": get_setting_from_db(
"slurm_venv_path",
"~/venvs/engiai",
),
"project": get_setting_from_db("slurm_project_path", "$HOME/EngiOpt"),
}
keys = {
"wandb_api_key": os.getenv("WANDB_API_KEY", ""),
"wandb_entity": get_setting_from_db(
"slurm_wandb_entity", os.getenv("WANDB_ENTITY", "")
),
"wandb_project": get_setting_from_db("slurm_wandb_project", "engiopt"),
"hf_home": get_setting_from_db("hf_home_remote", "$SCRATCH/models"),
"hf_datasets": get_setting_from_db(
"hf_datasets_cache_remote",
"$SCRATCH/datasets",
),
"hf_token": os.getenv("HF_TOKEN", ""),
}
# Get logs directory from settings
logs_dir = get_setting_from_db("slurm_logs_dir", "$SCRATCH/logs")
if os.getenv("SKIP_SLURM_EMAIL", "false").lower() == "true":
mail_lines = "# Mail notifications disabled (SKIP_SLURM_EMAIL)"
else:
mail_lines = (
f"#SBATCH --mail-type=END,FAIL\n#SBATCH --mail-user={slurm['email_user']}"
)
return f"""#!/bin/bash
#SBATCH --job-name={cfg.algorithm}_{cfg.problem_id}
#SBATCH --time={slurm["time"]}
#SBATCH --ntasks={slurm["ntasks"]}
#SBATCH --cpus-per-task={slurm["cpus_per_task"]}
#SBATCH --mem-per-cpu={slurm["mem_per_cpu"]}
#SBATCH --gpus={slurm["gpus"]}
#SBATCH --output=engiopt_{cfg.algorithm}_{cfg.problem_id}_%j.out
#SBATCH --error=engiopt_{cfg.algorithm}_{cfg.problem_id}_%j.err
{mail_lines}
mkdir -p "{logs_dir}" "{keys["hf_datasets"]}" "{keys["hf_home"]}"
module purge
module load {modules["stack"]} {modules["gcc"]} {modules["python"]} {modules["cuda"]} eth_proxy
source {paths["venv"]}/bin/activate
export WANDB_API_KEY="{keys["wandb_api_key"]}"
export WANDB_ENTITY="{keys["wandb_entity"]}"
export WANDB_PROJECT="{keys["wandb_project"]}"
export HF_HOME="{keys["hf_home"]}"
export HF_DATASETS_CACHE="{keys["hf_datasets"]}"
export HF_TOKEN="{keys["hf_token"]}"
cd {paths["project"]}
{command}
training_exit_code=$?
echo "Training complete (exit code $training_exit_code)"
exit $training_exit_code
"""
# ============================================================
# Helper: build API status message
# ============================================================
def _api_status_message(wandb_key: str, hf_token: str) -> str:
parts = []
if wandb_key:
parts.append(f"✓ WandB API key loaded (ending in ...{wandb_key[-4:]})")
else:
parts.append("✗ WandB API key not found in .env")
if hf_token:
parts.append(f"✓ HuggingFace token loaded (ending in ...{hf_token[-4:]})")
else:
parts.append("✗ HuggingFace token not found in .env")
return "\n".join(parts)
# ============================================================
# Helper: the reference training script a job runs
# ============================================================
_REPO_ROOT = Path(__file__).resolve().parents[2]
# Closes the quoted heredoc that writes the training script into the job's run
# directory. Quoted, so bash expands nothing inside the embedded source.
_TEMPLATE_HEREDOC = "ENGIAI_REFERENCE_TEMPLATE"
# Interpolated into the command bash runs on the cluster.
_SAFE_WANDB_ENTITY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
def _reference_template(problem_id: str, algorithm: str) -> Path:
"""The in-repo reference training script for ``(problem_id, algorithm)``.
Since EngiOpt PR #75 its own training scripts publish checkpoints to
HuggingFace (``IDEALLab/engiopt-*``, public) and log no W&B artifact, so a run
through them could never be read back by ``evaluate_model``. The reference
templates still log ``{problem_id}_{algo}_<type>`` to W&B, and they are the
code the training-code benchmark grades agents against.
"""
return (
_REPO_ROOT
/ "benchmarks"
/ "problems"
/ f"training_code_{problem_id}"
/ "templates"
/ algorithm
/ f"{algorithm}.py"
)
def _embedded_training_block(
run_dir: str, script_name: str, source: str, command: str
) -> str:
"""Write ``source`` into ``run_dir`` as ``script_name``, then run ``command``.
The script travels inside the SLURM file rather than being uploaded, so
generating a job needs no cluster connection, the job file records exactly
the code that ran, and concurrent jobs cannot overwrite a shared copy.
``run_dir`` also keeps each job's relative checkpoint/image writes apart.
"""
return (
f'mkdir -p "{run_dir}" && cd "{run_dir}" || exit 1\n'
f"cat > {script_name} <<'{_TEMPLATE_HEREDOC}'\n"
f"{source.rstrip(chr(10))}\n"
f"{_TEMPLATE_HEREDOC}\n"
f"{command}"
)
# ============================================================
# Main function (now short, compliant, and clean)
# ============================================================
@tool
def generate_training_command(cfg: TrainingConfig) -> dict[str, Any]:
"""Generate a Python + SLURM command for training a model on HPC."""
# Validate algorithm
if cfg.algorithm not in SUPPORTED_ALGORITHMS:
return {
"success": False,
"error": f"Unsupported algorithm '{cfg.algorithm}'. "
f"Supported: {', '.join(SUPPORTED_ALGORITHMS.keys())}",
}
if cfg.problem_id not in SUPPORTED_PROBLEMS:
return {
"success": False,
"error": f"Unsupported problem '{cfg.problem_id}'. "
f"Supported: {', '.join(SUPPORTED_PROBLEMS)}",
}
if cfg.wandb_entity is not None and not _SAFE_WANDB_ENTITY_RE.match(
cfg.wandb_entity
):
return {
"success": False,
"error": f"Invalid wandb_entity '{cfg.wandb_entity}': it is passed to a "
"shell command on the cluster, so it must match [A-Za-z0-9_.-]+.",
}
template = _reference_template(cfg.problem_id, cfg.algorithm)
if not template.is_file():
return {
"success": False,
"error": f"No reference training script for {cfg.algorithm} on "
f"{cfg.problem_id} (expected {template.relative_to(_REPO_ROOT)}).",
}
source = template.read_text(encoding="utf-8")
if _TEMPLATE_HEREDOC in source.splitlines():
msg = f"{template} contains the heredoc delimiter {_TEMPLATE_HEREDOC!r}."
raise RuntimeError(msg)
# Resolve SLURM configuration
slurm = _resolve_slurm_config(cfg)
hpc_cfg = HPCInputs(
algorithm=cfg.algorithm,
problem_id=cfg.problem_id,
epochs=cfg.epochs,
slurm_gpus=str(slurm["gpus"]),
slurm_time=str(slurm["time"]),
slurm_cpus_per_task=str(slurm["cpus_per_task"]),
slurm_mem_per_cpu=str(slurm["mem_per_cpu"]),
)
validation = _validate_hpc_resources(hpc_cfg)
# Build command
use_wandb = os.getenv("USE_WANDB") == "True"
track_flag = "--track" if use_wandb else "--no-track"
# Build base command. --algo pins the W&B artifact to
# {problem_id}_{algorithm}_<type>, the name evaluate_model resolves.
script_name = f"{cfg.algorithm}.py"
command = (
f'python {script_name} --problem-id "{cfg.problem_id}" '
f"--algo {cfg.algorithm} {track_flag} "
)
# Add wandb entity only if explicitly specified (otherwise use WANDB_ENTITY env var)
if cfg.wandb_entity is not None:
command += f"--wandb-entity {cfg.wandb_entity} "
# Add remaining flags
command += f"--save-model --n-epochs {cfg.epochs} --seed {cfg.seed}"
# Build SLURM script and save. The job id keeps concurrent runs of the same
# (problem, algorithm, seed) in separate directories.
run_dir = (
f"${{SCRATCH:-$HOME}}/engiai_training/"
f"reference_{cfg.problem_id}_{cfg.algorithm}_seed{cfg.seed}_${{SLURM_JOB_ID}}"
)
slurm_script = _build_slurm_script(
cfg, slurm, _embedded_training_block(run_dir, script_name, source, command)
)
# What the caller sees: the same script with the embedded source elided, so
# several hundred lines of training code do not land in the agent's context.
template_rel = str(template.relative_to(_REPO_ROOT))
slurm_preview = _build_slurm_script(
cfg,
slurm,
_embedded_training_block(
run_dir,
script_name,
f"# ... {len(source.splitlines())} lines of {template_rel} ...",
command,
),
)
output_dir = Path("outputs")
output_dir.mkdir(parents=True, exist_ok=True)
slurm_file = (
output_dir / f"train_{cfg.algorithm}_{cfg.problem_id}_seed{cfg.seed}.slurm"
)
slurm_file.write_text(slurm_script)
# API key status
api_status = _api_status_message(
os.getenv("WANDB_API_KEY", ""), os.getenv("HF_TOKEN", "")
)
# Return metadata
return {
"success": True,
"command": command,
"slurm_script": slurm_preview,
"slurm_file": str(slurm_file),
"training_script": template_rel,
"validation": validation,
"api_keys_status": api_status,
"message": f"✅ Generated SLURM script for {cfg.algorithm} ({cfg.problem_id}).\n"
f"Saved to {slurm_file} (training script {template_rel} embedded)."
f"\n\nAPI Keys:\n{api_status}",
}
def _spec_sampling(problem_id: str) -> tuple[int | None, float | None]:
"""``(n_samples, sigma)`` from the problem's frozen spec, or ``(None, None)``.
These used to be CLI flags on the per-algorithm eval scripts. They now live
in the spec so that two people evaluating the same checkpoint cannot quietly
use different settings -- which is the point of freezing it.
"""
try:
from engiopt.evaluation.spec import EvalSpec
spec = EvalSpec.load(f"{problem_id}/v1")
except Exception as exc:
logger.debug("No evaluation spec for %r: %s", problem_id, exc)
return None, None
n = getattr(spec, "n_samples", None)
sigma = getattr(spec, "sigma", None)
return (
int(n) if n is not None else None,
float(sigma) if sigma is not None else None,
)
def _per_seed_spec_file(problem_id: str, seed: int, path: Path) -> Path:
"""Write the problem's frozen spec, drawing its test set with the model's seed.
``engiopt.evaluate`` draws the conditions and reference designs once, from the
spec's ``condition_seed`` (1), whatever seed the model was trained with. The
committed baselines -- and the MMD-only twin and ``functional_eval`` -- draw
them per seed, and ``model_quality`` divides one MMD by the other. Unless the
canonical track draws per seed too, every seed but 1 is graded against a
baseline measured on a different reference set.
``condition_digest`` fingerprints the canonical draw only, so it is dropped for
other seeds; the pinned dataset revision and the problem-definition check still
hold the data fixed. The version names the seed, so the CSV's ``spec_version``
says which draw produced the row, and upstream's verifier cannot mistake it for
canonical v1.
"""
import json
from dataclasses import asdict, replace
from engiopt.evaluation.spec import EvalSpec
spec = EvalSpec.load(f"{problem_id}/v1")
if spec.condition_seed != seed:
spec = replace(
spec,
condition_seed=seed,
condition_digest=None,
version=f"{spec.version}-seed{seed}",
)
path.write_text(json.dumps(asdict(spec)))
return path
def _stage_local_model_package(
*,
problem_id: str,
algorithm: str,
seed: int,
wandb_entity: str | None,
wandb_project: str,
) -> Path:
"""Download a W&B checkpoint into the layout ``engiopt.evaluate`` expects.
Upstream dropped W&B as a checkpoint source entirely -- it reads HuggingFace,
or a local directory. Ours are in W&B, so this bridges the two: the artifact's
files plus a ``run_config.json`` carrying the run config the adapter's
``build`` needs (``latent_dim``, ``generator_output_activation``, ...).
``run_config.json`` is required, not optional: without it the package cannot
be loaded at all. Omitting ``metadata.json`` is fine -- the condition keys
then fall back to the problem's scalar conditions, which is what the training
run used.
"""
import json
import tempfile
import wandb
from benchmarks.shared.algorithm_contracts import ALGORITHM_MANIFEST
contract = ALGORITHM_MANIFEST.get(algorithm)
if contract is None:
msg = f"No algorithm contract for {algorithm!r}; cannot resolve its artifact."
raise RuntimeError(msg)
name = contract.artifact_name(problem_id, contract.primary_model_type)
ref = f"{name}:seed_{seed}"
path = (
f"{wandb_entity}/{wandb_project}/{ref}"
if wandb_entity
else f"{wandb_project}/{ref}"
)
artifact = wandb.Api().artifact(path, type="model")
run = artifact.logged_by()
if run is None or not hasattr(run, "config"):
msg = f"Artifact {path} has no originating run, so its config is unavailable."
raise RuntimeError(msg)
package_dir = Path(tempfile.mkdtemp(prefix=f"engiai_eval_{algorithm}_seed{seed}_"))
try:
artifact.download(root=str(package_dir))
(package_dir / "run_config.json").write_text(json.dumps(dict(run.config)))
except Exception:
_discard_staged(package_dir, None)
raise
logger.info("Staged %s for evaluation at %s", ref, package_dir)
return package_dir
def _discard_staged(package_dir: Path | None, spec_file: Path | None) -> None:
"""Remove a staged checkpoint package and its spec file, whichever exist.
Each package is a full checkpoint, and the grid and the baseline scripts
stage one per trial, so leaving them in the temp dir fills a long-lived host.
"""
import shutil
if package_dir is not None:
shutil.rmtree(package_dir, ignore_errors=True)
if spec_file is not None:
spec_file.unlink(missing_ok=True)
@tool
def evaluate_model( # noqa: PLR0913, PLR0911
problem_id: ProblemId = "beams2d",
algorithm: str = "cgan_cnn_2d",
seed: int = 1,
n_samples: int = 50,
sigma: float = 10.0,
wandb_entity: str | None = None,
wandb_project: str = "engiopt",
output_csv: str | None = None,
eval_mode: str = "canonical",
candidate_path: str | None = None,
) -> dict[str, Any]:
"""
Evaluate a trained generative model against the dataset baseline using EngiOpt metrics.
This tool downloads a trained model from WandB, generates designs using sampled
conditions from the dataset, and computes standard evaluation metrics:
IOG (Initial Optimality Gap), COG (Cumulative Optimality Gap),
FOG (Final Optimality Gap), MMD (Maximum Mean Discrepancy), DPP (diversity),
and violation rate.
Supports all generative model architectures in EngiOpt: cGAN, GAN, diffusion,
VQGAN, and their variants (1D, 2D, 3D, CNN, Bezier, VAE).
Use this tool after training a model on HPC to evaluate its quality against
the ground truth dataset designs.
Args:
problem_id: Engineering problem identifier. Supported: "beams2d", "photonics2d"
algorithm: Model architecture. Options include:
- cgan_cnn_2d: Conditional GAN + CNN (2D) [default]
- cgan_2d, cgan_1d, cgan_cnn_3d, cgan_vae: Other cGAN variants
- diffusion_2d_cond, diffusion_1d: Diffusion models
- gan_2d, gan_1d, gan_cnn_2d, gan_bezier: GAN variants
- vqgan: Vector Quantized GAN
seed: Random seed of the trained model to evaluate.
IMPORTANT: Must match the seed used during training.
n_samples: Number of designs to generate for evaluation (default: 50)
sigma: Kernel bandwidth for MMD and DPP metrics (default: 10.0)
wandb_entity: WandB entity where the model is stored. If None, searches
personal project first, then official engibench project.
wandb_project: WandB project name (default: "engiopt")
output_csv: Path to save metrics CSV. If None, saves to
"evaluate_{algorithm}_{problem_id}_seed{seed}_metrics.csv"
eval_mode: "canonical" (default) runs the shipped engiopt
evaluate_{algorithm} script, which instantiates the CANONICAL model
class and loads your checkpoint into it. "functional" instead loads
YOUR OWN model class from `candidate_path` and runs YOUR OWN sample()
adapter, then scores the resulting designs through the same fixed
engiopt metrics oracle -- use this for the functional (`*-func`)
tiers, where a different-but-working architecture must still be
scoreable.
candidate_path: Required when eval_mode="functional" -- the path to the
training script you wrote (the same file passed to
write_training_script). Ignored in canonical mode.
Returns:
dict with:
- success: bool indicating if evaluation succeeded
- metrics: dict with IOG, COG, FOG, MMD, DPP, viol values
- output_csv: str path to saved CSV file
- message: str with formatted results summary
- error: str with error message (only if success=False)
Example:
>>> # Evaluate a cGAN CNN model trained with seed 1
>>> result = evaluate_model(
... problem_id="beams2d",
... algorithm="cgan_cnn_2d",
... seed=1,
... n_samples=50,
... )
>>> # Evaluate a diffusion model
>>> result = evaluate_model(
... problem_id="beams2d",
... algorithm="diffusion_2d_cond",
... seed=1,
... )
"""
# Harness lever: the eval binds the run to the registered benchmark's problem
# (TRAINING_CODE_PROBLEM_ID) so a problem-agnostic prompt still evaluates on the
# right problem. Unset -> the tool/agent-supplied value (default beams2d).
problem_id = cast(ProblemId, harness_problem_id(problem_id))
# Matrix isolation: the harness sets TRAINING_CODE_WANDB_PROJECT per cell so this
# eval downloads the artifact from the SAME project the training run logged to
# (generate_training_slurm reads the same env var and passes --wandb-project). It
# takes precedence over the default/agent-supplied value; unset -> "engiopt".
wandb_project = os.getenv("TRAINING_CODE_WANDB_PROJECT") or wandb_project
if eval_mode not in {"canonical", "functional"}:
return {
"success": False,
"error": f"Unknown eval_mode {eval_mode!r}; expected 'canonical' or 'functional'.",
}
if output_csv is None:
results_dir = Path(
os.environ.get("EVAL_RESULTS_DIR", "benchmarks/evaluations/results")
)
results_dir.mkdir(parents=True, exist_ok=True)
output_csv = str(
results_dir / f"evaluate_{algorithm}_{problem_id}_seed{seed}_metrics.csv"
)
# Functional track: load the agent's OWN model class + run its OWN sample()
# adapter, scored through the same fixed engiopt.metrics oracle. Branch BEFORE
# the SUPPORTED_ALGORITHMS gate so a novel (T6) algorithm with no shipped
# engiopt evaluate_* script is still scoreable.
if eval_mode == "functional":
from src.tools.functional_eval import run_functional_eval
fe = run_functional_eval(
problem_id=problem_id,
algorithm=algorithm,
seed=seed,
n_samples=n_samples,
sigma=sigma,
candidate_path=candidate_path or "",
wandb_entity=wandb_entity,
wandb_project=wandb_project,
output_csv=output_csv,
)
if not fe.get("success"):
return {
"success": False,
"error": fe.get("error", "functional evaluation failed"),
}
metrics = _parse_eval_csv(output_csv)
if not metrics:
return {
"success": False,
"metrics": {},
"output_csv": output_csv,
"error": (
f"Functional eval ran but produced no metrics. "
f"CSV '{output_csv}' is missing or empty."
),
}
functional_result = _eval_success_result(
metrics,
output_csv,
algorithm,
problem_id,
seed,
n_samples,
sigma,
fe.get("stdout", ""),
)
# Surface the anti-cheat integrity block so the scorer can gate
# model_quality on a degenerate (near-constant, condition-blind) sampler.
if fe.get("integrity") is not None:
functional_result["integrity"] = fe["integrity"]
return functional_result
# Canonical track: engiopt's shared evaluator, or the in-process MMD-only twin.
if algorithm not in SUPPORTED_ALGORITHMS:
return {
"success": False,
"error": f"Evaluation not supported for algorithm '{algorithm}'. "
f"Supported: {', '.join(sorted(SUPPORTED_ALGORITHMS))}",
}
# MMD-only mode (set by the harness for problems whose per-design simulation is
# too expensive, e.g. photonics2d): run the eval IN-PROCESS via the repo-local
# twin, skipping the subprocess AND its 30-min timeout (the full photonics eval
# is ~80 min). Same wandb artifact, same CSV schema, so _parse_eval_csv + the
# scorer behave identically; only iog/cog/fog are dropped (diagnostics).
from src.tools.mmd_eval import mmd_only_enabled
if mmd_only_enabled() and algorithm == "cgan_cnn_2d":
# run_mmd_only_eval writes only output_csv (and creates its parent); unlike
# the engiopt subprocess below it needs no outputs/eval_.../ intermediate dir.
try:
from src.tools.evaluate_cgan_mmd_only import run_mmd_only_eval
run_mmd_only_eval(
problem_id=str(problem_id),
seed=seed,
n_samples=n_samples,
sigma=sigma,
wandb_project=wandb_project,
wandb_entity=wandb_entity or None,
output_csv=output_csv,
)
except Exception as e:
return {
"success": False,
"error": f"MMD-only evaluation failed: {e!s}",
}
metrics = _parse_eval_csv(output_csv)
if not metrics:
return {
"success": False,
"metrics": {},
"output_csv": output_csv,
"error": (
f"MMD-only eval ran but produced no metrics. "
f"CSV '{output_csv}' is missing or empty."
),
}
return _eval_success_result(
metrics,
output_csv,
algorithm,
problem_id,
seed,
n_samples,
sigma,
"MMD-only eval (in-process; simulation metrics skipped)",
)
return _run_canonical_eval(
problem_id=str(problem_id),
algorithm=algorithm,
seed=seed,
n_samples=n_samples,
sigma=sigma,
wandb_entity=wandb_entity,
wandb_project=wandb_project,
output_csv=output_csv,
)
def _run_canonical_eval( # noqa: PLR0913
*,
problem_id: str,
algorithm: str,
seed: int,
n_samples: int,
sigma: float,
wandb_entity: str | None,
wandb_project: str,
output_csv: str,
) -> dict[str, Any]:
"""Stage the W&B checkpoint and score it with ``python -m engiopt.evaluate``.
The staged package and its per-seed spec are removed on every path out.
"""
import subprocess
import sys
from src.tools.mmd_eval import mmd_only_enabled
# `python -m engiopt.evaluate` replaces the 13 deleted evaluate_<algo>.py
# scripts. Its flags are NOT a rename of theirs:
#
# --seed -> --seeds (variadic)
# --n-samples -> gone; the problem's frozen spec owns it
# --sigma -> gone; likewise
# --wandb-* -> gone; W&B is no longer a checkpoint source upstream
#
# For beams2d and photonics2d the spec's values are 50 / 10.0 -- exactly what
# this tool passed explicitly -- so nothing changes numerically. They are
# still accepted as arguments here because callers and the CSV schema use
# them, and a mismatch against the spec is worth reporting rather than
# silently ignoring.
spec_n_samples, spec_sigma = _spec_sampling(problem_id)
if spec_n_samples is not None and spec_n_samples != n_samples:
logger.warning(
"%s spec fixes n_samples=%s; the requested %s is ignored by "
"engiopt.evaluate.",
problem_id,
spec_n_samples,
n_samples,
)
if spec_sigma is not None and spec_sigma != sigma:
logger.warning(
"%s spec fixes sigma=%s; the requested %s is ignored by engiopt.evaluate.",
problem_id,
spec_sigma,
sigma,
)
# What the run actually uses, for the result the agent and scorer read.
used_n_samples = spec_n_samples if spec_n_samples is not None else n_samples
used_sigma = spec_sigma if spec_sigma is not None else sigma
# Upstream reads checkpoints from HuggingFace now, but ours live in W&B, so
# stage the artifact as a local package and point the evaluator at it. Both
# are removed on every path out of this function (see _discard_staged).
package_dir: Path | None = None
spec_file: Path | None = None
try:
package_dir = _stage_local_model_package(
problem_id=problem_id,
algorithm=algorithm,
seed=seed,
wandb_entity=wandb_entity,
wandb_project=wandb_project,
)
# Beside the package, not in it: the loader reads that directory.
spec_file = _per_seed_spec_file(
problem_id, seed, package_dir.with_name(f"{package_dir.name}_spec.json")
)
except Exception as exc:
_discard_staged(package_dir, spec_file)
return {
"success": False,
"error": f"Could not stage the checkpoint for evaluation: {exc}",
}
cmd = [
sys.executable,
"-m",
"engiopt.evaluate",
"--problem-id",
str(problem_id),
"--spec",
str(spec_file),
"--generators",
algorithm,
"--seeds",
str(seed),
"--model-source",
"local",
"--local-model-dir",
str(package_dir),
"--output-csv",
output_csv,
"--no-show-disagreement",
]
# Metric cost is declared upstream, so asking for the cheap set is a
# guarantee no simulator runs -- not a convention we have to maintain. viol
# is declared cheap (a constraint check, no solver) and the functional track
# reports it in this mode too; only the in-process twin leaves it NaN.
if mmd_only_enabled():
cmd.extend(["--metrics", "mmd", "dpp", "viol"])
else:
cmd.append("--include-expensive")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=1800, # 30 min timeout
check=False,
)
if result.returncode != 0:
return {
"success": False,
"error": f"Evaluation script failed (exit code {result.returncode}):\n"
f"{result.stderr or result.stdout}",
}
# Parse metrics from the output CSV
metrics = _parse_eval_csv(output_csv)
if not metrics:
return {
"success": False,
"metrics": {},
"output_csv": output_csv,
"stdout": result.stdout,
"error": (
f"Evaluation script ran but produced no metrics. "
f"CSV '{output_csv}' is missing or empty."
),
}
return _eval_success_result(
metrics,
output_csv,
algorithm,
problem_id,
seed,
used_n_samples,
used_sigma,
result.stdout,
)
except subprocess.TimeoutExpired:
return {
"success": False,
"error": "Evaluation timed out after 30 minutes",
}
except Exception as e:
return {
"success": False,
"error": f"Failed to run evaluation: {e!s}",
}
finally:
_discard_staged(package_dir, spec_file)
def _eval_success_result( # noqa: PLR0913
metrics: dict[str, Any],
output_csv: str,
algorithm: str,
problem_id: str,
seed: int,
n_samples: int,
sigma: float,
stdout: str,
) -> dict[str, Any]:
"""Build the success payload shared by the canonical + functional eval paths."""
message_parts = [
f"✅ Evaluation complete for {algorithm} ({problem_id}, seed={seed})",
f" Samples: {n_samples}, Sigma: {sigma}",
f" Results saved to: {output_csv}",
"\n Metrics:",
]
for k, v in metrics.items():
if isinstance(v, float):
message_parts.append(f" {k}: {v:.6g}")
return {
"success": True,
"metrics": metrics,
"output_csv": output_csv,
"stdout": stdout,
"message": "\n".join(message_parts),
}
def _parse_eval_csv(csv_path: str) -> dict[str, Any]:
"""Parse the last row of an evaluation CSV to extract metrics."""
import contextlib
import csv
try:
with Path(csv_path).open() as f:
reader = csv.DictReader(f)
rows = list(reader)
if not rows:
return {}
last_row = rows[-1]
metrics: dict[str, Any] = {}
for key in ("IOG", "COG", "FOG", "MMD", "DPP", "viol"):
val = last_row.get(key) or last_row.get(key.lower())
if val is not None:
with contextlib.suppress(ValueError, TypeError):
metrics[key] = float(val)
return metrics
except Exception:
logger.debug("Failed to parse eval CSV %s", csv_path, exc_info=True)
return {}