Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 08:24:56

0001 """Ax-based Bayesian optimizer for AID2E framework."""
0002 
0003 from copy import deepcopy
0004 from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
0005 import logging, sys
0006 import numpy as np
0007 
0008 logger = logging.getLogger(__name__)
0009 
0010 # Import Ax components (lazy to avoid import errors if Ax not installed)
0011 try:
0012     import ax
0013     from ax.core.experiment import Experiment
0014     from ax.core.search_space import SearchSpace as AxSearchSpace
0015     from ax.core.parameter import ChoiceParameter as AxChoiceParameter
0016     from ax.core.parameter import ParameterType, RangeParameter as AxRangeParameter
0017     from ax.core.parameter_constraint import ParameterConstraint as AxParameterConstraint
0018     try:
0019         from ax.core.parameter_constraint import SumConstraint as AxSumConstraint
0020     except ImportError:
0021         AxSumConstraint = None
0022     from ax.core.objective import MultiObjective, Objective
0023     from ax.core.optimization_config import MultiObjectiveOptimizationConfig, OptimizationConfig
0024     from ax.core.metric import Metric
0025     from ax.core.outcome_constraint import ComparisonOp, ObjectiveThreshold
0026     from ax.generation_strategy.generation_strategy import GenerationStrategy
0027     try:
0028         from ax.generation_strategy.center_generation_node import CenterGenerationNode
0029         from ax.generation_strategy.transition_criterion import MinTrials
0030         from ax.generation_strategy.generation_node import GenerationNode
0031         from ax.generation_strategy.generator_spec import GeneratorSpec
0032         # Treat successful imports as the compatibility gate. In the Ax build
0033         # shipped in env_AID2E, the node-based APIs exist and work even though
0034         # `GenerationStrategy` does not expose `nodes` as a class attribute.
0035         AX_NODE_STRATEGY_AVAILABLE = True
0036     except ImportError:
0037         CenterGenerationNode = None
0038         MinTrials = None
0039         GenerationNode = None
0040         GeneratorSpec = None
0041         AX_NODE_STRATEGY_AVAILABLE = False
0042     from ax.adapter.registry import Generators
0043     AX_AVAILABLE = True
0044 except ImportError as e:
0045     AX_AVAILABLE = False
0046     # Create type stubs when Ax is not available
0047     if TYPE_CHECKING:
0048         from ax.core.experiment import Experiment
0049         from ax.core.search_space import SearchSpace as AxSearchSpace
0050         from ax.core.optimization_config import OptimizationConfig
0051         from ax.generation_strategy.generation_strategy import GenerationStrategy
0052         from ax.core.parameter_constraint import (
0053             ParameterConstraint as AxParameterConstraint,
0054             SumConstraint as AxSumConstraint,
0055         )
0056     else:
0057         AxSearchSpace = None
0058         OptimizationConfig = None
0059         GenerationStrategy = None
0060         AxParameterConstraint = None
0061         AxSumConstraint = None
0062         CenterGenerationNode = None
0063         MinTrials = None
0064         GenerationNode = None
0065         GeneratorSpec = None
0066         AX_NODE_STRATEGY_AVAILABLE = False
0067     logger.warning(f"Ax not available: {e}. Install with: pip install ax-platform==1.0.0")
0068 
0069 from aid2e.optimizers.base import (
0070     BaseOptimizer,
0071     SearchSpace,
0072     Trial,
0073     TRIAL_STATUS_SUGGESTED,
0074 )
0075 from aid2e.utilities.configurations.base_models import (
0076     ChoiceParameter as DesignChoiceParameter,
0077     RangeParameter as DesignRangeParameter,
0078 )
0079 from aid2e.utilities.configurations.design_config import DesignConfig
0080 from ._resolver import resolve_generator_kwargs
0081 from .config import AxOptimizerConfig
0082 
0083 
0084 class AxOptimizer(BaseOptimizer):
0085     """Ax-based Bayesian optimization for multi-objective optimization.
0086     
0087     This optimizer uses the Ax platform for Bayesian optimization with
0088     support for multiple objectives and native Ax Modular BoTorch generation.
0089     
0090     Attributes:
0091         config: AxOptimizerConfig instance with strategy settings.
0092         objective_names: List of objective metric names.
0093         experiment: Ax Experiment object managing trials.
0094         generation_strategy: Ax GenerationStrategy for candidate generation.
0095     
0096     Examples:
0097         >>> from aid2e.optimizers.ax import AxOptimizer, AxOptimizerConfig
0098         >>> search_space = SearchSpace(
0099         ...     parameters={
0100         ...         "x": {"type": "range", "bounds": [0.0, 1.0]},
0101         ...         "y": {"type": "range", "bounds": [0.0, 1.0]}
0102         ...     }
0103         ... )
0104         >>> config = AxOptimizerConfig(
0105         ...     initialization_strategy="sobol",
0106         ...     generator="BOTORCH_MODULAR"
0107         ... )
0108         >>> optimizer = AxOptimizer(
0109         ...     search_space=search_space,
0110         ...     config=config,
0111         ...     objective_names=["loss", "time"]
0112         ... )
0113     
0114     Notes:
0115         This implementation defaults to a native Ax node-based generation
0116         strategy that transitions from an initializer node into
0117         ``Generators.BOTORCH_MODULAR``.
0118         
0119         Project: AID2E v0.0.1 - AI assisted Detector Design for EIC
0120         Homepage: https://aid2e.github.io/AID2E-framework
0121         Repository: https://github.com/aid2e/AID2E-framework.git
0122     """
0123     
0124     def __init__(
0125         self,
0126         search_space: Union[SearchSpace, DesignConfig],
0127         config: AxOptimizerConfig,
0128         objective_names: List[str],
0129         seed: Optional[int] = None,
0130         objective_directions: Optional[Dict[str, Any]] = None,
0131     ):
0132         """Initialize the Ax optimizer.
0133         
0134         Args:
0135             search_space: Parameter search space definition.
0136             config: AxOptimizerConfig instance with strategy settings.
0137             objective_names: List of objective metric names to optimize.
0138             seed: Random seed for reproducibility (overrides config.seed if provided).
0139         
0140         Raises:
0141             ImportError: If Ax is not installed.
0142             ValueError: If search_space is empty or config is invalid.
0143         
0144         Notes:
0145             The optimizer is initialized but not yet ready to suggest candidates.
0146             Ax Experiment and GenerationStrategy are created lazily on first use.
0147         """
0148         if not AX_AVAILABLE:
0149             raise ImportError(
0150                 "Ax is required but not installed. "
0151                 "Install with: pip install ax-platform==1.0.0"
0152             )
0153         if not AX_NODE_STRATEGY_AVAILABLE:
0154             raise RuntimeError(
0155                 "The installed Ax runtime does not support the node-based "
0156                 "generation API required by AID2E. Upgrade Ax to a version "
0157                 "that provides CenterGenerationNode, GenerationNode, "
0158                 "GeneratorSpec, and MinTrials."
0159             )
0160         
0161         # Initialize base class (handles DesignConfig → SearchSpace conversion)
0162         super().__init__(
0163             search_space=search_space,
0164             objective_names=objective_names,
0165             objective_directions=objective_directions,
0166             seed=seed if seed is not None else config.seed,
0167         )
0168 
0169         self.config = config
0170         # self.objective_names and self.n_objectives are inherited from BaseOptimizer
0171         
0172         # TODO Version check removed for now, re-enable when ready
0173         
0174         # Create Ax search space
0175         self.ax_search_space = self._create_ax_search_space()
0176         
0177         # Create optimization config
0178         self.optimization_config = self._create_optimization_config()
0179         
0180         # Create Ax experiment
0181         self.experiment = Experiment(
0182             name=f"aid2e_optimization",
0183             search_space=self.ax_search_space,
0184             optimization_config=self.optimization_config
0185         )
0186         
0187         # Create generation strategy
0188         self.generation_strategy = self._create_generation_strategy()
0189         
0190         # Track trials
0191         # self._trials and self._trial_counter are owned by BaseOptimizer
0192         
0193         logger.info(
0194             f"AxOptimizer initialized: {len(self.search_space.parameters)} params, "
0195             f"{len(objective_names)} objectives, strategy={config.initialization_strategy}, "
0196             f"generator={config.generator}"
0197         )
0198     
0199     def _parse_constraint_to_ax(
0200         self, constraint
0201     ) -> Optional[Any]:
0202         """Parse a ParameterConstraint rule to an Ax constraint object.
0203         
0204         Args:
0205             constraint: The ParameterConstraint from design_config.
0206             
0207         Returns:
0208             Ax constraint object (ParameterConstraint or SumConstraint), or None if parsing fails.
0209             
0210         Notes:
0211             Ax ParameterConstraint supports linear constraints of the form:
0212                 sum(w_i * param_i) <= bound
0213             
0214             This method attempts to parse simple sum constraints like "x + y <= 1.5"
0215             into Ax's format. More complex expressions may not be supported.
0216         """
0217         import re
0218         
0219         rule = constraint.rule
0220         
0221         # Try to parse sum constraints: "param1 + param2 + ... <= bound" or "param1 + param2 + ... < bound"
0222         # Also handle >= and > by negating
0223         
0224         # Pattern: captures parameters, operator, and bound
0225         # Example: "DTLZ2.x1 + DTLZ2.x2 <= 1.5"
0226         pattern = r'^([^<>=]+)\s*([<>]=?)\s*([\d.]+)$'
0227         match = re.match(pattern, rule.strip())
0228         
0229         if not match:
0230             logger.warning(
0231                 f"Constraint '{constraint.name}' has unsupported format: {rule}. "
0232                 "Only simple sum constraints are supported (e.g., 'x + y <= 1.5')."
0233             )
0234             return None
0235         
0236         lhs, operator, bound_str = match.groups()
0237         bound = float(bound_str)
0238         
0239         # Parse left-hand side to extract parameters and coefficients
0240         # For now, only handle simple addition with coefficient 1
0241         # Pattern: param_name optionally preceded by + or -
0242         param_pattern = r'([+-]?)\s*([a-zA-Z_][a-zA-Z0-9_.]*)'
0243         terms = re.findall(param_pattern, lhs)
0244         
0245         if not terms:
0246             logger.warning(
0247                 f"Constraint '{constraint.name}': Could not parse parameters from: {lhs}"
0248             )
0249             return None
0250         
0251         # Build constraint_dict: {param_name: coefficient}
0252         constraint_dict = {}
0253         for sign, param_name in terms:
0254             coeff = 1.0 if sign != '-' else -1.0
0255             constraint_dict[param_name.strip()] = coeff
0256         
0257         strict_epsilon = sys.float_info.epsilon # this is the smallest representable positive number such that 1.0 + eps != 1.0, used to convert strict inequalities to non-strict
0258 
0259         # Determine if upper or lower bound based on operator
0260         # sum <= bound or sum < bound: upper bound
0261         # sum >= bound or sum > bound: flip to -sum <= -bound (upper bound with negated coeffs)
0262         if operator in ['<=', '<']:
0263             is_upper_bound = True
0264             if operator == '<':
0265                 bound -= strict_epsilon
0266         elif operator in ['>=', '>']:
0267             # Convert sum >= bound to -sum <= -bound
0268             is_upper_bound = True
0269             constraint_dict = {k: -v for k, v in constraint_dict.items()}
0270             bound = -bound
0271             if operator == '>':
0272                 bound -= strict_epsilon
0273         else:
0274             logger.warning(f"Unsupported operator in constraint: {operator}")
0275             return None
0276         
0277         # Check if all coefficients are the same (typically 1.0 for sum constraints)
0278         coeffs = list(constraint_dict.values())
0279         if all(c == coeffs[0] for c in coeffs) and coeffs[0] == 1.0:
0280             # Use SumConstraint for simple sum constraints
0281             # Note: SumConstraint requires Parameter objects, not just names
0282             # We'll use ParameterConstraint instead which takes names
0283             pass
0284         
0285         try:
0286             terms_rendered = []
0287             for param_name, coeff in constraint_dict.items():
0288                 if coeff == 1.0:
0289                     terms_rendered.append(param_name)
0290                 elif coeff == -1.0:
0291                     terms_rendered.append(f"-{param_name}")
0292                 else:
0293                     terms_rendered.append(f"{coeff}*{param_name}")
0294 
0295             inequality = " + ".join(terms_rendered).replace("+ -", "- ")
0296             inequality = f"{inequality} <= {bound}"
0297             ax_constraint = AxParameterConstraint(inequality=inequality)
0298             logger.debug(
0299                 f"Converted constraint '{constraint.name}' to Ax format: "
0300                 f"{inequality}"
0301             )
0302             return ax_constraint
0303         except Exception as e:
0304             logger.warning(
0305                 f"Failed to create Ax constraint for '{constraint.name}': {e}"
0306             )
0307             return None
0308     
0309     def _create_ax_search_space(self) -> AxSearchSpace:
0310         """Create an Ax SearchSpace from the typed SearchSpace parameters.
0311 
0312         Returns:
0313             Ax SearchSpace describing the optimization domain with constraints.
0314 
0315         Raises:
0316             ValueError: If a parameter type is not supported by the Ax backend.
0317 
0318         Notes:
0319             Constraints from search_space.constraints are automatically converted
0320             to Ax ParameterConstraint objects and included in the search space.
0321         """
0322 
0323         ax_params = []
0324         for param_name, param in self.search_space.parameters.items():
0325             if isinstance(param, DesignRangeParameter):
0326                 lower, upper = param.bounds
0327                 ax_params.append(
0328                     AxRangeParameter(
0329                         name=param_name,
0330                         parameter_type=ParameterType.FLOAT,
0331                         lower=float(lower),
0332                         upper=float(upper),
0333                     )
0334                 )
0335             elif isinstance(param, DesignChoiceParameter):
0336                 ax_params.append(
0337                     AxChoiceParameter(
0338                         name=param_name,
0339                         parameter_type=ParameterType.STRING,
0340                         values=list(param.choices),
0341                         is_ordered=False,
0342                     )
0343                 )
0344             else:
0345                 raise ValueError(
0346                     f"Unsupported parameter type for Ax: {param.__class__.__name__}"
0347                 )
0348 
0349         # Convert design constraints to Ax parameter constraints
0350         ax_constraints = []
0351         for constraint in self.search_space.constraints:
0352             ax_constraint = self._parse_constraint_to_ax(constraint)
0353             if ax_constraint is not None:
0354                 ax_constraints.append(ax_constraint)
0355                 logger.debug(f"Added constraint '{constraint.name}': {constraint.rule}")
0356 
0357         return AxSearchSpace(
0358             parameters=ax_params,
0359             parameter_constraints=ax_constraints if ax_constraints else None
0360         )
0361     
0362     def _create_optimization_config(self):
0363         """Create Ax optimization configuration for multi-objective optimization.
0364         
0365         Returns:
0366             Ax OptimizationConfig or None for single objective.
0367         """
0368         if len(self.objective_names) == 1:
0369             # Single objective case
0370             name = self.objective_names[0]
0371             direction = getattr(
0372                 self.objective_directions.get(name),
0373                 "value",
0374                 self.objective_directions.get(name, "minimize"),
0375             )
0376             minimize = str(direction).lower() != "maximize"
0377             return OptimizationConfig(
0378                 objective=Objective(
0379                     metric=Metric(name=name, lower_is_better=minimize),
0380                     minimize=minimize,
0381                 )
0382             )
0383         else:
0384             # Multi-objective case
0385             objectives = []
0386             for name in self.objective_names:
0387                 direction = getattr(
0388                     self.objective_directions.get(name),
0389                     "value",
0390                     self.objective_directions.get(name, "minimize"),
0391                 )
0392                 minimize = str(direction).lower() != "maximize"
0393                 objectives.append(
0394                     Objective(
0395                         metric=Metric(name=name, lower_is_better=minimize),
0396                         minimize=minimize,
0397                     )
0398                 )
0399             objective_thresholds = []
0400             if self.config.objective_thresholds:
0401                 for name, bound in self.config.objective_thresholds.items():
0402                     direction = getattr(
0403                         self.objective_directions.get(name),
0404                         "value",
0405                         self.objective_directions.get(name, "minimize"),
0406                     )
0407                     minimize = str(direction).lower() != "maximize"
0408                     objective_thresholds.append(
0409                         ObjectiveThreshold(
0410                             metric=Metric(name=name, lower_is_better=minimize),
0411                             bound=float(bound),
0412                             relative=False,
0413                             op=ComparisonOp.LEQ if minimize else ComparisonOp.GEQ,
0414                         )
0415                     )
0416             return MultiObjectiveOptimizationConfig(
0417                 objective=MultiObjective(objectives=objectives),
0418                 objective_thresholds=objective_thresholds,
0419             )
0420     
0421     def _create_generation_strategy(self):
0422         """Create Ax GenerationStrategy based on config.
0423         
0424         Returns:
0425             Ax GenerationStrategy configured with chosen initialization +
0426             model-based optimization backend.
0427         
0428         Notes:
0429             Strategy uses the required node-based Ax API and transitions from
0430             initialization into the configured model-based generator.
0431         """
0432         return self._create_node_generation_strategy()
0433 
0434     def _create_node_generation_strategy(self):
0435         """Create a node-based generation strategy using the latest Ax APIs.
0436 
0437         Notes:
0438             This mirrors the modern Modular BoTorch tutorial pattern of chaining
0439             CenterOfSearchSpace -> initializer node -> model-based node using
0440             transition criteria such as MinTrials.
0441         """
0442         model_node_name = self._get_model_node_name()
0443         model_node = GenerationNode(
0444             name=model_node_name,
0445             generator_specs=[
0446                 GeneratorSpec(
0447                     generator_enum=self._get_model_based_generator_enum(),
0448                     generator_kwargs=self._get_model_generator_kwargs(),
0449                     generator_gen_kwargs=self._get_model_generator_gen_kwargs(),
0450                 )
0451             ],
0452         )
0453 
0454         nodes = []
0455         init_strategy = self.config.initialization_strategy.lower()
0456         init_trials = int(self.config.n_initial_samples)
0457 
0458         if init_strategy == "center":
0459             remaining_init_trials = max(0, init_trials - 1)
0460             next_node_name = model_node.name
0461             if remaining_init_trials > 0:
0462                 init_node = self._build_initialization_node(
0463                     node_name="Sobol",
0464                     generator_enum=Generators.SOBOL,
0465                     num_trials=remaining_init_trials,
0466                     transition_to=model_node.name,
0467                 )
0468                 next_node_name = init_node.name
0469                 nodes.append(init_node)
0470 
0471             nodes.insert(0, CenterGenerationNode(next_node_name=next_node_name))
0472             nodes.append(model_node)
0473             return GenerationStrategy(
0474                 name=f"Center+{next_node_name}+{model_node.name}",
0475                 nodes=nodes,
0476             )
0477 
0478         init_node = self._build_initialization_node(
0479             node_name="Random" if init_strategy == "random" else "Sobol",
0480             generator_enum=self._get_initialization_model_enum(),
0481             num_trials=init_trials,
0482             transition_to=model_node.name,
0483         )
0484 
0485         return GenerationStrategy(
0486             name=f"{init_node.name}+{model_node.name}",
0487             nodes=[init_node, model_node],
0488         )
0489 
0490     def _build_initialization_node(
0491         self,
0492         *,
0493         node_name: str,
0494         generator_enum: Any,
0495         num_trials: int,
0496         transition_to: str,
0497     ):
0498         """Build one initialization node for the node-based Ax API."""
0499         return GenerationNode(
0500             name=node_name,
0501             generator_specs=[
0502                 GeneratorSpec(
0503                     generator_enum=generator_enum,
0504                     generator_kwargs=self._get_initialization_generator_kwargs(),
0505                 )
0506             ],
0507             transition_criteria=[
0508                 MinTrials(
0509                     threshold=num_trials,
0510                     transition_to=transition_to,
0511                     use_all_trials_in_exp=True,
0512                 )
0513             ],
0514         )
0515 
0516     def _get_initialization_model_enum(self):
0517         """Return Ax initializer generator enum for the configured strategy."""
0518         init_strategy = self.config.initialization_strategy.lower()
0519         if init_strategy == "uniform":
0520             uniform = getattr(Generators, "UNIFORM", None)
0521             if uniform is not None:
0522                 return uniform
0523             logger.warning(
0524                 "Generators.UNIFORM is unavailable in this Ax version; "
0525                 "falling back to Sobol for initialization."
0526             )
0527         return Generators.SOBOL
0528 
0529     def _get_initialization_generator_kwargs(self) -> Dict[str, Any]:
0530         """Return generator kwargs for initialization nodes."""
0531         if self.seed is None:
0532             return {}
0533         return {"seed": int(self.seed)}
0534 
0535     def _get_model_based_generator_enum(self):
0536         """Return the configured Ax model-based generator enum."""
0537         generator_name = self.config.generator
0538         generator_enum = getattr(Generators, generator_name, None)
0539         if generator_enum is None:
0540             raise ValueError(
0541                 f"Configured Ax generator '{generator_name}' is unavailable in "
0542                 "the installed Ax version."
0543             )
0544         return generator_enum
0545 
0546     def _get_model_node_name(self) -> str:
0547         """Return the display name for the active model-based generation node."""
0548         if self.config.generator == "BOTORCH_MODULAR":
0549             return "ModularBoTorch"
0550         return self.config.generator
0551 
0552     def _get_model_generator_kwargs(self) -> Dict[str, Any]:
0553         """Return resolved generator kwargs for the configured backend."""
0554         return resolve_generator_kwargs(
0555             generator_name=self.config.generator,
0556             generator_kwargs=self.config.generator_kwargs,
0557         )
0558 
0559     def _get_model_generator_gen_kwargs(self) -> Dict[str, Any]:
0560         """Return generation-time kwargs for model-based candidate generation."""
0561         return deepcopy(self.config.generator_gen_kwargs)
0562 
0563     def _split_generator_run(self, generator_run: Any) -> List[Any]:
0564         """Split a possibly batched Ax generator run into per-arm runs.
0565 
0566         Args:
0567             generator_run: A generator run returned by Ax.
0568 
0569         Returns:
0570             List of single-arm generator runs.
0571         """
0572         if not hasattr(generator_run, "arms"):
0573             raise TypeError(
0574                 "Expected an Ax generator run with an 'arms' attribute, got "
0575                 f"{type(generator_run).__name__}"
0576             )
0577 
0578         arms = list(generator_run.arms)
0579         if len(arms) <= 1:
0580             return [generator_run]
0581 
0582         from ax.core.generator_run import GeneratorRun
0583 
0584         weights = list(getattr(generator_run, "weights", []) or [])
0585         single_runs: List[Any] = []
0586         for index, arm in enumerate(arms):
0587             weight = weights[index] if index < len(weights) else 1.0
0588             single_run = GeneratorRun(
0589                 arms=[arm],
0590                 weights=[weight],
0591                 fit_time=getattr(generator_run, "fit_time", None),
0592                 gen_time=getattr(generator_run, "gen_time", None),
0593                 generation_node_name=getattr(generator_run, "_generation_node_name", None),
0594             )
0595             for attr_name in ("_model_key", "_generation_node_name"):
0596                 if hasattr(generator_run, attr_name):
0597                     setattr(single_run, attr_name, getattr(generator_run, attr_name))
0598             single_runs.append(single_run)
0599 
0600         return single_runs
0601 
0602     def _normalize_generator_runs(self, gen_result: Any) -> List[Any]:
0603         """Normalize Ax generation output into per-arm generator runs.
0604 
0605         Args:
0606             gen_result: Value returned by ``generation_strategy.gen(...)``.
0607 
0608         Returns:
0609             List of single-arm generator runs.
0610 
0611         Raises:
0612             TypeError: If the return shape cannot be interpreted as generator
0613                 run output.
0614 
0615         Notes:
0616             Newer Ax internals may return wrapper/list-like structures around
0617             generator runs. AID2E uses one Ax generation call per requested
0618             batch and then splits the result into individual trial records.
0619         """
0620         if hasattr(gen_result, "arms"):
0621             return self._split_generator_run(gen_result)
0622 
0623         if isinstance(gen_result, (list, tuple)):
0624             generator_runs: List[Any] = []
0625             for item in gen_result:
0626                 generator_runs.extend(self._normalize_generator_runs(item))
0627             return generator_runs
0628 
0629         if hasattr(gen_result, "generator_run_structs"):
0630             structs = getattr(gen_result, "generator_run_structs")
0631             generator_runs: List[Any] = []
0632             for struct in structs:
0633                 generator_runs.extend(self._normalize_generator_runs(struct))
0634             return generator_runs
0635 
0636         if hasattr(gen_result, "generator_run"):
0637             return self._normalize_generator_runs(getattr(gen_result, "generator_run"))
0638 
0639         raise TypeError(
0640             "Unsupported Ax generation result type: "
0641             f"{type(gen_result).__name__}"
0642         )
0643 
0644     def _get_generation_strategy_metadata(self) -> Dict[str, Any]:
0645         """Return strategy progress metadata compatible across Ax APIs.
0646 
0647         Returns:
0648             Dictionary containing ``ax_step_index`` when available and
0649             ``ax_node_name`` for node-based strategies.
0650         """
0651         metadata: Dict[str, Any] = {"ax_step_index": -1}
0652 
0653         step_index = getattr(self.generation_strategy, "current_step_index", None)
0654         if step_index is not None:
0655             try:
0656                 metadata["ax_step_index"] = int(step_index)
0657             except (TypeError, ValueError):
0658                 logger.debug("Unable to coerce Ax step index '%s' to int", step_index)
0659 
0660         node_name = getattr(self.generation_strategy, "current_node_name", None)
0661         if node_name is not None:
0662             metadata["ax_node_name"] = str(node_name)
0663 
0664         return metadata
0665     
0666     def suggest_candidates(self, n_candidates: int = 1) -> List[Dict[str, Any]]:
0667         """Suggest the next batch of parameter configurations to evaluate.
0668 
0669         Args:
0670             n_candidates: Number of candidates to generate.
0671 
0672         Returns:
0673             List of parameter dictionaries ready for evaluation.
0674 
0675         Notes:
0676             Constraints are handled natively by Ax when present in search_space.
0677             Ax enforces constraints during candidate generation automatically.
0678             
0679             IMPLEMENTATION NOTE: This method generates the requested batch in a
0680             single Ax call whenever possible, then splits the resulting batch
0681             into individual AID2E trial records so the rest of the framework can
0682             keep using single-trial ``update_with_results`` semantics.
0683             
0684             The GenerationStrategy still properly tracks progress: after
0685             n_initial_samples individual trials complete (regardless of how
0686             they were generated), it automatically switches from Sobol to BO.
0687         """
0688 
0689         candidates = []
0690         model_keys: List[str] = []
0691 
0692         generator_run_result = self.generation_strategy.gen(
0693             experiment=self.experiment,
0694             n=n_candidates,
0695         )
0696         strategy_metadata = self._get_generation_strategy_metadata()
0697         generator_runs = self._normalize_generator_runs(generator_run_result)
0698         if len(generator_runs) < n_candidates:
0699             raise RuntimeError(
0700                 "Ax returned fewer generator runs than requested: "
0701                 f"requested={n_candidates}, got={len(generator_runs)}"
0702             )
0703 
0704         for generator_run in generator_runs[:n_candidates]:
0705             trial = self.experiment.new_trial(generator_run=generator_run)
0706             trial.mark_running(no_runner_required=True)
0707             arm = generator_run.arms[0]
0708             candidate_params = dict(arm.parameters)
0709             model_key = getattr(generator_run, "_model_key", "unknown")
0710             self.set_trial_status(
0711                 trial_index=trial.index,
0712                 status=TRIAL_STATUS_SUGGESTED,
0713                 parameters=candidate_params,
0714                 metrics=None,
0715                 metadata={
0716                     "ax_model_key": model_key,
0717                     **strategy_metadata,
0718                 },
0719             )
0720             candidates.append(candidate_params)
0721             model_keys.append(model_key)
0722 
0723         self._trial_counter = max(self._trial_counter, len(self.experiment.trials))
0724         strategy_metadata = self._get_generation_strategy_metadata()
0725         strategy_ref = strategy_metadata.get(
0726             "ax_node_name",
0727             strategy_metadata.get("ax_step_index", -1),
0728         )
0729 
0730         logger.debug(
0731             "Generated %d candidates using %s (trials %d-%d, strategy=%s)",
0732             n_candidates,
0733             ",".join(model_keys) if model_keys else "unknown",
0734             len(self.experiment.trials) - n_candidates,
0735             len(self.experiment.trials) - 1,
0736             strategy_ref,
0737         )
0738         return candidates
0739     
0740     def update_with_results(
0741         self,
0742         trial_index: int,
0743         parameters: Dict[str, Any],
0744         metrics: Dict[str, float]
0745     ) -> None:
0746         """Update optimizer with evaluation results from a trial.
0747         
0748         Args:
0749             trial_index: Unique identifier for the trial.
0750             parameters: Parameter values that were evaluated.
0751             metrics: Objective values obtained from evaluation.
0752         
0753         Examples:
0754             >>> optimizer.update_with_results(
0755             ...     trial_index=0,
0756             ...     parameters={'x': 0.5, 'y': 0.3},
0757             ...     metrics={'loss': 0.1, 'accuracy': 0.9}
0758             ... )
0759         
0760         Notes:
0761             Completes the trial in Ax experiment and attaches data.
0762             This allows the surrogate model to learn from the evaluation.
0763         """
0764         # Validate metrics
0765         for obj_name in self.objective_names:
0766             if obj_name not in metrics:
0767                 raise ValueError(
0768                     f"Missing objective '{obj_name}' in metrics. "
0769                     f"Expected: {self.objective_names}, got: {list(metrics.keys())}"
0770                 )
0771         
0772         # Get the trial from experiment
0773         if trial_index < len(self.experiment.trials):
0774             trial = self.experiment.trials[trial_index]
0775             
0776             # Complete the trial with data
0777             trial.mark_completed()
0778             
0779             # Attach data to experiment
0780             from ax.core.data import Data
0781             import pandas as pd
0782             
0783             data_rows = []
0784             for metric_name, metric_value in metrics.items():
0785                 if metric_name in self.objective_names:
0786                     data_rows.append({
0787                         'trial_index': trial_index,
0788                         'metric_name': metric_name,
0789                         'metric_signature': metric_name,
0790                         'arm_name': trial.arm.name if trial.arm else f"arm_{trial_index}",
0791                         'mean': float(metric_value),
0792                         'sem': 0.0  # Standard error of mean (0 for deterministic)
0793                     })
0794             
0795             if data_rows:
0796                 df = pd.DataFrame(data_rows)
0797                 data = Data(df=df)
0798                 self.experiment.attach_data(data)
0799         
0800         # Update internal trial tracking through the base API
0801         self.set_trial_status(
0802             trial_index=trial_index,
0803             status="completed",
0804             parameters=parameters,
0805             metrics={k: float(v) for k, v in metrics.items()},
0806         )
0807         
0808         logger.debug(
0809             f"Updated trial {trial_index} with {len(metrics)} metrics"
0810         )
0811     
0812     def serialize_state(self) -> Dict[str, Any]:
0813         """Serialize optimizer state for distributed execution.
0814         
0815         Returns:
0816             Dictionary containing all necessary state to reconstruct the optimizer.
0817         
0818         Examples:
0819             >>> state = optimizer.serialize_state()
0820             >>> json.dumps(state)  # Should be JSON-serializable
0821         """
0822         space_payload = {
0823             name: param.model_dump()
0824             for name, param in self.search_space.parameters.items()
0825         }
0826         constraints_payload = [
0827             constraint.model_dump()
0828             for constraint in self.search_space.constraints
0829         ]
0830 
0831         return {
0832             "search_space": {
0833                 "parameters": space_payload,
0834                 "constraints": constraints_payload,
0835                 "name": self.search_space.name,
0836             },
0837             "n_objectives": self.n_objectives,
0838             "seed": self.seed,
0839             "objective_names": self.objective_names,
0840             "config": {
0841                 "initialization_strategy": self.config.initialization_strategy,
0842                 "generator": self.config.generator,
0843                 "generator_kwargs": deepcopy(self.config.generator_kwargs),
0844                 "generator_gen_kwargs": deepcopy(self.config.generator_gen_kwargs),
0845                 "objective_thresholds": (
0846                     deepcopy(self.config.objective_thresholds)
0847                     if self.config.objective_thresholds is not None
0848                     else None
0849                 ),
0850                 "n_initial_samples": self.config.n_initial_samples,
0851                 "n_iterations": self.config.n_iterations,
0852                 "batch_size": self.config.batch_size,
0853                 "seed": self.config.seed,
0854             },
0855             "trials": [
0856                 {
0857                     "index": t.index,
0858                     "parameters": t.parameters,
0859                     "metrics": t.metrics,
0860                     "status": t.status,
0861                     "metadata": t.metadata
0862                 }
0863                 for t in self._trials if t is not None
0864             ],
0865             "trial_counter": self._trial_counter
0866         }
0867     
0868     def load_state(self, state: Dict[str, Any]) -> None:
0869         """Load optimizer state from serialized form.
0870         
0871         Args:
0872             state: Dictionary containing serialized optimizer state.
0873         
0874         Raises:
0875             ValueError: If state is invalid or incompatible.
0876         
0877         Notes:
0878             This recreates the Ax experiment and generation strategy,
0879             then replays all trials to restore the optimizer state.
0880         """
0881         # Validate state
0882         required_keys = ["search_space", "objective_names", "config", "trials"]
0883         for key in required_keys:
0884             if key not in state:
0885                 raise ValueError(f"Missing required key in state: {key}")
0886 
0887         # Restore config and objective metadata
0888         self.config = AxOptimizerConfig(**state["config"])
0889         self.objective_names = list(state["objective_names"])
0890 
0891         # Rebuild search space and Ax components from serialized payload
0892         saved_space = state["search_space"] or {}
0893         parameters_payload = saved_space.get("parameters", saved_space)
0894         constraints_payload = saved_space.get("constraints", [])
0895 
0896         self.search_space = SearchSpace(
0897             parameters=parameters_payload,
0898             constraints=constraints_payload,
0899             name=saved_space.get("name"),
0900         )
0901 
0902         self.ax_search_space = self._create_ax_search_space()
0903         self.optimization_config = self._create_optimization_config()
0904         self.experiment = Experiment(
0905             name="aid2e_optimization",
0906             search_space=self.ax_search_space,
0907             optimization_config=self.optimization_config,
0908         )
0909         self.generation_strategy = self._create_generation_strategy()
0910         
0911         # Restore trials
0912         self._trials = []
0913         for trial_data in state["trials"]:
0914             trial = Trial(
0915                 index=trial_data["index"],
0916                 parameters=trial_data["parameters"],
0917                 metrics=trial_data.get("metrics"),
0918                 status=trial_data.get("status", "pending"),
0919                 metadata=trial_data.get("metadata", {})
0920             )
0921             
0922             while len(self._trials) <= trial.index:
0923                 self._trials.append(None)
0924             self._trials[trial.index] = trial
0925             
0926             # Replay trial in Ax experiment if completed
0927             if trial.status == "completed" and trial.metrics:
0928                 # Create trial in Ax
0929                 ax_trial = self.experiment.new_trial()
0930                 ax_trial.mark_running(no_runner_required=True)
0931                 
0932                 # Update with results
0933                 self.update_with_results(
0934                     trial_index=trial.index,
0935                     parameters=trial.parameters,
0936                     metrics=trial.metrics
0937                 )
0938         
0939         self._trial_counter = state.get("trial_counter", len(self._trials))
0940         self._trial_counter = max(self._trial_counter, len(self.experiment.trials))
0941         
0942         logger.info(f"Loaded optimizer state with {len(self._trials)} trials")
0943     
0944     def __repr__(self) -> str:
0945         """Return string representation of the optimizer.
0946         
0947         Returns:
0948             String describing the optimizer configuration.
0949         """
0950         return (
0951             f"AxOptimizer("
0952             f"n_params={len(self.search_space.parameters)}, "
0953             f"n_objectives={self.n_objectives}, "
0954             f"strategy={self.config.initialization_strategy}, "
0955             f"generator={self.config.generator}, "
0956             f"seed={self.seed}"
0957             f")"
0958         )