Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """Base optimizer abstractions for the AID2E framework.
0002 
0003 This module defines reusable dataclasses and abstract interfaces that optimizers
0004 use to consume design parameter definitions coming from the design configuration
0005 layer. Search spaces are built from typed design parameters, and trials track
0006 optimizer evaluations in a consistent structure.
0007 
0008 Key concepts:
0009     - ``SearchSpace``: Typed parameter domain derived from a DesignConfig.
0010     - ``Trial``: One evaluated point in the search space, with metrics attached.
0011     - ``BaseOptimizer``: Abstract base that all optimizer backends must implement.
0012     - ``compute_pareto_front``: Backend-agnostic utility for Pareto front extraction.
0013 
0014 Entrypoints:
0015     Most users interact with a concrete backend (e.g. ``AxOptimizer``,
0016     ``PyMOOOptimizer``) rather than these base classes directly.
0017 """
0018 
0019 from abc import ABC, abstractmethod
0020 from dataclasses import dataclass, field
0021 from typing import Any, Dict, List, Optional, Tuple, Union
0022 import json
0023 import logging
0024 from pathlib import Path
0025 
0026 logger = logging.getLogger(__name__)
0027 
0028 TRIAL_STATUS_PENDING = "pending"
0029 TRIAL_STATUS_SUGGESTED = "suggested"
0030 TRIAL_STATUS_RUNNING = "running"
0031 TRIAL_STATUS_COMPLETED = "completed"
0032 TRIAL_STATUS_FAILED = "failed"
0033 TRIAL_STATUS_ABORTED = "aborted"
0034 TRIAL_STATUS_CANCELLED = "cancelled"
0035 
0036 VALID_TRIAL_STATUSES = {
0037     TRIAL_STATUS_PENDING,
0038     TRIAL_STATUS_SUGGESTED,
0039     TRIAL_STATUS_RUNNING,
0040     TRIAL_STATUS_COMPLETED,
0041     TRIAL_STATUS_FAILED,
0042     TRIAL_STATUS_ABORTED,
0043     TRIAL_STATUS_CANCELLED,
0044 }
0045 
0046 DISPLAY_STATUS_MAP = {
0047     TRIAL_STATUS_PENDING: "Pending",
0048     TRIAL_STATUS_SUGGESTED: "Suggested",
0049     TRIAL_STATUS_RUNNING: "Running",
0050     TRIAL_STATUS_COMPLETED: "Finished",
0051     TRIAL_STATUS_FAILED: "Failed",
0052     TRIAL_STATUS_ABORTED: "Aborted",
0053     TRIAL_STATUS_CANCELLED: "Cancelled",
0054 }
0055 
0056 from aid2e.utilities.configurations.base_models import BaseParameter, parse_parameter
0057 from aid2e.utilities.configurations.design_config import (
0058     DesignConfig,
0059     ParameterConstraint,
0060 )
0061 
0062 
0063 @dataclass
0064 class SearchSpace:
0065     """Represent an optimization search space built from design parameters.
0066 
0067     Attributes:
0068         parameters: Mapping of parameter names to typed design parameters.
0069         constraints: Optional list of parameter constraints to enforce.
0070         name: Optional identifier for the search space.
0071         source_config: Optional originating DesignConfig for traceability.
0072 
0073     Examples:
0074         >>> from aid2e.utilities.configurations.base_models import RangeParameter
0075         >>> params = {
0076         ...     "x": RangeParameter(name="x", value=0.5, bounds=(0.0, 1.0)),
0077         ... }
0078         >>> space = SearchSpace(parameters=params)
0079         >>> space.validate({"x": 0.4})
0080         (True, [])
0081     """
0082 
0083     parameters: Dict[str, BaseParameter]
0084     constraints: List[ParameterConstraint] = field(default_factory=list)
0085     name: Optional[str] = None
0086     source_config: Optional[DesignConfig] = None
0087 
0088     def __post_init__(self) -> None:
0089         """Normalize parameter and constraint inputs after initialization."""
0090         parsed_parameters: Dict[str, BaseParameter] = {}
0091         for param_name, param in self.parameters.items():
0092             if isinstance(param, BaseParameter):
0093                 parsed = param
0094             elif isinstance(param, dict):
0095                 param_data = dict(param)
0096                 if "values" in param_data:
0097                     raise ValueError(
0098                         f"Parameter '{param_name}' uses retired key 'values'. "
0099                         "Use 'choices'."
0100                     )
0101                 if "bounds" in param_data and "value" not in param_data:
0102                     raise ValueError(
0103                         f"Range parameter '{param_name}' must define an explicit "
0104                         "'value' alongside 'bounds'."
0105                     )
0106                 if "choices" in param_data and "value" not in param_data:
0107                     raise ValueError(
0108                         f"Choice parameter '{param_name}' must define an explicit "
0109                         "'value' alongside 'choices'."
0110                     )
0111                 parsed = parse_parameter(param_name, param_data)
0112             else:
0113                 raise TypeError(
0114                     "Parameters must be BaseParameter instances or dictionaries"
0115                 )
0116 
0117             if parsed.name != param_name:
0118                 parsed = parsed.model_copy(update={"name": param_name})
0119             parsed_parameters[param_name] = parsed
0120 
0121         self.parameters = parsed_parameters
0122 
0123         constraints_input = self.constraints or []
0124         self.constraints = [
0125             c if isinstance(c, ParameterConstraint) else ParameterConstraint(**c)
0126             for c in constraints_input
0127         ]
0128 
0129     @classmethod
0130     def from_design_config(cls, design_config: DesignConfig) -> "SearchSpace":
0131         """Build a search space from a DesignConfig instance.
0132 
0133         Args:
0134             design_config: Fully validated design configuration containing
0135                 parameters and optional parameter constraints.
0136 
0137         Returns:
0138             SearchSpace populated with flattened parameters and constraints.
0139         """
0140 
0141         return cls(
0142             parameters=design_config.get_flat_parameters(),
0143             constraints=design_config.parameter_constraints or [],
0144             name=getattr(design_config, "name", None),
0145             source_config=design_config,
0146         )
0147 
0148     def validate(self, param_values: Dict[str, Any]) -> Tuple[bool, List[str]]:
0149         """Check if parameter values satisfy all constraints (for non-Ax optimizers).
0150 
0151         This method is for optimizers that DON'T have native constraint support
0152         (e.g., random search, simple evolutionary algorithms). For optimizers
0153         with native constraint support (e.g., Ax), pass self.constraints directly
0154         to the optimizer instead of calling this method.
0155 
0156         Args:
0157             param_values: Mapping of qualified parameter names to concrete values.
0158 
0159         Returns:
0160             Tuple of ``(all_valid, failed_constraints)`` where ``all_valid`` is
0161             ``True`` when every constraint passes and ``failed_constraints``
0162             lists the names (or error strings) of failing constraints.
0163 
0164         Example:
0165             >>> # For optimizers WITHOUT constraint support
0166             >>> is_valid, failures = search_space.validate(candidate)
0167             >>> if not is_valid:
0168             ...     print(f"Constraint violations: {failures}")
0169             
0170             >>> # For Ax (HAS constraint support) - DON'T use this method
0171             >>> # Instead, pass search_space.constraints to Ax optimizer
0172 
0173         Notes:
0174             - Only use this for runtime checking with constraint-agnostic optimizers
0175             - Ax and similar optimizers handle constraints internally via self.constraints
0176             - Syntax validation already done at DesignConfig load time
0177         """
0178 
0179         if not self.constraints:
0180             return True, []
0181 
0182         failed: List[str] = []
0183         for constraint in self.constraints:
0184             try:
0185                 if not constraint.evaluate(param_values):
0186                     failed.append(constraint.name)
0187             except Exception as exc:  # pragma: no cover - defensive logging
0188                 failed.append(f"{constraint.name} (error: {exc})")
0189 
0190         return len(failed) == 0, failed
0191 
0192 
0193 @dataclass
0194 class Trial:
0195     """Capture the parameters and results of a single optimization trial.
0196 
0197     Attributes:
0198         index: Unique trial identifier within the optimizer.
0199         parameters: Parameter values evaluated during the trial.
0200         metrics: Objective values produced by evaluation (if available).
0201         metadata: Optional auxiliary metadata about the trial.
0202         status: Lifecycle status such as ``pending``, ``completed``, or ``failed``.
0203 
0204     Examples:
0205         >>> trial = Trial(
0206         ...     index=0,
0207         ...     parameters={"x": 0.5},
0208         ...     metrics={"loss": 0.1},
0209         ...     status="completed",
0210         ... )
0211         >>> trial.metadata
0212         {}
0213     """
0214 
0215     index: int
0216     parameters: Dict[str, Any]
0217     metrics: Optional[Dict[str, float]] = None
0218     metadata: Dict[str, Any] = None
0219     status: str = "pending"
0220 
0221     def __post_init__(self) -> None:
0222         """Normalize metadata and status values after initialization."""
0223         if self.metadata is None:
0224             self.metadata = {}
0225         normalized = str(self.status).strip().lower()
0226         self.status = normalized if normalized else TRIAL_STATUS_PENDING
0227         if self.status not in VALID_TRIAL_STATUSES:
0228             logger.warning(
0229                 "Unknown trial status '%s'; keeping value as-is.",
0230                 self.status,
0231             )
0232 
0233     def save_to_json(self, output_path: Union[str, Path]) -> Path:
0234         """Write the trial's design parameters to disk as pretty-printed JSON.
0235 
0236         This helper is intentionally design-point focused: it serializes only
0237         ``parameters`` so command-line evaluators can consume the resulting file
0238         as an input payload without needing optimizer metadata.
0239 
0240         Args:
0241             output_path: Target JSON path.
0242 
0243         Returns:
0244             Resolved path of the written file.
0245         """
0246         path = Path(output_path)
0247         path.parent.mkdir(parents=True, exist_ok=True)
0248         with path.open("w", encoding="utf-8") as handle:
0249             json.dump(dict(self.parameters or {}), handle, indent=2, sort_keys=True)
0250         return path
0251 
0252 
0253 def compute_pareto_front(
0254     trials: List["Trial"],
0255     objective_names: List[str],
0256     objective_directions: Optional[Dict[str, Any]] = None,
0257 ) -> List["Trial"]:
0258     """Extract non-dominated (Pareto-optimal) trials from a collection of completed trials.
0259 
0260     Objectives are compared using ``objective_directions``. Minimization is the
0261     default; maximization objectives treat larger values as better. For
0262     single-objective problems the function returns the best trial under that
0263     objective's direction.
0264 
0265     Args:
0266         trials: Iterable of Trial objects.  Only trials whose ``status`` is
0267             ``"completed"`` and whose ``metrics`` dict is non-empty are
0268             considered.
0269         objective_names: Ordered list of objective metric keys that must be
0270             present in each trial's ``metrics`` dict.
0271         objective_directions: Optional mapping from objective name to
0272             ``"minimize"`` or ``"maximize"``. Missing objectives default to
0273             minimization.
0274 
0275     Returns:
0276         List of non-dominated Trial objects ordered by their original
0277         position in ``trials``.  Returns an empty list when no completed
0278         trials are available.
0279 
0280     Examples:
0281         >>> from aid2e.optimizers.base import Trial, compute_pareto_front
0282         >>> t1 = Trial(index=0, parameters={}, metrics={"f1": 1.0, "f2": 3.0}, status="completed")
0283         >>> t2 = Trial(index=1, parameters={}, metrics={"f1": 2.0, "f2": 1.0}, status="completed")
0284         >>> t3 = Trial(index=2, parameters={}, metrics={"f1": 0.5, "f2": 2.0}, status="completed")
0285         >>> front = compute_pareto_front([t1, t2, t3], ["f1", "f2"])
0286         >>> {t.index for t in front}
0287         {0, 1, 2}
0288 
0289     Notes:
0290         Uses NumPy for vectorised comparisons when available; falls back to
0291         a pure-Python O(n²) loop otherwise.  For production workloads with
0292         thousands of trials, the NumPy path is strongly recommended.
0293     """
0294     completed = [
0295         t for t in trials
0296         if t is not None and t.status == "completed" and t.metrics
0297     ]
0298     if not completed:
0299         return []
0300     if len(completed) == 1:
0301         return completed
0302 
0303     directions = objective_directions or {}
0304     objective_signs = []
0305     for obj in objective_names:
0306         direction = getattr(directions.get(obj), "value", directions.get(obj, "minimize"))
0307         objective_signs.append(-1.0 if str(direction).lower() == "maximize" else 1.0)
0308 
0309     def score(trial: "Trial", obj: str, sign: float) -> float:
0310         value = trial.metrics.get(obj)
0311         return float("inf") if value is None else float(value) * sign
0312 
0313     try:
0314         import numpy as np
0315 
0316         n = len(completed)
0317         F = np.array(
0318             [
0319                 [score(t, obj, sign) for obj, sign in zip(objective_names, objective_signs)]
0320                 for t in completed
0321             ],
0322             dtype=float,
0323         )
0324         is_dominated = np.zeros(n, dtype=bool)
0325         for i in range(n):
0326             if is_dominated[i]:
0327                 continue
0328             # Vectorised: does any other solution dominate i?
0329             # j dominates i iff F[j] <= F[i] (all) and F[j] < F[i] (any)
0330             dom_mask = np.all(F <= F[i], axis=1) & np.any(F < F[i], axis=1)
0331             dom_mask[i] = False  # Exclude self-comparison
0332             if np.any(dom_mask):
0333                 is_dominated[i] = True
0334 
0335         return [t for t, dom in zip(completed, is_dominated) if not dom]
0336 
0337     except ImportError:  # pragma: no cover – NumPy should always be present
0338         logger.warning("NumPy not available; falling back to pure-Python Pareto computation.")
0339         n = len(completed)
0340         is_dominated = [False] * n
0341         for i in range(n):
0342             if is_dominated[i]:
0343                 continue
0344             for j in range(n):
0345                 if i == j or is_dominated[j]:
0346                     continue
0347                 # Check whether j dominates i
0348                 all_leq = all(
0349                     score(completed[j], obj, sign) <= score(completed[i], obj, sign)
0350                     for obj, sign in zip(objective_names, objective_signs)
0351                 )
0352                 any_lt = any(
0353                     score(completed[j], obj, sign) < score(completed[i], obj, sign)
0354                     for obj, sign in zip(objective_names, objective_signs)
0355                 )
0356                 if all_leq and any_lt:
0357                     is_dominated[i] = True
0358                     break
0359 
0360         return [t for t, dom in zip(completed, is_dominated) if not dom]
0361 
0362 
0363 class BaseOptimizer(ABC):
0364     """Abstract base class for all AID2E optimizers.
0365 
0366     Subclasses must implement the abstract methods to suggest candidates, ingest
0367     evaluation results, and surface optimizer state. The interface is intentionally
0368     minimal to support a range of backends (Ax, genetic algorithms, grid search)
0369     while keeping a consistent contract for the rest of the framework.
0370 
0371     Concrete default implementations are provided for ``get_pareto_front`` and
0372     ``get_best_trial`` using :func:`compute_pareto_front`.  Individual backends
0373     may override these when native support (e.g. PyMOO's built-in Pareto tools)
0374     is preferable.
0375     """
0376 
0377     def __init__(
0378         self,
0379         search_space: Union[SearchSpace, DesignConfig],
0380         objective_names: List[str],
0381         seed: Optional[int] = None,
0382         objective_directions: Optional[Dict[str, Any]] = None,
0383     ) -> None:
0384         """Initialize the optimizer with a search space and objective specification.
0385 
0386         Args:
0387             search_space: Typed search space or DesignConfig to optimize over.
0388             objective_names: Ordered list of objective metric names.  Each name
0389                 must match the keys returned in the ``metrics`` dict when
0390                 ``update_with_results`` is called.
0391             seed: Optional integer seed for reproducibility.
0392             objective_directions: Optional mapping from objective name to
0393                 ``"minimize"`` or ``"maximize"``. Missing objectives default to
0394                 minimization.
0395 
0396         Raises:
0397             ValueError: If the search space is empty or no objective names are
0398                 provided.
0399         """
0400 
0401         resolved_space = (
0402             SearchSpace.from_design_config(search_space)
0403             if isinstance(search_space, DesignConfig)
0404             else search_space
0405         )
0406 
0407         if not resolved_space.parameters:
0408             raise ValueError("Search space cannot be empty")
0409         if not objective_names:
0410             raise ValueError("objective_names must contain at least one name")
0411 
0412         self.search_space = resolved_space
0413         self.objective_names: List[str] = list(objective_names)
0414         self.objective_directions: Dict[str, Any] = dict(objective_directions or {})
0415         self.seed = seed
0416 
0417         # Shared trial history managed by the base class.
0418         # All backends read and write through self._trials and self._trial_counter
0419         # so utilities like seed_from_trials, get_trials, get_pareto_front work
0420         # uniformly regardless of the backend.
0421         self._trials: List[Optional[Trial]] = []
0422         self._trial_counter: int = 0
0423 
0424     @property
0425     def n_objectives(self) -> int:
0426         """Return the number of optimisation objectives.
0427 
0428         Returns:
0429             Integer count derived from ``objective_names``.
0430         """
0431         return len(self.objective_names)
0432 
0433     @abstractmethod
0434     def suggest_candidates(self, n_candidates: int = 1) -> List[Dict[str, Any]]:
0435         """Suggest next parameter configurations to evaluate.
0436         
0437         Args:
0438             n_candidates: Number of candidates to suggest.
0439         
0440         Returns:
0441             List of parameter dictionaries, where each dictionary maps
0442             parameter names to their suggested values.
0443         
0444         Raises:
0445             RuntimeError: If optimizer is not properly initialized.
0446         
0447         Examples:
0448             >>> candidates = optimizer.suggest_candidates(n_candidates=5)
0449             >>> candidates[0]
0450             {'x': 0.5, 'y': 0.3, 'z': 2.1}
0451         
0452         Notes:
0453             The implementation should use the configured strategy
0454             (e.g., Sobol sampling, Bayesian optimization, genetic algorithms).
0455         """
0456         pass
0457     
0458     @abstractmethod
0459     def update_with_results(
0460         self,
0461         trial_index: int,
0462         parameters: Dict[str, Any],
0463         metrics: Dict[str, float]
0464     ) -> None:
0465         """Update optimizer with evaluation results from a trial.
0466         
0467         Args:
0468             trial_index: Unique identifier for the trial.
0469             parameters: Parameter values that were evaluated.
0470             metrics: Objective values obtained from evaluation.
0471                 Keys are metric names, values are metric values.
0472         
0473         Raises:
0474             ValueError: If metrics don't match expected objectives.
0475         
0476         Examples:
0477             >>> optimizer.update_with_results(
0478             ...     trial_index=0,
0479             ...     parameters={'x': 0.5, 'y': 0.3},
0480             ...     metrics={'loss': 0.1, 'accuracy': 0.9}
0481             ... )
0482         
0483         Notes:
0484             After updating, the optimizer can use this information to
0485             suggest better candidates in subsequent calls to suggest_candidates().
0486         """
0487         pass
0488 
0489     def get_trials(self) -> List[Trial]:
0490         """Return all recorded trials (pending, completed, and failed).
0491 
0492         The base implementation reads directly from ``self._trials``, which is
0493         owned by ``BaseOptimizer`` and kept up to date by every backend.
0494         Backends that maintain additional internal state may override this to
0495         include synthetic or reconstructed trials, but doing so is uncommon.
0496 
0497         Returns:
0498             List of non-``None`` Trial objects in creation order.
0499 
0500         Examples:
0501             >>> done = [t for t in optimizer.get_trials() if t.status == "completed"]
0502         """
0503         return [t for t in self._trials if t is not None]
0504 
0505     def set_trial_status(
0506         self,
0507         trial_index: int,
0508         status: str,
0509         *,
0510         parameters: Optional[Dict[str, Any]] = None,
0511         metrics: Optional[Dict[str, float]] = None,
0512         metadata: Optional[Dict[str, Any]] = None,
0513     ) -> Trial:
0514         """Create or update a trial entry with a new lifecycle status.
0515 
0516         Args:
0517             trial_index: Unique trial identifier.
0518             status: Trial lifecycle status (for example ``running``,
0519                 ``completed``, ``aborted``).
0520             parameters: Optional parameter dictionary to store on the trial.
0521             metrics: Optional objective dictionary to store on the trial.
0522             metadata: Optional metadata to merge into existing metadata.
0523 
0524         Returns:
0525             The updated Trial object.
0526 
0527         Raises:
0528             ValueError: If ``trial_index`` is negative.
0529         """
0530         if trial_index < 0:
0531             raise ValueError("trial_index must be >= 0")
0532 
0533         normalized_status = str(status).strip().lower()
0534         while len(self._trials) <= trial_index:
0535             self._trials.append(None)
0536 
0537         existing = self._trials[trial_index]
0538         existing_parameters = existing.parameters if existing else {}
0539         existing_metrics = existing.metrics if existing else None
0540         existing_metadata = dict(existing.metadata) if existing and existing.metadata else {}
0541 
0542         trial = Trial(
0543             index=trial_index,
0544             parameters=parameters if parameters is not None else existing_parameters,
0545             metrics=metrics if metrics is not None else existing_metrics,
0546             status=normalized_status,
0547             metadata={**existing_metadata, **(metadata or {})},
0548         )
0549         self._trials[trial_index] = trial
0550         self._trial_counter = max(self._trial_counter, trial_index + 1)
0551         return trial
0552 
0553     def get_optimization_results(self) -> Dict[str, Any]:
0554         """Return a normalized optimization-results payload.
0555 
0556         Returns:
0557             Dictionary containing objective names and trial records with
0558             parameters, metrics, and both raw and display status labels.
0559         """
0560         trials_payload: List[Dict[str, Any]] = []
0561         for trial in self.get_trials():
0562             trials_payload.append(
0563                 {
0564                     "trial_index": trial.index,
0565                     "status": trial.status,
0566                     "display_status": DISPLAY_STATUS_MAP.get(
0567                         trial.status,
0568                         trial.status.title(),
0569                     ),
0570                     "design_parameters": dict(trial.parameters or {}),
0571                     "objectives": dict(trial.metrics or {}),
0572                     "metadata": dict(trial.metadata or {}),
0573                 }
0574             )
0575 
0576         return {
0577             "objective_names": list(self.objective_names),
0578             "n_objectives": self.n_objectives,
0579             "n_trials": len(trials_payload),
0580             "trials": trials_payload,
0581         }
0582 
0583     def save_optimization_results(self, output_path: Union[str, Path]) -> Path:
0584         """Write optimization results to disk as pretty-printed JSON.
0585 
0586         Args:
0587             output_path: Target JSON path.
0588 
0589         Returns:
0590             Resolved path of the written file.
0591         """
0592         path = Path(output_path)
0593         path.parent.mkdir(parents=True, exist_ok=True)
0594         payload = self.get_optimization_results()
0595         with path.open("w", encoding="utf-8") as handle:
0596             json.dump(payload, handle, indent=2, sort_keys=True)
0597         return path
0598 
0599     def seed_from_trials(
0600         self,
0601         trials: List[Trial],
0602         *,
0603         only_completed: bool = True,
0604     ) -> int:
0605         """Inject external trials into the optimizer history without advancing the algorithm.
0606 
0607         This is the *backend-switch primitive*.  It lets you:
0608 
0609         - Seed a new optimizer with results from a previous one (e.g. random
0610           init → MOEA → BO transition).
0611         - Inject prior knowledge before the first ``suggest_candidates`` call.
0612         - Resume an optimisation from a checkpoint produced by a *different*
0613           backend.
0614 
0615         Trials are appended to the internal ``_trials`` list with freshly
0616         assigned sequential indices (starting from the current
0617         ``_trial_counter``).  The original ``trial.index`` values from the
0618         source optimizer are preserved in each trial's ``metadata`` under the
0619         key ``"source_index"``.
0620 
0621         Args:
0622             trials: Iterable of Trial objects to inject.  The list may contain
0623                 ``None`` placeholders (they are silently skipped).
0624             only_completed: When ``True`` (default), only trials whose
0625                 ``status`` is ``"completed"`` are imported.  Set to ``False``
0626                 to also import ``"pending"`` or ``"failed"`` trials.
0627 
0628         Returns:
0629             Number of trials actually injected.
0630 
0631         Examples:
0632             >>> # Transfer best results from a random-search warmup:
0633             >>> warmup_trials = random_opt.get_trials()
0634             >>> pymoo_opt.seed_from_trials(warmup_trials)
0635             100
0636             >>> # Now start MOEA generation — the history already has 100 points
0637             >>> candidates = pymoo_opt.suggest_candidates()
0638 
0639         Notes:
0640             - This method does NOT advance PyMOO's (or any other backend's)
0641               internal population.  The injected trials are purely visible in
0642               the history for Pareto-front and best-trial queries.
0643             - Backends that want to warm-start their internal state from these
0644               trials should override this method.
0645         """
0646         accepted = 0
0647         for trial in trials:
0648             if trial is None:
0649                 continue
0650             if only_completed and trial.status != "completed":
0651                 continue
0652             new_idx = self._trial_counter
0653             seeded_trial = Trial(
0654                 index=new_idx,
0655                 parameters=trial.parameters,
0656                 metrics=trial.metrics,
0657                 status=trial.status,
0658                 metadata={**(trial.metadata or {}), "source_index": trial.index},
0659             )
0660             while len(self._trials) <= new_idx:
0661                 self._trials.append(None)
0662             self._trials[new_idx] = seeded_trial
0663             self._trial_counter += 1
0664             accepted += 1
0665 
0666         if accepted:
0667             logger.debug(
0668                 "seed_from_trials: injected %d trial(s) (total history: %d).",
0669                 accepted,
0670                 self._trial_counter,
0671             )
0672         return accepted
0673 
0674     def get_pareto_front(self) -> List[Trial]:
0675         """Retrieve the current Pareto front of non-dominated solutions.
0676 
0677         The default implementation delegates to :func:`compute_pareto_front`,
0678         which operates on the trials returned by :meth:`get_trials`.  Backends
0679         with native Pareto support (e.g. PyMOO) may override this method to
0680         expose more detailed Pareto metadata.
0681 
0682         Returns:
0683             List of Trial objects representing Pareto-optimal solutions.
0684             For single-objective optimisation, returns the single trial with
0685             the best metric value under its configured direction. Returns an
0686             empty list when no completed trials are available.
0687 
0688         Examples:
0689             >>> pareto_front = optimizer.get_pareto_front()
0690             >>> for trial in pareto_front:
0691             ...     print(f"Params: {trial.parameters}, Metrics: {trial.metrics}")
0692 
0693         Notes:
0694             Objective directions are read from ``self.objective_directions``.
0695         """
0696         return compute_pareto_front(
0697             self.get_trials(),
0698             self.objective_names,
0699             self.objective_directions,
0700         )
0701 
0702     def get_best_trial(self) -> Optional[Trial]:
0703         """Get the best trial found so far.
0704 
0705         For single-objective optimisation, returns the completed trial with the
0706         best metric value under its configured direction. For multi-objective,
0707         returns the first trial from the Pareto front (arbitrary representative;
0708         use :meth:`get_pareto_front` for the full front).
0709 
0710         Returns:
0711             Best Trial, or ``None`` if no completed trials exist.
0712 
0713         Examples:
0714             >>> best = optimizer.get_best_trial()
0715             >>> if best:
0716             ...     print(f"Best parameters: {best.parameters}")
0717             ...     print(f"Best metrics: {best.metrics}")
0718         """
0719         front = self.get_pareto_front()
0720         if not front:
0721             return None
0722         if self.n_objectives == 1:
0723             obj = self.objective_names[0]
0724             return min(front, key=lambda t: t.metrics[obj])
0725         # Multi-objective: return first element of the front
0726         return front[0]
0727 
0728     @abstractmethod
0729     def serialize_state(self) -> Dict[str, Any]:
0730         """Serialize optimizer state for distributed execution or checkpointing.
0731 
0732         Returns:
0733             Dictionary containing all necessary state to reconstruct
0734             the optimizer. Should be JSON-serializable.
0735 
0736         Examples:
0737             >>> state = optimizer.serialize_state()
0738             >>> import json
0739             >>> with open('optimizer_state.json', 'w') as f:
0740             ...     json.dump(state, f)
0741 
0742         Notes:
0743             This is crucial for distributed optimization where optimizer
0744             state needs to be shared across workers or checkpointed.
0745         """
0746         pass
0747 
0748     @abstractmethod
0749     def load_state(self, state: Dict[str, Any]) -> None:
0750         """Load optimizer state from serialized form.
0751 
0752         Args:
0753             state: Dictionary containing serialized optimizer state,
0754                 as returned by serialize_state().
0755         
0756         Raises:
0757             ValueError: If state is invalid or incompatible.
0758         
0759         Examples:
0760             >>> import json
0761             >>> with open('optimizer_state.json', 'r') as f:
0762             ...     state = json.load(f)
0763             >>> optimizer.load_state(state)
0764         
0765         Notes:
0766             After loading state, the optimizer should be able to continue
0767             optimization as if it never stopped.
0768         """
0769         pass
0770     
0771     def __repr__(self) -> str:
0772         """Return string representation of the optimizer.
0773         
0774         Returns:
0775             String describing the optimizer configuration.
0776         """
0777         return (
0778             f"{self.__class__.__name__}("
0779             f"n_params={len(self.search_space.parameters)}, "
0780             f"n_objectives={self.n_objectives}, "
0781             f"seed={self.seed}"
0782             f")"
0783         )