ctrl-freak 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctrl_freak/registry.py ADDED
@@ -0,0 +1,303 @@
1
+ """Registry system for selection and survival strategies.
2
+
3
+ This module provides a registry pattern for managing selection and survival
4
+ strategies in evolutionary algorithms. Instead of hardcoding strategy
5
+ implementations, users can register factories that create configured selectors
6
+ and retrieve them by name.
7
+
8
+ The registry pattern enables:
9
+ - **Pluggable strategies**: Swap selection methods without code changes
10
+ - **Configuration-driven experiments**: Select strategies by string name from config files
11
+ - **Discoverability**: List all available strategies programmatically
12
+ - **Factory pattern**: Register functions that create configured selectors
13
+
14
+ There are two independent registries:
15
+ 1. **SelectionRegistry**: For parent selection strategies (ParentSelector protocol)
16
+ 2. **SurvivalRegistry**: For survivor selection strategies (SurvivorSelector protocol)
17
+
18
+ Examples
19
+ --------
20
+ Strategies are registered as factories and retrieved by name::
21
+
22
+ from ctrl_freak.registry import SelectionRegistry, list_selections
23
+
24
+ def tournament_factory(size=2):
25
+ def tournament_selector(pop, n_parents, rng, **kwargs):
26
+ ...
27
+ return tournament_selector
28
+
29
+ SelectionRegistry.register("tournament", tournament_factory)
30
+ selector = SelectionRegistry.get("tournament", size=3)
31
+ available = list_selections()
32
+ """
33
+
34
+ from collections.abc import Callable
35
+
36
+ from ctrl_freak.protocols import ParentSelector, SurvivorSelector
37
+
38
+
39
+ class SelectionRegistry:
40
+ """Registry for parent selection strategies.
41
+
42
+ This class provides a class-level registry for parent selection strategy
43
+ factories. Strategies are registered by name and can be retrieved with
44
+ custom configuration parameters.
45
+
46
+ The registry stores factory functions that accept keyword arguments and
47
+ return ParentSelector callables. This enables flexible configuration at
48
+ retrieval time.
49
+
50
+ Attributes
51
+ ----------
52
+ _registry : dict[str, Callable[..., ParentSelector]]
53
+ Mapping from strategy names to factory functions.
54
+
55
+ Examples
56
+ --------
57
+ A strategy factory returns a callable that satisfies ``ParentSelector``::
58
+
59
+ def tournament_factory(size=2):
60
+ def selector(pop, n_parents, rng, **kwargs):
61
+ ...
62
+ return selector
63
+
64
+ SelectionRegistry.register("tournament", tournament_factory)
65
+ selector = SelectionRegistry.get("tournament", size=5)
66
+ """
67
+
68
+ _registry: dict[str, Callable[..., ParentSelector]] = {}
69
+
70
+ @classmethod
71
+ def register(cls, name: str, factory: Callable[..., ParentSelector]) -> None:
72
+ """Register a parent selection strategy factory.
73
+
74
+ The factory is a callable that accepts keyword arguments and returns
75
+ a ParentSelector. This enables strategies to be configured at retrieval
76
+ time with custom parameters.
77
+
78
+ Parameters
79
+ ----------
80
+ name : str
81
+ Unique name for the strategy. Existing names are overwritten.
82
+ factory : Callable[..., ParentSelector]
83
+ Callable that returns a parent selector.
84
+
85
+ Examples
86
+ --------
87
+ >>> from ctrl_freak.registry import SelectionRegistry
88
+ >>> SelectionRegistry.register(
89
+ ... "__doc_random__",
90
+ ... lambda: lambda pop, n_parents, rng, **kw: rng.choice(len(pop), n_parents),
91
+ ... )
92
+ >>> "__doc_random__" in SelectionRegistry.list()
93
+ True
94
+ """
95
+ cls._registry[name] = factory
96
+
97
+ @classmethod
98
+ def get(cls, name: str, **kwargs) -> ParentSelector:
99
+ """Get a configured parent selector by name.
100
+
101
+ Retrieves the factory for the given strategy name and calls it with
102
+ the provided keyword arguments to create a configured selector.
103
+
104
+ Parameters
105
+ ----------
106
+ name : str
107
+ Name of the registered strategy.
108
+ **kwargs
109
+ Configuration parameters passed to the factory function.
110
+
111
+ Returns
112
+ -------
113
+ ParentSelector
114
+ A configured parent selector callable.
115
+
116
+ Raises
117
+ ------
118
+ KeyError
119
+ If the strategy name is not registered.
120
+
121
+ Examples
122
+ --------
123
+ >>> from ctrl_freak.registry import SelectionRegistry
124
+ >>> SelectionRegistry.register("__doc_empty__", lambda: lambda pop, n, rng, **kw: [])
125
+ >>> selector = SelectionRegistry.get("__doc_empty__")
126
+ >>> callable(selector)
127
+ True
128
+ """
129
+ if name not in cls._registry:
130
+ available = ", ".join(sorted(cls._registry.keys())) or "none"
131
+ raise KeyError(f"Selection strategy '{name}' not found. Available strategies: {available}")
132
+ factory = cls._registry[name]
133
+ return factory(**kwargs)
134
+
135
+ @classmethod
136
+ def list(cls) -> list[str]:
137
+ """Return list of registered strategy names.
138
+
139
+ Returns
140
+ -------
141
+ list[str]
142
+ Sorted list of all registered parent selection strategy names.
143
+
144
+ Examples
145
+ --------
146
+ >>> from ctrl_freak.registry import SelectionRegistry
147
+ >>> isinstance(SelectionRegistry.list(), list)
148
+ True
149
+ """
150
+ return sorted(cls._registry.keys())
151
+
152
+
153
+ class SurvivalRegistry:
154
+ """Registry for survivor selection strategies.
155
+
156
+ This class provides a class-level registry for survivor selection strategy
157
+ factories. Strategies are registered by name and can be retrieved with
158
+ custom configuration parameters.
159
+
160
+ The registry stores factory functions that accept keyword arguments and
161
+ return SurvivorSelector callables. This enables flexible configuration at
162
+ retrieval time.
163
+
164
+ Attributes
165
+ ----------
166
+ _registry : dict[str, Callable[..., SurvivorSelector]]
167
+ Mapping from strategy names to factory functions.
168
+
169
+ Examples
170
+ --------
171
+ A strategy factory returns a callable that satisfies ``SurvivorSelector``::
172
+
173
+ def nsga2_factory(preserve_diversity=True):
174
+ def selector(pop, n_survivors, **kwargs):
175
+ ...
176
+ return selector
177
+
178
+ SurvivalRegistry.register("nsga2", nsga2_factory)
179
+ selector = SurvivalRegistry.get("nsga2", preserve_diversity=False)
180
+ """
181
+
182
+ _registry: dict[str, Callable[..., SurvivorSelector]] = {}
183
+
184
+ @classmethod
185
+ def register(cls, name: str, factory: Callable[..., SurvivorSelector]) -> None:
186
+ """Register a survivor selection strategy factory.
187
+
188
+ The factory is a callable that accepts keyword arguments and returns
189
+ a SurvivorSelector. This enables strategies to be configured at retrieval
190
+ time with custom parameters.
191
+
192
+ Parameters
193
+ ----------
194
+ name : str
195
+ Unique name for the strategy. Existing names are overwritten.
196
+ factory : Callable[..., SurvivorSelector]
197
+ Callable that returns a survivor selector.
198
+
199
+ Examples
200
+ --------
201
+ >>> import numpy as np
202
+ >>> from ctrl_freak.registry import SurvivalRegistry
203
+ >>> SurvivalRegistry.register(
204
+ ... "__doc_truncation__",
205
+ ... lambda: lambda pop, n, **kw: (np.arange(n), {}),
206
+ ... )
207
+ >>> "__doc_truncation__" in SurvivalRegistry.list()
208
+ True
209
+ """
210
+ cls._registry[name] = factory
211
+
212
+ @classmethod
213
+ def get(cls, name: str, **kwargs) -> SurvivorSelector:
214
+ """Get a configured survivor selector by name.
215
+
216
+ Retrieves the factory for the given strategy name and calls it with
217
+ the provided keyword arguments to create a configured selector.
218
+
219
+ Parameters
220
+ ----------
221
+ name : str
222
+ Name of the registered strategy.
223
+ **kwargs
224
+ Configuration parameters passed to the factory function.
225
+
226
+ Returns
227
+ -------
228
+ SurvivorSelector
229
+ A configured survivor selector callable.
230
+
231
+ Raises
232
+ ------
233
+ KeyError
234
+ If the strategy name is not registered.
235
+
236
+ Examples
237
+ --------
238
+ >>> from ctrl_freak.registry import SurvivalRegistry
239
+ >>> SurvivalRegistry.register("__doc_survivor__", lambda: lambda pop, n, **kw: ([], {}))
240
+ >>> selector = SurvivalRegistry.get("__doc_survivor__")
241
+ >>> callable(selector)
242
+ True
243
+ """
244
+ if name not in cls._registry:
245
+ available = ", ".join(sorted(cls._registry.keys())) or "none"
246
+ raise KeyError(f"Survival strategy '{name}' not found. Available strategies: {available}")
247
+ factory = cls._registry[name]
248
+ return factory(**kwargs)
249
+
250
+ @classmethod
251
+ def list(cls) -> list[str]:
252
+ """Return list of registered strategy names.
253
+
254
+ Returns
255
+ -------
256
+ list[str]
257
+ Sorted list of all registered survivor selection strategy names.
258
+
259
+ Examples
260
+ --------
261
+ >>> from ctrl_freak.registry import SurvivalRegistry
262
+ >>> isinstance(SurvivalRegistry.list(), list)
263
+ True
264
+ """
265
+ return sorted(cls._registry.keys())
266
+
267
+
268
+ def list_selections() -> list[str]:
269
+ """List all registered parent selection strategies.
270
+
271
+ Convenience function that returns SelectionRegistry.list().
272
+
273
+ Returns
274
+ -------
275
+ list[str]
276
+ Sorted list of all registered parent selection strategy names.
277
+
278
+ Examples
279
+ --------
280
+ >>> from ctrl_freak.registry import list_selections
281
+ >>> isinstance(list_selections(), list)
282
+ True
283
+ """
284
+ return SelectionRegistry.list()
285
+
286
+
287
+ def list_survivals() -> list[str]:
288
+ """List all registered survivor selection strategies.
289
+
290
+ Convenience function that returns SurvivalRegistry.list().
291
+
292
+ Returns
293
+ -------
294
+ list[str]
295
+ Sorted list of all registered survivor selection strategy names.
296
+
297
+ Examples
298
+ --------
299
+ >>> from ctrl_freak.registry import list_survivals
300
+ >>> isinstance(list_survivals(), list)
301
+ True
302
+ """
303
+ return SurvivalRegistry.list()
ctrl_freak/results.py ADDED
@@ -0,0 +1,246 @@
1
+ """Algorithm-specific result types for genetic algorithms.
2
+
3
+ This module provides result dataclasses that encapsulate algorithm-specific
4
+ metadata along with the final population:
5
+
6
+ - NSGA2Result: Results from NSGA-II multi-objective optimization
7
+ - GAResult: Results from single-objective genetic algorithms
8
+
9
+ Both classes are immutable (frozen dataclasses) to enforce functional style.
10
+ All numpy arrays are copied on construction to ensure immutability.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+
17
+ from ctrl_freak.population import Population
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class NSGA2Result:
22
+ """Results from NSGA-II multi-objective optimization algorithm.
23
+
24
+ This class encapsulates the final population along with algorithm-specific
25
+ metadata like Pareto ranks and crowding distances. All arrays are copied
26
+ on construction to ensure immutability.
27
+
28
+ Attributes
29
+ ----------
30
+ population : Population
31
+ The final population after optimization.
32
+ rank : numpy.ndarray
33
+ Pareto rank for each individual. Rank 0 indicates individuals on the
34
+ Pareto front.
35
+ crowding_distance : numpy.ndarray
36
+ Crowding distance for each individual.
37
+ generations : int
38
+ Number of generations completed during optimization.
39
+ evaluations : int
40
+ Total number of objective function evaluations performed.
41
+
42
+ Examples
43
+ --------
44
+ >>> import numpy as np
45
+ >>> from ctrl_freak.population import Population
46
+ >>> from ctrl_freak.results import NSGA2Result
47
+ >>> x = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
48
+ >>> obj = np.array([[0.5, 0.5], [0.3, 0.7], [0.4, 0.6]])
49
+ >>> pop = Population(x=x, objectives=obj)
50
+ >>> result = NSGA2Result(
51
+ ... population=pop,
52
+ ... rank=np.array([0, 0, 1]),
53
+ ... crowding_distance=np.array([np.inf, np.inf, 0.5]),
54
+ ... generations=100,
55
+ ... evaluations=5000,
56
+ ... )
57
+ >>> len(result.pareto_front)
58
+ 2
59
+ """
60
+
61
+ population: Population
62
+ rank: np.ndarray
63
+ crowding_distance: np.ndarray
64
+ generations: int
65
+ evaluations: int
66
+
67
+ def __post_init__(self) -> None:
68
+ """Validate shapes and copy arrays for immutability.
69
+
70
+ Raises
71
+ ------
72
+ TypeError
73
+ If rank or crowding_distance are not numpy arrays.
74
+ ValueError
75
+ If array shapes are inconsistent.
76
+ """
77
+ n = len(self.population)
78
+
79
+ # Validate rank
80
+ if not isinstance(self.rank, np.ndarray):
81
+ raise TypeError(f"rank must be a numpy array, got {type(self.rank).__name__}")
82
+ if self.rank.ndim != 1:
83
+ raise ValueError(f"rank must be 1D, got shape {self.rank.shape}")
84
+ if self.rank.shape[0] != n:
85
+ raise ValueError(f"rank has {self.rank.shape[0]} elements, expected {n} to match population size")
86
+
87
+ # Validate crowding_distance
88
+ if not isinstance(self.crowding_distance, np.ndarray):
89
+ raise TypeError(f"crowding_distance must be a numpy array, got {type(self.crowding_distance).__name__}")
90
+ if self.crowding_distance.ndim != 1:
91
+ raise ValueError(f"crowding_distance must be 1D, got shape {self.crowding_distance.shape}")
92
+ if self.crowding_distance.shape[0] != n:
93
+ raise ValueError(
94
+ f"crowding_distance has {self.crowding_distance.shape[0]} elements, expected {n} to match population size"
95
+ )
96
+
97
+ # Copy arrays for immutability (use object.__setattr__ for frozen dataclass)
98
+ object.__setattr__(self, "rank", self.rank.copy())
99
+ object.__setattr__(self, "crowding_distance", self.crowding_distance.copy())
100
+
101
+ @property
102
+ def pareto_front(self) -> Population:
103
+ """Extract the Pareto front (rank-0 individuals) as a new Population.
104
+
105
+ Returns
106
+ -------
107
+ Population
108
+ A new Population containing only individuals with rank 0.
109
+
110
+ Examples
111
+ --------
112
+ >>> import numpy as np
113
+ >>> from ctrl_freak.population import Population
114
+ >>> from ctrl_freak.results import NSGA2Result
115
+ >>> pop = Population(
116
+ ... x=np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]),
117
+ ... objectives=np.array([[0.5, 0.5], [0.3, 0.7], [0.4, 0.6]]),
118
+ ... )
119
+ >>> result = NSGA2Result(
120
+ ... population=pop,
121
+ ... rank=np.array([0, 0, 1]),
122
+ ... crowding_distance=np.array([np.inf, np.inf, 0.5]),
123
+ ... generations=1,
124
+ ... evaluations=3,
125
+ ... )
126
+ >>> len(result.pareto_front)
127
+ 2
128
+ """
129
+ # Find indices of rank-0 individuals
130
+ rank_0_mask = self.rank == 0
131
+ rank_0_indices = np.where(rank_0_mask)[0]
132
+
133
+ # Extract rank-0 individuals
134
+ x_front = self.population.x[rank_0_indices]
135
+ objectives_front = (
136
+ self.population.objectives[rank_0_indices] if self.population.objectives is not None else None
137
+ )
138
+
139
+ return Population(x=x_front, objectives=objectives_front)
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class GAResult:
144
+ """Results from single-objective genetic algorithm optimization.
145
+
146
+ This class encapsulates the final population along with fitness values
147
+ for each individual. In minimization problems, lower fitness is better.
148
+ All arrays are copied on construction to ensure immutability.
149
+
150
+ Attributes
151
+ ----------
152
+ population : Population
153
+ The final population after optimization.
154
+ fitness : numpy.ndarray
155
+ Fitness values for each individual. Lower values indicate better
156
+ fitness for minimization problems.
157
+ best_idx : int
158
+ Index of the best individual.
159
+ generations : int
160
+ Number of generations completed during optimization.
161
+ evaluations : int
162
+ Total number of objective function evaluations performed.
163
+
164
+ Examples
165
+ --------
166
+ >>> import numpy as np
167
+ >>> from ctrl_freak.population import Population
168
+ >>> from ctrl_freak.results import GAResult
169
+ >>> pop = Population(x=np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]))
170
+ >>> result = GAResult(
171
+ ... population=pop,
172
+ ... fitness=np.array([0.5, 0.3, 0.7]),
173
+ ... best_idx=1,
174
+ ... generations=50,
175
+ ... evaluations=2500,
176
+ ... )
177
+ >>> result.best[1]
178
+ 0.3
179
+ """
180
+
181
+ population: Population
182
+ fitness: np.ndarray
183
+ best_idx: int
184
+ generations: int
185
+ evaluations: int
186
+
187
+ def __post_init__(self) -> None:
188
+ """Validate shapes and copy arrays for immutability.
189
+
190
+ Raises
191
+ ------
192
+ TypeError
193
+ If fitness is not a numpy array or best_idx is not an integer.
194
+ ValueError
195
+ If array shapes are inconsistent or best_idx is out of bounds.
196
+ """
197
+ n = len(self.population)
198
+
199
+ # Validate fitness
200
+ if not isinstance(self.fitness, np.ndarray):
201
+ raise TypeError(f"fitness must be a numpy array, got {type(self.fitness).__name__}")
202
+ if self.fitness.ndim != 1:
203
+ raise ValueError(f"fitness must be 1D, got shape {self.fitness.shape}")
204
+ if self.fitness.shape[0] != n:
205
+ raise ValueError(f"fitness has {self.fitness.shape[0]} elements, expected {n} to match population size")
206
+
207
+ # Validate best_idx
208
+ if not isinstance(self.best_idx, (int, np.integer)):
209
+ raise TypeError(f"best_idx must be an integer, got {type(self.best_idx).__name__}")
210
+ if self.best_idx < 0 or self.best_idx >= n:
211
+ raise ValueError(f"best_idx {self.best_idx} is out of bounds for population with {n} individuals")
212
+
213
+ # Copy fitness for immutability (use object.__setattr__ for frozen dataclass)
214
+ object.__setattr__(self, "fitness", self.fitness.copy())
215
+
216
+ @property
217
+ def best(self) -> tuple[np.ndarray, float]:
218
+ """Extract the best individual and its fitness value.
219
+
220
+ Returns
221
+ -------
222
+ tuple[numpy.ndarray, float]
223
+ Decision variables and fitness value for the best individual.
224
+
225
+ Examples
226
+ --------
227
+ >>> import numpy as np
228
+ >>> from ctrl_freak.population import Population
229
+ >>> from ctrl_freak.results import GAResult
230
+ >>> pop = Population(x=np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]))
231
+ >>> result = GAResult(
232
+ ... population=pop,
233
+ ... fitness=np.array([0.5, 0.3, 0.7]),
234
+ ... best_idx=1,
235
+ ... generations=1,
236
+ ... evaluations=3,
237
+ ... )
238
+ >>> best_x, best_fitness = result.best
239
+ >>> best_x
240
+ array([3., 4.])
241
+ >>> best_fitness
242
+ 0.3
243
+ """
244
+ best_x = self.population.x[self.best_idx]
245
+ best_fitness = self.fitness[self.best_idx]
246
+ return (best_x, float(best_fitness))
@@ -0,0 +1,13 @@
1
+ """Selection strategies for genetic algorithms."""
2
+
3
+ from ctrl_freak.registry import SelectionRegistry
4
+ from ctrl_freak.selection.crowded import crowded_tournament
5
+ from ctrl_freak.selection.roulette import roulette_wheel
6
+ from ctrl_freak.selection.tournament import fitness_tournament
7
+
8
+ # Register built-in selection strategies
9
+ SelectionRegistry.register("crowded", crowded_tournament)
10
+ SelectionRegistry.register("tournament", fitness_tournament)
11
+ SelectionRegistry.register("roulette", roulette_wheel)
12
+
13
+ __all__ = ["crowded_tournament", "fitness_tournament", "roulette_wheel"]
@@ -0,0 +1,117 @@
1
+ """Crowded tournament selection for multi-objective optimization."""
2
+
3
+ import numpy as np
4
+
5
+ from ctrl_freak.population import Population
6
+
7
+
8
+ def crowded_tournament(tournament_size: int = 2):
9
+ """Create a crowded tournament parent selector.
10
+
11
+ In crowded tournament selection, individuals are compared by:
12
+ 1. Pareto rank (lower is better)
13
+ 2. If ranks are equal, crowding distance (higher is better for diversity)
14
+
15
+ Parameters
16
+ ----------
17
+ tournament_size
18
+ Number of individuals in each tournament.
19
+
20
+ Returns
21
+ -------
22
+ callable
23
+ Parent selector that returns selected parent indices.
24
+
25
+ Examples
26
+ --------
27
+ >>> import numpy as np
28
+ >>> from ctrl_freak.population import Population
29
+ >>> from ctrl_freak.selection.crowded import crowded_tournament
30
+ >>> pop = Population(x=np.zeros((4, 2)), objectives=np.zeros((4, 2)))
31
+ >>> rng = np.random.default_rng(0)
32
+ >>> rank = np.array([0, 0, 1, 1])
33
+ >>> cd = np.array([1.0, 2.0, 1.0, 2.0])
34
+ >>> selector = crowded_tournament(tournament_size=2)
35
+ >>> parents = selector(pop, 6, rng, rank=rank, crowding_distance=cd)
36
+ >>> parents.shape
37
+ (6,)
38
+ """
39
+
40
+ def selector(
41
+ pop: Population,
42
+ n_parents: int,
43
+ rng: np.random.Generator,
44
+ **kwargs: np.ndarray,
45
+ ) -> np.ndarray:
46
+ """Select parents using crowded tournament selection.
47
+
48
+ Parameters
49
+ ----------
50
+ pop
51
+ Population to select from.
52
+ n_parents
53
+ Number of parents to select.
54
+ rng
55
+ Random number generator.
56
+ **kwargs
57
+ Must include ``rank`` and ``crowding_distance`` arrays.
58
+
59
+ Returns
60
+ -------
61
+ numpy.ndarray
62
+ Selected parent indices.
63
+
64
+ Raises
65
+ ------
66
+ ValueError
67
+ If ``rank`` or ``crowding_distance`` is missing.
68
+
69
+ Examples
70
+ --------
71
+ >>> import numpy as np
72
+ >>> from ctrl_freak.population import Population
73
+ >>> from ctrl_freak.selection.crowded import crowded_tournament
74
+ >>> pop = Population(x=np.zeros((4, 1)), objectives=np.zeros((4, 2)))
75
+ >>> selector = crowded_tournament()
76
+ >>> out = selector(
77
+ ... pop,
78
+ ... 3,
79
+ ... np.random.default_rng(1),
80
+ ... rank=np.array([0, 1, 0, 1]),
81
+ ... crowding_distance=np.ones(4),
82
+ ... )
83
+ >>> out.shape
84
+ (3,)
85
+ """
86
+ # Validate required kwargs
87
+ if "rank" not in kwargs:
88
+ raise ValueError("crowded tournament selection requires 'rank' in kwargs")
89
+ if "crowding_distance" not in kwargs:
90
+ raise ValueError("crowded tournament selection requires 'crowding_distance' in kwargs")
91
+
92
+ rank = kwargs["rank"]
93
+ crowding_distance = kwargs["crowding_distance"]
94
+ pop_size = len(pop)
95
+
96
+ # Select n_parents winners via tournament
97
+ selected = np.empty(n_parents, dtype=np.intp)
98
+
99
+ for i in range(n_parents):
100
+ # Pick tournament_size random individuals
101
+ candidates = rng.integers(0, pop_size, size=tournament_size)
102
+
103
+ # Find winner: prefer lower rank, break ties with higher crowding distance
104
+ best_idx = candidates[0]
105
+ for c in candidates[1:]:
106
+ if (
107
+ rank[c] < rank[best_idx]
108
+ or rank[c] == rank[best_idx]
109
+ and crowding_distance[c] > crowding_distance[best_idx]
110
+ ):
111
+ best_idx = c
112
+
113
+ selected[i] = best_idx
114
+
115
+ return selected
116
+
117
+ return selector