From 7803727e4a436c845163de85654721eaaabb10cf Mon Sep 17 00:00:00 2001 From: Liu Hao <169072815+lll-hhh@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:59:02 +0800 Subject: [PATCH] fix(map-elites): reject non-finite fitness and descriptors --- evoagentx/optimizers/map_elites_optimizer.py | 11 ++- .../test_map_elites_finite_metrics.py | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/src/optimizers/test_map_elites_finite_metrics.py diff --git a/evoagentx/optimizers/map_elites_optimizer.py b/evoagentx/optimizers/map_elites_optimizer.py index f135733b..47653032 100644 --- a/evoagentx/optimizers/map_elites_optimizer.py +++ b/evoagentx/optimizers/map_elites_optimizer.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import math import random from dataclasses import dataclass from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union @@ -121,7 +122,10 @@ def _evaluate(self, output: Dict[str, Any]) -> Tuple[Metrics, float]: fitness = metrics[self.fitness_key] if not isinstance(fitness, (int, float)) or isinstance(fitness, bool): raise TypeError(f"fitness_key '{self.fitness_key}' must be numeric") - return metrics, float(fitness) + fitness = float(fitness) + if not math.isfinite(fitness): + raise ValueError(f"fitness_key '{self.fitness_key}' must be finite") + return metrics, fitness def _bins_for_dim(self, dim: str) -> int: if isinstance(self.feature_bins, int): @@ -139,9 +143,12 @@ def _cell_from_metrics(self, metrics: Metrics) -> Tuple[int, ...]: value = metrics[dim] if not isinstance(value, (int, float)) or isinstance(value, bool): raise TypeError(f"feature dimension '{dim}' must be numeric") + value = float(value) + if not math.isfinite(value): + raise ValueError(f"feature dimension '{dim}' must be finite") lo, hi = self.feature_ranges[dim] bins = max(1, self._bins_for_dim(dim)) - coords.append(self._bin_index(float(value), lo, hi, bins)) + coords.append(self._bin_index(value, lo, hi, bins)) return tuple(coords) @staticmethod diff --git a/tests/src/optimizers/test_map_elites_finite_metrics.py b/tests/src/optimizers/test_map_elites_finite_metrics.py new file mode 100644 index 00000000..f3083c03 --- /dev/null +++ b/tests/src/optimizers/test_map_elites_finite_metrics.py @@ -0,0 +1,69 @@ +"""Exercise numeric validation through the actual MAP-Elites search loop.""" + +import math + +import pytest + +from evoagentx.optimizers.engine.registry import ParamRegistry +from evoagentx.optimizers.map_elites_optimizer import MapElitesOptimizer + + +def make_optimizer(evaluator, *, scalar_result=False): + """Construct a deterministic search that requires no model or network.""" + return MapElitesOptimizer( + registry=ParamRegistry(), + evaluator=evaluator, + search_space={}, + feature_dimensions=[] if scalar_result else ["length"], + feature_ranges={} if scalar_result else {"length": (0.0, 2.0)}, + n_iterations=2, + program=dict, + ) + + +@pytest.mark.parametrize("fitness", [float("nan"), float("inf"), float("-inf")]) +@pytest.mark.parametrize("scalar_result", [False, True]) +def test_nonfinite_fitness_is_rejected_before_archive_admission(fitness, scalar_result): + """Invalid fitness must not become an unbeatable elite or best candidate.""" + result = fitness if scalar_result else {"score": fitness, "length": 1.0} + optimizer = make_optimizer(lambda output: result, scalar_result=scalar_result) + + with pytest.raises(ValueError, match="fitness_key 'score' must be finite"): + optimizer.optimize() + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_nonfinite_feature_is_rejected_before_binning(value): + """Infinite descriptors must not be silently clipped into boundary cells.""" + optimizer = make_optimizer(lambda output: {"score": 1.0, "length": value}) + + with pytest.raises(ValueError, match="feature dimension 'length' must be finite"): + optimizer.optimize() + + +@pytest.mark.parametrize("fitness", [-2.0, 0.0, 2.0]) +@pytest.mark.parametrize("length, cell", [(-1.0, (0,)), (0.0, (0,)), (1.0, (5,)), (2.0, (9,)), (3.0, (9,))]) +def test_finite_scores_and_clipped_descriptors_keep_their_behavior(fitness, length, cell): + """Signed fitness and finite out-of-range descriptors remain supported.""" + optimizer = make_optimizer(lambda output: {"score": fitness, "length": length}) + + best_cfg, result = optimizer.optimize() + + assert best_cfg == {} + assert result["best"].fitness == fitness + assert result["best"].cell == cell + assert list(result["archive"]) == [cell] + assert [row["accepted"] for row in result["history"]] == [True, False] + assert math.isfinite(result["best"].fitness) + + +def test_later_finite_improvement_replaces_an_elite(): + """Validation must preserve the optimizer's replacement and best tracking.""" + scores = iter([-1.0, 1.0]) + optimizer = make_optimizer(lambda output: {"score": next(scores), "length": 1.0}) + + _, result = optimizer.optimize() + + assert [row["accepted"] for row in result["history"]] == [True, True] + assert result["best"].fitness == 1.0 + assert result["archive"][(5,)].fitness == 1.0