radiate_core/evaluator.rs
1//! # Fitness Evaluators
2//!
3//! This module provides fitness evaluation strategies for genetic algorithms, offering
4//! both individual and batch evaluation approaches. Evaluators are responsible for
5//! computing fitness scores for individuals in the population using the provided problem.
6//!
7//! The module provides two main evaluation strategies:
8//! - **FitnessEvaluator**: Evaluates individuals one at a time
9//! - **BatchFitnessEvaluator**: Evaluates individuals in batches for better performance
10//!
11//! Both evaluators support parallel execution through the executor system and integrate
12//! seamlessly with the ecosystem and problem abstractions.
13
14use crate::Result;
15use crate::{Chromosome, Ecosystem, Executor, Problem};
16use std::sync::Arc;
17
18/// A trait for evaluating fitness of individuals in the ecosystem.
19///
20/// The [Evaluator] trait defines the interface for fitness evaluation strategies.
21/// Implementors can define different approaches to computing fitness scores,
22/// such as individual evaluation, batch evaluation, or specialized evaluation
23/// strategies for specific problem domains.
24///
25/// The two main implementations provided are:
26/// - [FitnessEvaluator]: Evaluates individuals one at a time
27/// - [BatchFitnessEvaluator]: Evaluates individuals in batches
28/// Custom evaluators can be created and used, however, take special note on how the
29/// members of the ecosystem are accessed and modified, (taken out of the phenotype then restored).
30/// This is important to ensure memory safety and avoid unnecessary cloning of genotypes.
31///
32/// # Generic Parameters
33/// - `C`: The chromosome type that represents the genetic material
34/// - `T`: The target type that the problem operates on
35pub trait Evaluator<C: Chromosome, T>: Send + Sync {
36 /// Evaluates the fitness of unevaluated individuals in the ecosystem.
37 ///
38 /// This method processes individuals that don't have fitness scores yet,
39 /// evaluates them using the provided problem, and updates the ecosystem
40 /// with the computed scores.
41 ///
42 /// # Arguments
43 ///
44 /// * `ecosystem` - The ecosystem containing the population
45 /// * `problem` - The problem instance used to evaluate fitness
46 ///
47 /// # Returns
48 ///
49 /// The number of individuals that were evaluated during this call
50 fn eval(&self, ecosystem: &mut Ecosystem<C>, problem: Arc<dyn Problem<C, T>>) -> Result<usize>;
51}
52
53/// A fitness evaluator that evaluates individuals one at a time.
54///
55/// `FitnessEvaluator` processes individuals individually, which is suitable for
56/// problems where individual evaluation is the most efficient approach or when
57/// you need fine-grained control over the evaluation process.
58///
59/// # Performance Characteristics
60///
61/// - **Individual Processing**: Each individual is evaluated separately
62/// - **Parallelization**: Can utilize multiple threads through the executor
63/// - **Flexibility**: Easy to implement custom evaluation logic
64pub struct FitnessEvaluator {
65 executor: Arc<Executor>,
66}
67
68impl FitnessEvaluator {
69 /// Creates a new fitness evaluator with the specified executor.
70 ///
71 /// # Note
72 ///
73 /// Choose an executor that matches your performance requirements:
74 /// - [Executor::Serial] for single-threaded execution
75 /// - [Executor::ThreadPool(n)] for parallel execution with n worker threads
76 pub fn new(executor: Arc<Executor>) -> Self {
77 Self { executor }
78 }
79}
80
81/// Implementation of [Evaluator] for [FitnessEvaluator].
82///
83/// This implementation evaluates individuals one at a time, collecting unevaluated
84/// individuals and processing them through the executor system.
85///
86/// # Algorithm
87///
88/// 1. **Collect Unevaluated Individuals**: Find individuals without fitness scores
89/// 2. **Create Evaluation Jobs**: Package genotypes and indices into jobs
90/// 3. **Execute Jobs**: Run evaluations through the executor
91/// 4. **Update Ecosystem**: Store computed scores and restore genotypes
92///
93/// # Performance Optimizations
94///
95/// - **Efficient Job Creation**: Jobs are created with minimal allocation
96/// - **Batch Execution**: Multiple jobs are submitted to the executor at once
97/// - **Memory Reuse**: Genotypes are restored to avoid unnecessary cloning
98impl<C: Chromosome, T> Evaluator<C, T> for FitnessEvaluator
99where
100 C: Chromosome + 'static,
101 T: 'static,
102{
103 #[inline]
104 fn eval(&self, ecosystem: &mut Ecosystem<C>, problem: Arc<dyn Problem<C, T>>) -> Result<usize> {
105 let mut jobs = Vec::new();
106 let len = ecosystem.population.len();
107 for idx in 0..len {
108 if ecosystem.population[idx].score().is_none() {
109 let geno = ecosystem.population[idx].take_genotype()?;
110 jobs.push((idx, geno));
111 }
112 }
113
114 let results = self.executor.execute_batch(
115 jobs.into_iter()
116 .map(|(idx, geno)| {
117 let problem = Arc::clone(&problem);
118 move || {
119 let score = problem.eval(&geno);
120 (idx, score, geno)
121 }
122 })
123 .collect::<Vec<_>>(),
124 );
125
126 let count = results.len();
127 for result in results {
128 let (idx, score, genotype) = result;
129 ecosystem.population[idx].set_score(Some(score?));
130 ecosystem.population[idx].set_genotype(genotype);
131 }
132
133 Ok(count)
134 }
135}
136
137/// Default implementation for [FitnessEvaluator].
138///
139/// Creates a fitness evaluator with a serial executor, which is suitable for
140/// single-threaded applications or when parallel execution is not needed.
141///
142/// # Note
143///
144/// The default executor is [Executor::Serial], which processes evaluations
145/// sequentially. For better performance with large populations, consider using
146/// [variant@Executor::ThreadPool(n)] with an appropriate number of worker threads
147/// or [Executor::WorkerPool] (rayon feature required).
148impl Default for FitnessEvaluator {
149 fn default() -> Self {
150 Self {
151 executor: Arc::new(Executor::Serial),
152 }
153 }
154}
155
156/// A fitness evaluator that evaluates individuals in batches for improved performance or
157/// for when you need access to parts or the whole of an unevaluated population in order
158/// to compute fitness.
159///
160/// [BatchFitnessEvaluator] groups individuals into batches and evaluates them
161/// together, which can be more efficient than individual evaluation, especially
162/// when the problem supports batch evaluation or when there are significant
163/// overhead costs per evaluation.
164///
165/// # Performance Characteristics
166///
167/// - **Batch Processing**: Multiple individuals evaluated together
168/// - **Reduced Overhead**: Sometimes lower per-individual evaluation cost
169///
170/// # Use Cases
171///
172/// Use [BatchFitnessEvaluator] when:
173/// - The problem supports efficient batch evaluation
174/// - You have a large population that benefits from parallelization
175/// - Individual evaluation overhead is significant
176/// - You need access to parts or the whole of an unevaluated population in order to compute fitness.
177///
178/// # Batch Size Calculation
179///
180/// In order to ensure the optimal distribution of work across available threads,
181/// batch size is automatically calculated based on the number of workers
182/// in the executor and the total number of individuals to evaluate:
183///
184/// ```text
185/// batch_size = (total_individuals + num_workers - 1) / num_workers
186/// ```
187/// So, for example, if you have 100 individuals and 4 workers, the batch size would be:
188///
189/// ```text
190/// batch_size = (100 + 4 - 1) / 4 = 25
191/// ```
192///
193/// But, if your executor is [Executor::Serial], then the batch size will simply be the total number of
194/// individuals to evaluate, resulting in a single batch.
195pub struct BatchFitnessEvaluator {
196 executor: Arc<Executor>,
197}
198
199impl BatchFitnessEvaluator {
200 /// Creates a new batch fitness evaluator with the specified executor.
201 ///
202 /// # Note
203 ///
204 /// Batch evaluation works best with parallel executors that have multiple
205 /// worker threads. The evaluator will automatically divide work into optimal
206 /// batch sizes based on the number of available workers.
207 pub fn new(executor: Arc<Executor>) -> Self {
208 Self { executor }
209 }
210}
211
212/// Implementation of [Evaluator] for [BatchFitnessEvaluator].
213///
214/// This implementation groups individuals into batches and evaluates them
215/// together, which can significantly improve performance for large populations
216/// or when the problem supports efficient batch evaluation.
217///
218/// # Algorithm
219///
220/// The algorithm largely follows the same steps as `FitnessEvaluator`:
221/// 1. **Collect Unevaluated Individuals**: Find individuals without fitness scores
222/// 2. **Calculate Batch Size**: Determine optimal batch size based on worker count
223/// 3. **Create Batches**: Group individuals into batches for parallel processing
224/// 4. **Execute Batches**: Run batch evaluations through the executor
225/// 5. **Update Ecosystem**: Store computed scores and restore genotypes
226///
227/// # Batch Size Optimization
228///
229/// The batch size is calculated to ensure optimal work distribution:
230/// - **Small Batches**: Ensure all workers have work to do
231/// - **Large Batches**: Minimize overhead from job creation and distribution
232/// - **Balanced Distribution**: Work is evenly distributed across available threads
233///
234/// # Performance Optimizations
235///
236/// - **Efficient Batching**: Batches are created with minimal memory allocation
237/// - **Parallel Execution**: Multiple batches are processed simultaneously
238/// - **Memory Management**: Genotypes are efficiently restored after evaluation
239impl<C: Chromosome, T> Evaluator<C, T> for BatchFitnessEvaluator
240where
241 C: Chromosome + 'static,
242 T: 'static,
243{
244 #[inline]
245 fn eval(&self, ecosystem: &mut Ecosystem<C>, problem: Arc<dyn Problem<C, T>>) -> Result<usize> {
246 let mut pairs = Vec::new();
247 let len = ecosystem.population.len();
248 for idx in 0..len {
249 if ecosystem.population[idx].score().is_none() {
250 let geno = ecosystem.population[idx].take_genotype()?;
251 pairs.push((idx, geno));
252 }
253 }
254
255 let num_workers = self.executor.num_workers();
256 let batch_size = (pairs.len() + num_workers - 1) / num_workers;
257
258 if pairs.is_empty() || batch_size == 0 {
259 return Ok(0);
260 }
261
262 let mut batches = Vec::with_capacity(num_workers);
263
264 while !pairs.is_empty() {
265 let take = pairs.len().min(batch_size);
266
267 let mut batch_indices = Vec::with_capacity(take);
268 let mut batch_genotypes = Vec::with_capacity(take);
269
270 // drain from the end of pairs vector to avoid O(n^2) complexity
271 for (idx, geno) in pairs.drain(pairs.len() - take..) {
272 batch_indices.push(idx);
273 batch_genotypes.push(geno);
274 }
275
276 batches.push((batch_indices, batch_genotypes));
277 }
278
279 let results = self.executor.execute_batch(
280 batches
281 .into_iter()
282 .map(|batch| {
283 let problem = Arc::clone(&problem);
284 move || {
285 let scores = problem.eval_batch(&batch.1);
286 (batch.0, scores, batch.1)
287 }
288 })
289 .collect(),
290 );
291
292 let mut count = 0;
293 for (indices, scores, genotypes) in results {
294 count += indices.len();
295 let score_genotype_iter = scores?.into_iter().zip(genotypes.into_iter());
296 for (i, (score, genotype)) in score_genotype_iter.enumerate() {
297 let idx = indices[i];
298 ecosystem.population[idx].set_score(Some(score));
299 ecosystem.population[idx].set_genotype(genotype);
300 }
301 }
302
303 Ok(count)
304 }
305}