File indexing completed on 2026-04-28 07:12:52
0001 """Problem configuration models and loader.
0002
0003 Defines generic problem models and a YAML-based loader that parses files like
0004 `examples/basic/problem.config`. The schema focuses on objectives and the
0005 design space reference while keeping environment and workflow management
0006 outside of the problem scope.
0007
0008 Notes:
0009 - This module intentionally avoids importing ePIC-specific utilities to
0010 keep `ProblemConfiguration` generic. ePIC specializations live under
0011 `epic_utils`.
0012 - The loader is designed to be modular and future-proof: new keys under
0013 the top-level `problem` block can be added without breaking existing
0014 behavior.
0015 """
0016
0017 from typing import Optional, List, Dict, Any
0018 from pydantic import BaseModel, Field, model_validator
0019 from pathlib import Path
0020 import yaml
0021
0022 from .design_config import DesignConfig, DesignConfigLoader
0023
0024
0025 class Objective(BaseModel):
0026 """Single optimization objective.
0027
0028 Args:
0029 name: Objective identifier (e.g., "f1").
0030 minimize: Whether to minimize the objective; if False, maximization.
0031 """
0032
0033 name: str
0034 minimize: bool = True
0035
0036
0037 class ProblemConfiguration(BaseModel):
0038 """Generic problem configuration.
0039
0040 Focuses on core problem attributes, a design configuration, objectives, and
0041 optional observations. Environment and scheduler/trial management belong to
0042 separate workflow components (e.g., `WorkflowManager`).
0043
0044 Notes:
0045 - `design_config` accepts any subclass of `DesignConfig`.
0046 - `objectives` must be non-empty with unique names.
0047 """
0048 name: str
0049 output_location: str
0050 work_location: str
0051 problem_type: str
0052
0053
0054 design_config: DesignConfig
0055 objectives: List[Objective]
0056 observations: Optional[List[Dict[str, Any]]] = Field(default=None)
0057
0058 @model_validator(mode="after")
0059 def validate_paths(self) -> "ProblemConfiguration":
0060 """Validate directory paths and objective correctness.
0061
0062 - Ensures `output_location` and `work_location` exist.
0063 - Ensures `objectives` is non-empty with unique names.
0064 """
0065 errors = []
0066
0067 for label, path in [("output_location", self.output_location),
0068 ("work_location", self.work_location)]:
0069 if path and not Path(path).exists():
0070 errors.append(f"{label} does not exist: {path}")
0071
0072
0073 if not self.objectives:
0074 errors.append("objectives must be provided and non-empty")
0075 else:
0076 names = [obj.name for obj in self.objectives]
0077 if len(set(names)) != len(names):
0078 errors.append("objective names must be unique")
0079
0080 if errors:
0081 raise ValueError("ProblemConfiguration validation failed:\n" + "\n".join(errors))
0082
0083 return self
0084
0085
0086 class ProblemConfigLoader:
0087 """Loader for problem YAML/CONFIG files.
0088
0089 Parses files following the schema used by `examples/basic/problem.config`:
0090
0091 problem:
0092 name: "..."
0093 type: "..."
0094 output_location: "..."
0095 work_location: "..."
0096 design_parameters_file: "./path/to/design.params"
0097 objectives:
0098 - name: "f1"
0099 minimize: true
0100 - name: "f2"
0101 minimize: true
0102
0103 Args:
0104 file_path: Path to the problem configuration file.
0105
0106 Returns:
0107 ProblemConfiguration: Fully instantiated configuration with a loaded
0108 `design_config` from the referenced design parameters file.
0109
0110 Raises:
0111 FileNotFoundError: If the problem file or design parameters file does
0112 not exist.
0113 ValueError: If required keys are missing or invalid.
0114 """
0115
0116 @staticmethod
0117 def _build_from_problem_dict(problem: Dict[str, Any], base_dir: Optional[Path]) -> ProblemConfiguration:
0118 """Build ProblemConfiguration from an inner 'problem' mapping.
0119
0120 Supports design source via either a file path ('design_parameters_file')
0121 or inline design payload ('inline_design'). Exactly one must be provided.
0122 """
0123
0124 required_scalar = [
0125 "name",
0126 "type",
0127 "output_location",
0128 "work_location",
0129 "objectives",
0130 ]
0131 missing = [k for k in required_scalar if k not in problem]
0132 if missing:
0133 raise ValueError("Invalid problem definition, missing keys: " + ", ".join(missing))
0134
0135
0136 objectives_raw = problem.get("objectives", [])
0137 if not isinstance(objectives_raw, list) or not objectives_raw:
0138 raise ValueError("'objectives' must be a non-empty list")
0139 objectives = [Objective(**obj) for obj in objectives_raw]
0140
0141
0142 has_path = "design_parameters_file" in problem
0143 has_inline = "inline_design" in problem
0144 if has_path == has_inline:
0145
0146 raise ValueError("Specify exactly one of 'design_parameters_file' or 'inline_design'")
0147
0148 if has_path:
0149
0150 design_params_path = Path(problem["design_parameters_file"]).expanduser()
0151 if base_dir and not design_params_path.is_absolute():
0152 design_params_path = (base_dir / design_params_path).resolve()
0153 if not design_params_path.exists():
0154 raise FileNotFoundError(f"Design parameters file not found: {design_params_path}")
0155
0156 design_config = DesignConfigLoader.load(str(design_params_path))
0157 else:
0158
0159 inline = problem["inline_design"]
0160 if not isinstance(inline, dict):
0161 raise ValueError("'inline_design' must be a mapping with design parameters")
0162 resolved = DesignConfigLoader._resolve_design_space(inline, config_dir=str(base_dir or Path('.')))
0163 payload: Dict[str, Any] = {
0164 "design_parameters": resolved["design_parameters"],
0165 }
0166 if "parameter_constraints" in resolved:
0167 payload["parameter_constraints"] = resolved["parameter_constraints"]
0168 design_config = DesignConfig(**payload)
0169
0170
0171 return ProblemConfiguration(
0172 name=problem["name"],
0173 problem_type=problem["type"],
0174 output_location=problem["output_location"],
0175 work_location=problem["work_location"],
0176 design_config=design_config,
0177 objectives=objectives,
0178 observations=problem.get("observations"),
0179 )
0180
0181 @staticmethod
0182 def load(file_path: str) -> ProblemConfiguration:
0183 path = Path(file_path)
0184 if not path.exists():
0185 raise FileNotFoundError(f"Problem file not found: {file_path}")
0186
0187 with open(path, "r") as f:
0188 data = yaml.safe_load(f) or {}
0189
0190 if "problem" not in data or not isinstance(data["problem"], dict):
0191 raise ValueError("Invalid problem file: missing 'problem' section")
0192
0193 return ProblemConfigLoader._build_from_problem_dict(data["problem"], base_dir=path.parent)
0194
0195 @staticmethod
0196 def from_dict(problem_payload: Dict[str, Any], base_dir: Optional[str] = None) -> ProblemConfiguration:
0197 """Construct ProblemConfiguration from a dict payload.
0198
0199 Accepts the inner 'problem' mapping as a Python dict and supports both
0200 file-based and inline design definitions. Set base_dir for reliable
0201 relative path resolution when using 'design_parameters_file'.
0202 """
0203 return ProblemConfigLoader._build_from_problem_dict(problem_payload, base_dir=Path(base_dir) if base_dir else None)