Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """PyMOO-based evolutionary optimizer for AID2E framework.
0002 
0003 AID2E philosophy — separation of concerns
0004 ------------------------------------------
0005 An AID2E optimizer is a *candidate generator* and a *result ledger*. It has
0006 no knowledge of how evaluations are performed — that is the job of schedulers,
0007 workflow engines, and simulation back-ends.  The optimizer only:
0008 
0009 1. Produces design-point candidates (``suggest_candidates``).
0010 2. Records objective values once evaluations are complete (``update_with_results``).
0011 
0012 This module wires the PyMOO evolutionary library into that interface via the
0013 **ask/tell protocol**:
0014 
0015 - ``suggest_candidates()`` → ``algorithm.ask()`` — returns the current
0016   generation as parameter dicts.
0017 - ``update_with_results()`` → buffers one result; flushes
0018   ``algorithm.tell()`` automatically when the full generation has results.
0019 
0020 The ``_flush_generation`` mechanism is entirely internal. Callers never need
0021 to think in terms of "generations" — they simply call the same two methods
0022 repeatedly regardless of the backend.
0023 
0024 PyMOOProblem
0025 ------------
0026 The public ``PyMOOProblem`` class represents the search space as a proper
0027 PyMOO ``Problem``.  It is structural only (variables, bounds, objectives)
0028 and intentionally does not evaluate candidates.  Evaluations are always
0029 handled externally by workflow/scheduler components and reported back via
0030 ``update_with_results``.
0031 
0032 Backend switching
0033 -----------------
0034 Use ``seed_from_trials(prior_optimizer.get_trials())`` to inject completed
0035 results from a different backend (e.g. a random-initialisation phase) before
0036 starting the evolutionary search.
0037 
0038 Project: AID2E v0.0.0 — AI assisted Detector Design for EIC
0039 Homepage: https://aid2e.github.io/AID2E-framework
0040 Repository: https://github.com/aid2e/AID2E-framework.git
0041 """
0042 
0043 from __future__ import annotations
0044 
0045 import logging
0046 import warnings
0047 from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
0048 
0049 import numpy as np
0050 
0051 logger = logging.getLogger(__name__)
0052 
0053 # ---------------------------------------------------------------------------
0054 # Optional PyMOO import guard
0055 # ---------------------------------------------------------------------------
0056 try:
0057     from pymoo.core.problem import Problem
0058     from pymoo.core.termination import NoTermination
0059     from pymoo.operators.crossover.sbx import SBX
0060     from pymoo.operators.mutation.pm import PM
0061     from pymoo.operators.sampling.rnd import FloatRandomSampling
0062 
0063     PYMOO_AVAILABLE = True
0064 except ImportError as _pymoo_err:  # pragma: no cover
0065     PYMOO_AVAILABLE = False
0066     if TYPE_CHECKING:
0067         from pymoo.core.problem import Problem
0068     else:
0069         Problem = object  # type: ignore[assignment,misc]
0070     logger.warning(
0071         "PyMOO not available: %s. Install with: pip install pymoo",
0072         _pymoo_err,
0073     )
0074 
0075 from aid2e.optimizers.base import BaseOptimizer, SearchSpace, Trial
0076 from aid2e.utilities.configurations.base_models import (
0077     ChoiceParameter as DesignChoiceParameter,
0078     RangeParameter as DesignRangeParameter,
0079 )
0080 from aid2e.utilities.configurations.design_config import DesignConfig
0081 from .config import PyMOOOptimizerConfig
0082 
0083 
0084 # ---------------------------------------------------------------------------
0085 # Public PyMOO problem class
0086 # ---------------------------------------------------------------------------
0087 
0088 class PyMOOProblem(Problem):
0089     """PyMOO Problem that wraps an AID2E ``SearchSpace``.
0090 
0091         This problem is structural-only for ask/tell workflows. ``decode_x``
0092         translates PyMOO float vectors into
0093     human-readable AID2E parameter dicts — the same representation returned
0094     by ``suggest_candidates``.
0095 
0096     Args:
0097         n_var: Number of continuous decision variables.
0098         n_obj: Number of objectives.
0099         xl: Lower-bound array of shape ``(n_var,)``.
0100         xu: Upper-bound array of shape ``(n_var,)``.
0101         param_items: Ordered list of ``(name, BaseParameter)`` pairs used for
0102             encoding/decoding.
0103         objective_names: Ordered objective names.
0104     """
0105 
0106     def __init__(
0107         self,
0108         n_var: int,
0109         n_obj: int,
0110         xl: np.ndarray,
0111         xu: np.ndarray,
0112         param_items: List[Tuple[str, Any]],
0113         objective_names: List[str],
0114     ) -> None:
0115         """Initialise the PyMOO problem with search-space metadata."""
0116         super().__init__(n_var=n_var, n_obj=n_obj, xl=xl, xu=xu)
0117         self._param_items = param_items
0118         self._objective_names = objective_names
0119 
0120     def decode_x(self, x_row: np.ndarray) -> Dict[str, Any]:
0121         """Translate a PyMOO float vector into an AID2E parameter dictionary.
0122 
0123         Args:
0124             x_row: 1-D float array of length ``n_var``.
0125 
0126         Returns:
0127             Mapping of parameter names to decoded values (``float`` for
0128             ``RangeParameter``, choice value for ``ChoiceParameter``).
0129         """
0130         params: Dict[str, Any] = {}
0131         for i, (name, param) in enumerate(self._param_items):
0132             val = float(x_row[i])
0133             if isinstance(param, DesignRangeParameter):
0134                 params[name] = val
0135             elif isinstance(param, DesignChoiceParameter):
0136                 idx = int(round(val))
0137                 idx = max(0, min(idx, len(param.choices) - 1))
0138                 params[name] = param.choices[idx]
0139         return params
0140 
0141     def _evaluate(
0142         self, x: np.ndarray, out: dict, *args: Any, **kwargs: Any
0143     ) -> None:
0144         """Raise because AID2E always uses external evaluation.
0145 
0146         Args:
0147             x: Population matrix (unused).
0148             out: Output dictionary (unused).
0149 
0150         Raises:
0151             NotImplementedError: Always, because evaluation belongs to the
0152                 workflow/scheduler layer in AID2E.
0153         """
0154         raise NotImplementedError(
0155             "PyMOOProblem is structural-only in ask/tell mode. "
0156             "Use PyMOOOptimizer.suggest_candidates() and "
0157             "PyMOOOptimizer.update_with_results() with external evaluation."
0158         )
0159 
0160 
0161 class AID2EProblem(PyMOOProblem):
0162     """Deprecated compatibility alias for ``PyMOOProblem``."""
0163 
0164     def __init__(self, *args: Any, **kwargs: Any) -> None:
0165         """Warn and delegate to ``PyMOOProblem``."""
0166         warnings.warn(
0167             "AID2EProblem is deprecated and will be removed in the next "
0168             "iteration. Use PyMOOProblem instead.",
0169             DeprecationWarning,
0170             stacklevel=2,
0171         )
0172         # TODO: Remove this compatibility alias in the next iteration.
0173         super().__init__(*args, **kwargs)
0174 
0175 
0176 # ---------------------------------------------------------------------------
0177 # Main optimizer class
0178 # ---------------------------------------------------------------------------
0179 
0180 class PyMOOOptimizer(BaseOptimizer):
0181     """Evolutionary optimizer backed by PyMOO algorithms.
0182 
0183     Implements the ``BaseOptimizer`` interface using PyMOO's ask/tell protocol
0184     so that evaluations can be performed externally (e.g. via schedulers or
0185     simulation jobs) without blocking PyMOO's internal loop.
0186 
0187     The generation lifecycle is::
0188 
0189         candidates = optimizer.suggest_candidates()   # calls algorithm.ask()
0190         for i, c in enumerate(candidates):
0191             metrics = my_evaluate(c)
0192             optimizer.update_with_results(i, c, metrics)
0193         # After the last call above, algorithm.tell() is invoked automatically.
0194         # The next suggest_candidates() call produces the next generation.
0195 
0196     Supported algorithms:
0197         - ``"ga"`` — Genetic Algorithm for single-objective optimisation.
0198         - ``"nsga2"`` — NSGA-II, recommended for 2–3 objectives.
0199         - ``"nsga3"`` — NSGA-III, recommended for 3+ objectives.
0200         - ``"moead"`` — MOEA/D, weight-decomposition approach.
0201 
0202     Attributes:
0203         config: ``PyMOOOptimizerConfig`` instance controlling algorithm behaviour.
0204         n_gen_completed: Number of generations that have been fully evaluated.
0205 
0206     Examples:
0207         >>> from aid2e.optimizers.pymoo import PyMOOOptimizer, PyMOOOptimizerConfig
0208         >>> from aid2e.optimizers.base import SearchSpace
0209         >>> space = SearchSpace(
0210         ...     parameters={
0211         ...         "x": {"type": "range", "bounds": [0.0, 1.0]},
0212         ...         "y": {"type": "range", "bounds": [0.0, 1.0]},
0213         ...     }
0214         ... )
0215         >>> config = PyMOOOptimizerConfig(pop_size=20, seed=0)
0216         >>> opt = PyMOOOptimizer(space, config, objective_names=["loss"])
0217         >>> candidates = opt.suggest_candidates()
0218         >>> for trial_idx, c in enumerate(candidates):
0219         ...     opt.update_with_results(trial_idx, c, {"loss": c["x"] + c["y"]})
0220         >>> best = opt.get_best_trial()
0221 
0222     Notes:
0223         - All objectives are treated as *minimisation* targets.  To maximise,
0224           negate values before passing them to ``update_with_results``.
0225         - Linear parameter constraints are not yet forwarded to PyMOO.  A
0226           warning is emitted when the search space contains constraints.
0227         - ``n_candidates`` passed to ``suggest_candidates`` is informational
0228           only; the actual batch size is determined by the algorithm
0229           (``pop_size`` for the first generation, ``n_offsprings`` thereafter).
0230 
0231         Project: AID2E v0.0.0 — AI assisted Detector Design for EIC
0232         Homepage: https://aid2e.github.io/AID2E-framework
0233         Repository: https://github.com/aid2e/AID2E-framework.git
0234     """
0235 
0236     def __init__(
0237         self,
0238         search_space: Union[SearchSpace, DesignConfig],
0239         config: PyMOOOptimizerConfig,
0240         objective_names: List[str],
0241         seed: Optional[int] = None,
0242     ) -> None:
0243         """Initialise the PyMOO optimizer.
0244 
0245         Args:
0246             search_space: Parameter search space or a ``DesignConfig`` instance.
0247                 ``DesignConfig`` is automatically converted to ``SearchSpace``.
0248             config: ``PyMOOOptimizerConfig`` controlling algorithm selection and
0249                 operator hyper-parameters.
0250             objective_names: Ordered list of objective metric names.  These must
0251                 match keys in the ``metrics`` dict passed to
0252                 ``update_with_results``.
0253             seed: Integer seed overriding ``config.seed`` when provided.
0254 
0255         Raises:
0256             ImportError: If PyMOO is not installed.
0257             ValueError: If the search space is empty or ``objective_names`` is
0258                 empty.
0259 
0260         Notes:
0261             The algorithm is initialised lazily; the actual PyMOO ``Problem``
0262             and ``Algorithm`` objects are created here but no evaluations occur
0263             until ``suggest_candidates`` is called.
0264         """
0265         if not PYMOO_AVAILABLE:
0266             raise ImportError(
0267                 "PyMOO is required but not installed. "
0268                 "Install with: pip install pymoo"
0269             )
0270 
0271         effective_seed = seed if seed is not None else config.seed
0272 
0273         super().__init__(
0274             search_space=search_space,
0275             objective_names=objective_names,
0276             seed=effective_seed,
0277         )
0278 
0279         self.config = config
0280         self.resolved_algorithm = self.config.resolve_algorithm(self.n_objectives)
0281 
0282         if self.search_space.constraints:
0283             logger.warning(
0284                 "PyMOOOptimizer: %d parameter constraint(s) detected in the search "
0285                 "space but are not yet forwarded to PyMOO. Constraint satisfaction "
0286                 "is not guaranteed during candidate generation.",
0287                 len(self.search_space.constraints),
0288             )
0289 
0290         # Build ordered parameter list (deterministic iteration order)
0291         self._param_items: List[Tuple[str, Any]] = list(
0292             self.search_space.parameters.items()
0293         )
0294 
0295         # Build numpy bounds
0296         self._xl, self._xu = self._build_bounds()
0297         n_var = len(self._param_items)
0298 
0299         # Public PyMOO problem (structural-only ask/tell mode).
0300         self.problem: PyMOOProblem = PyMOOProblem(
0301             n_var=n_var,
0302             n_obj=self.n_objectives,
0303             xl=self._xl,
0304             xu=self._xu,
0305             param_items=self._param_items,
0306             objective_names=self.objective_names,
0307         )
0308 
0309         # Create the PyMOO algorithm
0310         self._algorithm = self._create_algorithm()
0311         self._algorithm.setup(
0312             self.problem,
0313             seed=self.seed,
0314             verbose=self.config.verbose,
0315             termination=NoTermination(),
0316         )
0317 
0318         # _trials and _trial_counter are owned by BaseOptimizer.__init__
0319         self.n_gen_completed: int = 0
0320 
0321         # Per-generation state (cleared after each tell())
0322         self._generation_infills: Any = None           # pymoo Population
0323         self._gen_pos_to_trial_idx: Dict[int, int] = {}  # position → trial_index
0324         self._result_buffer: Dict[int, Dict[str, float]] = {}  # trial_index → metrics
0325 
0326         logger.info(
0327             "PyMOOOptimizer initialised: algorithm=%s, pop_size=%d, "
0328             "n_params=%d, n_objectives=%d, seed=%s",
0329             self.resolved_algorithm,
0330             config.pop_size,
0331             n_var,
0332             self.n_objectives,
0333             self.seed,
0334         )
0335 
0336     # ------------------------------------------------------------------
0337     # Internal helpers
0338     # ------------------------------------------------------------------
0339 
0340     def _build_bounds(self) -> Tuple[np.ndarray, np.ndarray]:
0341         """Build lower/upper bound arrays from the search space parameters.
0342 
0343         ``RangeParameter`` contributes its ``bounds``; ``ChoiceParameter``
0344         contributes ``[0, n_choices − 1]`` as a continuous range to be
0345         rounded during decoding.
0346 
0347         Returns:
0348             Tuple of ``(xl, xu)`` each as a ``float64`` array of shape
0349             ``(n_var,)``.
0350         """
0351         xl, xu = [], []
0352         for _, param in self._param_items:
0353             if isinstance(param, DesignRangeParameter):
0354                 xl.append(float(param.bounds[0]))
0355                 xu.append(float(param.bounds[1]))
0356             elif isinstance(param, DesignChoiceParameter):
0357                 xl.append(0.0)
0358                 xu.append(float(len(param.choices) - 1))
0359             else:
0360                 raise ValueError(
0361                     f"Unsupported parameter type for PyMOO: "
0362                     f"{param.__class__.__name__}"
0363                 )
0364         return np.array(xl, dtype=float), np.array(xu, dtype=float)
0365 
0366     def _decode_x(self, x_row: np.ndarray) -> Dict[str, Any]:
0367         """Delegate to ``self.problem.decode_x`` for internal use.
0368 
0369         Args:
0370             x_row: 1-D float array of length ``n_var``.
0371 
0372         Returns:
0373             Parameter dictionary for the given individual.
0374         """
0375         return self.problem.decode_x(x_row)
0376 
0377     def _encode_params(self, params: Dict[str, Any]) -> np.ndarray:
0378         """Encode a parameter dictionary back to a PyMOO-compatible float vector.
0379 
0380         Used by ``load_state`` when re-seeding completed trials into PyMOO's
0381         memory structures.
0382 
0383         Args:
0384             params: Mapping of parameter names to values.
0385 
0386         Returns:
0387             1-D float array of length ``n_var``.
0388         """
0389         x_row = np.zeros(len(self._param_items), dtype=float)
0390         for i, (name, param) in enumerate(self._param_items):
0391             val = params.get(name)
0392             if isinstance(param, DesignRangeParameter):
0393                 x_row[i] = float(val)
0394             elif isinstance(param, DesignChoiceParameter):
0395                 if val in param.choices:
0396                     x_row[i] = float(param.choices.index(val))
0397                 else:
0398                     x_row[i] = 0.0
0399         return x_row
0400 
0401     def _create_algorithm(self) -> Any:
0402         """Instantiate the PyMOO algorithm from the current config.
0403 
0404         Returns:
0405             An uninitialised PyMOO ``Algorithm`` instance.
0406 
0407         Raises:
0408             ValueError: If the resolved algorithm is not a supported identifier.
0409 
0410         Notes:
0411             Algorithm objects are created *before* ``setup()`` is called so
0412             that ``__init__`` can validate the config without triggering any
0413             sampling.
0414         """
0415         alg = self.resolved_algorithm.lower()
0416         n_offsprings = self.config.n_offsprings  # None → pop_size default
0417 
0418         crossover = SBX(
0419             prob=self.config.crossover_prob,
0420             eta=self.config.crossover_eta,
0421         )
0422         mutation = PM(eta=self.config.mutation_eta)
0423         sampling = FloatRandomSampling()
0424 
0425         if alg == "ga":
0426             from pymoo.algorithms.soo.nonconvex.ga import GA  # type: ignore[import]
0427 
0428             return GA(
0429                 pop_size=self.config.pop_size,
0430                 n_offsprings=n_offsprings,
0431                 crossover=crossover,
0432                 mutation=mutation,
0433                 sampling=sampling,
0434             )
0435 
0436         if alg == "nsga2":
0437             from pymoo.algorithms.moo.nsga2 import NSGA2  # type: ignore[import]
0438 
0439             return NSGA2(
0440                 pop_size=self.config.pop_size,
0441                 n_offsprings=n_offsprings,
0442                 crossover=crossover,
0443                 mutation=mutation,
0444                 sampling=sampling,
0445             )
0446 
0447         if alg == "nsga3":
0448             from pymoo.algorithms.moo.nsga3 import NSGA3  # type: ignore[import]
0449             from pymoo.class='include' href="/lxr/source/include/gsl/util/">util.ref_dirs import get_reference_directions  # type: ignore[import]
0450 
0451             ref_dirs = get_reference_directions(
0452                 "das-dennis",
0453                 n_dim=self.n_objectives,
0454                 n_partitions=self.config.n_partitions,
0455             )
0456             return NSGA3(
0457                 ref_dirs=ref_dirs,
0458                 pop_size=self.config.pop_size,
0459                 n_offsprings=n_offsprings,
0460                 crossover=crossover,
0461                 mutation=mutation,
0462                 sampling=sampling,
0463             )
0464 
0465         if alg == "moead":
0466             from pymoo.algorithms.moo.moead import MOEAD  # type: ignore[import]
0467             from pymoo.class='include' href="/lxr/source/include/gsl/util/">util.ref_dirs import get_reference_directions  # type: ignore[import]
0468 
0469             ref_dirs = get_reference_directions(
0470                 "uniform",
0471                 n_dim=self.n_objectives,
0472                 n_points=self.config.pop_size,
0473             )
0474             return MOEAD(
0475                 ref_dirs=ref_dirs,
0476                 n_neighbors=15,
0477                 crossover=crossover,
0478                 mutation=mutation,
0479                 sampling=sampling,
0480             )
0481 
0482         raise ValueError(
0483             f"Unknown PyMOO algorithm '{self.resolved_algorithm}'. "
0484             "Supported: 'ga', 'nsga2', 'nsga3', 'moead'."
0485         )
0486 
0487     def _flush_generation(self) -> None:
0488         """Advance the algorithm by one generation using buffered results.
0489 
0490         Called automatically by ``update_with_results`` when every candidate
0491         produced by the most recent ``suggest_candidates`` call has a result.
0492         Builds the objective matrix ``F`` from the buffer and calls
0493         ``algorithm.tell()``.
0494 
0495         Notes:
0496             This method clears ``_generation_infills``, ``_gen_pos_to_trial_idx``,
0497             and ``_result_buffer`` after flushing.
0498         """
0499         n_gen = len(self._gen_pos_to_trial_idx)
0500         F = np.zeros((n_gen, self.n_objectives), dtype=float)
0501 
0502         for pos, trial_idx in self._gen_pos_to_trial_idx.items():
0503             metrics = self._result_buffer[trial_idx]
0504             for j, obj in enumerate(self.objective_names):
0505                 F[pos, j] = metrics[obj]
0506 
0507         self._generation_infills.set("F", F)
0508         self._algorithm.tell(infills=self._generation_infills)
0509         self.n_gen_completed += 1
0510 
0511         logger.debug(
0512             "Generation %d completed: %d individuals evaluated.",
0513             self.n_gen_completed,
0514             n_gen,
0515         )
0516 
0517         # Reset generation state
0518         self._generation_infills = None
0519         self._gen_pos_to_trial_idx = {}
0520         self._result_buffer = {}
0521 
0522     # ------------------------------------------------------------------
0523     # BaseOptimizer interface overrides + extensions
0524     # ------------------------------------------------------------------
0525 
0526     def seed_from_trials(
0527         self,
0528         trials: List[Trial],
0529         *,
0530         only_completed: bool = True,
0531     ) -> int:
0532         """Inject completed trials from an external source into the history.
0533 
0534         Extends the base implementation with a guard that prevents seeding
0535         while a generation is in-flight (i.e., ``suggest_candidates`` has
0536         been called but not all ``update_with_results`` calls have come back).
0537 
0538         The injected trials are recorded in the optimizer's history and
0539         visible via ``get_trials()`` and ``get_pareto_front()``.  They do
0540         *not* advance PyMOO's internal population — the next
0541         ``suggest_candidates`` call still produces the next generation.
0542 
0543         Args:
0544             trials: Trials to inject, typically from a previous backend
0545                 (e.g. random-initialisation results).
0546             only_completed: When ``True`` (default), non-completed trials are
0547                 silently skipped.
0548 
0549         Returns:
0550             Number of trials actually injected.
0551 
0552         Raises:
0553             RuntimeError: If a generation is currently in-flight.
0554 
0555         Examples:
0556             >>> n = pymoo_opt.seed_from_trials(random_opt.get_trials())
0557             >>> print(f"Seeded {n} prior evaluations")
0558             >>> candidates = pymoo_opt.suggest_candidates()  # gen 1 starts
0559         """
0560         if self._generation_infills is not None:
0561             raise RuntimeError(
0562                 "Cannot seed trials while a generation is in-flight. "
0563                 "Call update_with_results() for all pending candidates first."
0564             )
0565         return super().seed_from_trials(trials, only_completed=only_completed)
0566 
0567     def suggest_candidates(self, n_candidates: int = 1) -> List[Dict[str, Any]]:
0568         """Suggest the next batch of candidates to evaluate.
0569 
0570         Calls ``algorithm.ask()`` to retrieve the current generation population
0571         from PyMOO and returns them as a list of parameter dicts.  The actual
0572         number of candidates is determined by the algorithm (``pop_size`` for
0573         the initial generation, ``n_offsprings`` thereafter), not by
0574         ``n_candidates``.
0575 
0576         Args:
0577             n_candidates: Advisory hint only.  A ``DEBUG``-level message is
0578                 emitted when the hint differs from the actual batch size.
0579 
0580         Returns:
0581             List of parameter dicts, one per individual in the current
0582             generation.  Trial indices for these candidates begin at the
0583             current ``_trial_counter``.
0584 
0585         Raises:
0586             RuntimeError: If a previous generation has not yet been fully
0587                 evaluated (i.e. some ``update_with_results`` calls are
0588                 outstanding).
0589 
0590         Examples:
0591             >>> candidates = optimizer.suggest_candidates()
0592             >>> len(candidates)
0593             100  # pop_size
0594         """
0595         # Guard: cannot ask for a new generation while one is in flight
0596         if self._generation_infills is not None:
0597             n_pending = len(self._gen_pos_to_trial_idx) - len(self._result_buffer)
0598             raise RuntimeError(
0599                 f"Cannot suggest new candidates: {n_pending} evaluation(s) from "
0600                 "the current generation are still outstanding. Call "
0601                 "update_with_results() for all pending candidates first."
0602             )
0603 
0604         infills = self._algorithm.ask()
0605         X = infills.get("X")   # shape (batch_size, n_var)
0606         batch_size = len(X)
0607 
0608         if n_candidates != 1 and n_candidates != batch_size:
0609             logger.debug(
0610                 "n_candidates=%d ignored — PyMOO algorithm produces %d candidates "
0611                 "(pop_size=%d). Use optimizer.suggest_candidates() without a hint "
0612                 "to suppress this message.",
0613                 n_candidates,
0614                 batch_size,
0615                 self.config.pop_size,
0616             )
0617 
0618         self._generation_infills = infills
0619         self._gen_pos_to_trial_idx = {}
0620         self._result_buffer = {}
0621 
0622         candidates: List[Dict[str, Any]] = []
0623         for pos, x_row in enumerate(X):
0624             trial_idx = self._trial_counter
0625             self._trial_counter += 1
0626             self._gen_pos_to_trial_idx[pos] = trial_idx
0627 
0628             params = self._decode_x(x_row)
0629 
0630             # Register as pending
0631             trial = Trial(index=trial_idx, parameters=params, status="pending")
0632             while len(self._trials) <= trial_idx:
0633                 self._trials.append(None)
0634             self._trials[trial_idx] = trial
0635 
0636             candidates.append(params)
0637 
0638         logger.debug(
0639             "Generation %d: suggested %d candidates (trial indices %d–%d).",
0640             self.n_gen_completed + 1,
0641             batch_size,
0642             self._trial_counter - batch_size,
0643             self._trial_counter - 1,
0644         )
0645         return candidates
0646 
0647     def update_with_results(
0648         self,
0649         trial_index: int,
0650         parameters: Dict[str, Any],
0651         metrics: Dict[str, float],
0652     ) -> None:
0653         """Record evaluation results for one candidate and advance the algorithm.
0654 
0655         When the last outstanding candidate of the current generation is
0656         updated, the generation buffer is flushed automatically via
0657         ``algorithm.tell()``.
0658 
0659         Args:
0660             trial_index: Index as returned in ``_trial_counter`` during
0661                 ``suggest_candidates``.  Must correspond to a pending trial.
0662             parameters: Parameter values that were evaluated (used for
0663                 bookkeeping; the underlying search point is already tracked).
0664             metrics: Objective values keyed by objective name.  All names
0665                 listed in ``objective_names`` must be present.
0666 
0667         Raises:
0668             ValueError: If any required objective is missing from ``metrics``.
0669 
0670         Examples:
0671             >>> optimizer.update_with_results(
0672             ...     trial_index=0,
0673             ...     parameters={"x": 0.5, "y": 0.3},
0674             ...     metrics={"f1": 0.1, "f2": 0.9},
0675             ... )
0676         """
0677         missing = [o for o in self.objective_names if o not in metrics]
0678         if missing:
0679             raise ValueError(
0680                 f"update_with_results: missing objectives {missing}. "
0681                 f"Expected {self.objective_names}, got {list(metrics.keys())}."
0682             )
0683 
0684         # Buffer the result
0685         self._result_buffer[trial_index] = {k: float(v) for k, v in metrics.items()}
0686 
0687         # Update trial record
0688         trial = Trial(
0689             index=trial_index,
0690             parameters=parameters,
0691             metrics={k: float(v) for k, v in metrics.items()},
0692             status="completed",
0693         )
0694         while len(self._trials) <= trial_index:
0695             self._trials.append(None)
0696         self._trials[trial_index] = trial
0697 
0698         # Flush once all candidates of the current generation have results
0699         if len(self._result_buffer) == len(self._gen_pos_to_trial_idx):
0700             self._flush_generation()
0701 
0702     def serialize_state(self) -> Dict[str, Any]:
0703         """Serialise optimizer state to a JSON-compatible dictionary.
0704 
0705         The serialised state includes the config, search space description,
0706         objective names, and all recorded trials.  The PyMOO algorithm's
0707         internal population state is serialised via ``pickle`` and encoded as
0708         a base-64 string to allow full resumption without re-running
0709         evaluations.
0710 
0711         Returns:
0712             JSON-serialisable dictionary containing all state needed to
0713             rebuild this optimizer via ``load_state``.
0714 
0715         Examples:
0716             >>> import json
0717             >>> state = optimizer.serialize_state()
0718             >>> with open("checkpoint.json", "w") as f:
0719             ...     json.dump(state, f)
0720 
0721         Notes:
0722             If the algorithm cannot be pickled (rare), the ``"algorithm_pickle"``
0723             key is omitted and a WARNING is logged.  ``load_state`` will then
0724             recreate the algorithm from the config + seed instead.
0725         """
0726         import base64
0727         import pickle
0728 
0729         space_payload = {
0730             name: param.model_dump()
0731             for name, param in self.search_space.parameters.items()
0732         }
0733         constraints_payload = [
0734             c.model_dump() for c in self.search_space.constraints
0735         ]
0736 
0737         algorithm_pickle: Optional[str] = None
0738         try:
0739             algorithm_pickle = base64.b64encode(
0740                 pickle.dumps(self._algorithm)
0741             ).decode("ascii")
0742         except Exception as exc:
0743             logger.warning(
0744                 "Could not pickle PyMOO algorithm state: %s. "
0745                 "load_state will restart the algorithm from scratch.",
0746                 exc,
0747             )
0748 
0749         return {
0750             "backend": "pymoo",
0751             "search_space": {
0752                 "parameters": space_payload,
0753                 "constraints": constraints_payload,
0754                 "name": self.search_space.name,
0755             },
0756             "objective_names": self.objective_names,
0757             "seed": self.seed,
0758             "config": self.config.model_dump(),
0759             "resolved_algorithm": self.resolved_algorithm,
0760             "trials": [
0761                 {
0762                     "index": t.index,
0763                     "parameters": t.parameters,
0764                     "metrics": t.metrics,
0765                     "status": t.status,
0766                     "metadata": t.metadata,
0767                 }
0768                 for t in self._trials
0769                 if t is not None
0770             ],
0771             "trial_counter": self._trial_counter,
0772             "n_gen_completed": self.n_gen_completed,
0773             "algorithm_pickle": algorithm_pickle,
0774         }
0775 
0776     def load_state(self, state: Dict[str, Any]) -> None:
0777         """Restore optimizer state from a serialised dictionary.
0778 
0779         The search space, config, objective names, completed trials, and
0780         (when available) the pickled algorithm state are all restored.  If
0781         ``algorithm_pickle`` is absent or cannot be deserialised, the algorithm
0782         is rebuilt from the config and seed, which means the internal
0783         population will be reset.
0784 
0785         Args:
0786             state: Dictionary as returned by ``serialize_state``.
0787 
0788         Raises:
0789             ValueError: If required keys are missing from ``state``.
0790             ImportError: If PyMOO is not installed.
0791 
0792         Notes:
0793             After a successful load, ``suggest_candidates`` resumes from where
0794             optimisation left off (provided the algorithm pickle was valid).
0795             Any in-flight generation (partial results) is discarded on load.
0796         """
0797         if not PYMOO_AVAILABLE:
0798             raise ImportError(
0799                 "PyMOO is required but not installed. "
0800                 "Install with: pip install pymoo"
0801             )
0802 
0803         required = {"search_space", "objective_names", "config", "trials"}
0804         missing = required - state.keys()
0805         if missing:
0806             raise ValueError(f"load_state: missing keys in state: {missing}")
0807 
0808         # Restore config, objectives, seed
0809         self.config = PyMOOOptimizerConfig(**state["config"])
0810         self.objective_names = list(state["objective_names"])
0811         self.seed = state.get("seed", self.config.seed)
0812         resolved_algorithm = self.config.resolve_algorithm(self.n_objectives)
0813         stored_algorithm = state.get("resolved_algorithm")
0814         if stored_algorithm and stored_algorithm != resolved_algorithm:
0815             logger.warning(
0816                 "Stored resolved_algorithm '%s' does not match current resolved "
0817                 "algorithm '%s'; using current value.",
0818                 stored_algorithm,
0819                 resolved_algorithm,
0820             )
0821         self.resolved_algorithm = resolved_algorithm
0822 
0823         saved_space = state["search_space"]
0824         self.search_space = SearchSpace(
0825             parameters=saved_space.get("parameters", {}),
0826             constraints=saved_space.get("constraints", []),
0827             name=saved_space.get("name"),
0828         )
0829 
0830         self._param_items = list(self.search_space.parameters.items())
0831         self._xl, self._xu = self._build_bounds()
0832         self.problem = PyMOOProblem(
0833             n_var=len(self._param_items),
0834             n_obj=self.n_objectives,
0835             xl=self._xl,
0836             xu=self._xu,
0837             param_items=self._param_items,
0838             objective_names=self.objective_names,
0839         )
0840 
0841         # Try to restore the algorithm state from pickle
0842         import base64
0843         import pickle
0844 
0845         algorithm_pickle = state.get("algorithm_pickle")
0846         if algorithm_pickle:
0847             try:
0848                 self._algorithm = pickle.loads(
0849                     base64.b64decode(algorithm_pickle.encode("ascii"))
0850                 )
0851                 logger.info("PyMOO algorithm state restored from pickle.")
0852             except Exception as exc:
0853                 logger.warning(
0854                     "Could not unpickle algorithm state (%s); "
0855                     "recreating from config + seed.",
0856                     exc,
0857                 )
0858                 self._algorithm = self._create_algorithm()
0859                 self._algorithm.setup(
0860                     self.problem,
0861                     seed=self.seed,
0862                     verbose=self.config.verbose,
0863                     termination=NoTermination(),
0864                 )
0865         else:
0866             self._algorithm = self._create_algorithm()
0867             self._algorithm.setup(
0868                 self.problem,
0869                 seed=self.seed,
0870                 verbose=self.config.verbose,
0871                 termination=NoTermination(),
0872             )
0873 
0874         # Restore trials
0875         self._trials = []
0876         for td in state["trials"]:
0877             trial = Trial(
0878                 index=td["index"],
0879                 parameters=td["parameters"],
0880                 metrics=td.get("metrics"),
0881                 status=td.get("status", "pending"),
0882                 metadata=td.get("metadata", {}),
0883             )
0884             while len(self._trials) <= trial.index:
0885                 self._trials.append(None)
0886             self._trials[trial.index] = trial
0887 
0888         self._trial_counter = state.get("trial_counter", len(self._trials))
0889         self.n_gen_completed = state.get("n_gen_completed", 0)
0890 
0891         # Clear any in-flight generation state
0892         self._generation_infills = None
0893         self._gen_pos_to_trial_idx = {}
0894         self._result_buffer = {}
0895 
0896         logger.info(
0897             "PyMOO optimizer state loaded: algorithm=%s, %d trials, %d generations completed.",
0898             self.resolved_algorithm,
0899             len(self._trials),
0900             self.n_gen_completed,
0901         )
0902 
0903     # ------------------------------------------------------------------
0904     # Dunder helpers
0905     # ------------------------------------------------------------------
0906 
0907     def __repr__(self) -> str:
0908         """Return a concise string representation of the optimizer.
0909 
0910         Returns:
0911             Human-readable description including algorithm, parameter count,
0912             objective count, and seed.
0913         """
0914         return (
0915             f"PyMOOOptimizer("
0916             f"algorithm={self.resolved_algorithm}, "
0917             f"pop_size={self.config.pop_size}, "
0918             f"n_params={len(self.search_space.parameters)}, "
0919             f"n_objectives={self.n_objectives}, "
0920             f"n_gen_completed={self.n_gen_completed}, "
0921             f"seed={self.seed})"
0922         )