Skip to main content

fugue_evo/interactive/
traits.rs

1//! Interactive fitness traits
2//!
3//! This module defines the `InteractiveFitness` trait for human-in-the-loop
4//! fitness evaluation, as well as supporting types for evaluation modes.
5
6use serde::{Deserialize, Serialize};
7
8use super::aggregation::FitnessAggregator;
9use super::evaluator::{Candidate, CandidateId, EvaluationRequest, EvaluationResponse};
10use crate::genome::traits::EvolutionaryGenome;
11
12/// Evaluation mode for interactive fitness
13///
14/// Determines how user feedback is collected during evolution.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum EvaluationMode {
17    /// User rates each candidate independently on a numeric scale
18    ///
19    /// Best for: Absolute quality assessment, when users can easily assign scores
20    Rating,
21
22    /// User compares pairs of candidates and selects the better one
23    ///
24    /// Best for: When relative comparisons are easier than absolute ratings,
25    /// provides consistent transitive preferences
26    Pairwise,
27
28    /// User selects top N favorites from a batch
29    ///
30    /// Best for: Quick evaluation of many candidates, implicit ranking
31    BatchSelection,
32
33    /// System chooses evaluation mode adaptively based on population state
34    ///
35    /// May switch between modes based on coverage, convergence, or user fatigue
36    Adaptive,
37}
38
39impl EvaluationMode {
40    /// Returns a human-readable description of this mode
41    pub fn description(&self) -> &'static str {
42        match self {
43            Self::Rating => "Rate each candidate on a numeric scale",
44            Self::Pairwise => "Compare pairs and select the better one",
45            Self::BatchSelection => "Select favorites from a batch",
46            Self::Adaptive => "System adapts evaluation method automatically",
47        }
48    }
49}
50
51impl Default for EvaluationMode {
52    fn default() -> Self {
53        Self::Rating
54    }
55}
56
57/// Trait for interactive fitness evaluation
58///
59/// Unlike the synchronous [`Fitness`](crate::fitness::traits::Fitness) trait that returns
60/// immediate values, `InteractiveFitness` generates evaluation requests that must
61/// be fulfilled by user interaction.
62///
63/// # Design
64///
65/// The trait is designed around a request/response pattern:
66/// 1. Algorithm calls `request_evaluation()` with candidates needing feedback
67/// 2. UI presents the request to the user and collects their response
68/// 3. Algorithm calls `process_response()` to update fitness estimates
69///
70/// # Example Implementation
71///
72/// ```rust,ignore
73/// use fugue_evo::interactive::prelude::*;
74///
75/// struct ArtFitness {
76///     mode: EvaluationMode,
77/// }
78///
79/// impl InteractiveFitness for ArtFitness {
80///     type Genome = MyArtGenome;
81///
82///     fn evaluation_mode(&self) -> EvaluationMode {
83///         self.mode
84///     }
85///
86///     fn request_evaluation(
87///         &self,
88///         candidates: &[Candidate<Self::Genome>],
89///     ) -> EvaluationRequest<Self::Genome> {
90///         match self.mode {
91///             EvaluationMode::Rating => {
92///                 EvaluationRequest::rate(candidates.to_vec())
93///             }
94///             EvaluationMode::BatchSelection => {
95///                 EvaluationRequest::select_from_batch(candidates.to_vec(), 3)
96///             }
97///             _ => unimplemented!()
98///         }
99///     }
100///
101///     fn process_response(
102///         &mut self,
103///         response: EvaluationResponse,
104///         aggregator: &mut FitnessAggregator,
105///     ) -> Vec<(CandidateId, f64)> {
106///         // Delegate to aggregator for standard processing
107///         aggregator.process_response(&response)
108///     }
109/// }
110/// ```
111pub trait InteractiveFitness: Send + Sync {
112    /// The genome type being evaluated
113    type Genome: EvolutionaryGenome;
114
115    /// Get the preferred evaluation mode for this fitness function
116    fn evaluation_mode(&self) -> EvaluationMode;
117
118    /// Generate an evaluation request for the given candidates
119    ///
120    /// The returned request will be presented to the user for feedback.
121    /// The implementation should select candidates appropriately for the
122    /// current evaluation mode.
123    fn request_evaluation(
124        &self,
125        candidates: &[Candidate<Self::Genome>],
126    ) -> EvaluationRequest<Self::Genome>;
127
128    /// Process user response and update fitness estimates
129    ///
130    /// Returns the updated fitness values for affected candidates.
131    /// The aggregator maintains cumulative statistics and should be
132    /// used for fitness computation.
133    fn process_response(
134        &mut self,
135        response: EvaluationResponse,
136        aggregator: &mut FitnessAggregator,
137    ) -> Vec<(CandidateId, f64)>;
138
139    /// Optional: Called at the start of each generation
140    ///
141    /// Allows the fitness function to adjust strategy based on
142    /// population state or user fatigue.
143    fn on_generation_start(&mut self, _generation: usize, _population_size: usize) {}
144
145    /// Optional: Called when an evaluation is skipped
146    ///
147    /// Allows tracking of user fatigue or disengagement.
148    fn on_evaluation_skipped(&mut self) {}
149}
150
151/// Default interactive fitness implementation using a fixed evaluation mode
152///
153/// This provides a simple implementation that delegates all processing
154/// to the fitness aggregator. Suitable for most use cases.
155#[derive(Clone, Debug)]
156pub struct DefaultInteractiveFitness<G>
157where
158    G: EvolutionaryGenome,
159{
160    mode: EvaluationMode,
161    batch_size: usize,
162    select_count: usize,
163    _marker: std::marker::PhantomData<G>,
164}
165
166impl<G> DefaultInteractiveFitness<G>
167where
168    G: EvolutionaryGenome,
169{
170    /// Create a new default interactive fitness with the given mode
171    pub fn new(mode: EvaluationMode) -> Self {
172        Self {
173            mode,
174            batch_size: 6,
175            select_count: 2,
176            _marker: std::marker::PhantomData,
177        }
178    }
179
180    /// Set the batch size for batch selection mode
181    pub fn with_batch_size(mut self, size: usize) -> Self {
182        self.batch_size = size;
183        self
184    }
185
186    /// Set how many candidates to select in batch selection mode
187    pub fn with_select_count(mut self, count: usize) -> Self {
188        self.select_count = count;
189        self
190    }
191}
192
193impl<G> Default for DefaultInteractiveFitness<G>
194where
195    G: EvolutionaryGenome,
196{
197    fn default() -> Self {
198        Self::new(EvaluationMode::Rating)
199    }
200}
201
202impl<G> InteractiveFitness for DefaultInteractiveFitness<G>
203where
204    G: EvolutionaryGenome + Clone + Send + Sync,
205{
206    type Genome = G;
207
208    fn evaluation_mode(&self) -> EvaluationMode {
209        self.mode
210    }
211
212    fn request_evaluation(
213        &self,
214        candidates: &[Candidate<Self::Genome>],
215    ) -> EvaluationRequest<Self::Genome> {
216        match self.mode {
217            EvaluationMode::Rating => EvaluationRequest::rate(candidates.to_vec()),
218            EvaluationMode::Pairwise => {
219                // Select two candidates for comparison
220                if candidates.len() >= 2 {
221                    EvaluationRequest::compare(candidates[0].clone(), candidates[1].clone())
222                } else if candidates.len() == 1 {
223                    // Fall back to rating if only one candidate
224                    EvaluationRequest::rate(candidates.to_vec())
225                } else {
226                    EvaluationRequest::rate(vec![])
227                }
228            }
229            EvaluationMode::BatchSelection => {
230                let batch: Vec<_> = candidates.iter().take(self.batch_size).cloned().collect();
231                EvaluationRequest::select_from_batch(batch, self.select_count)
232            }
233            EvaluationMode::Adaptive => {
234                // Default to rating for adaptive mode
235                EvaluationRequest::rate(candidates.to_vec())
236            }
237        }
238    }
239
240    fn process_response(
241        &mut self,
242        response: EvaluationResponse,
243        aggregator: &mut FitnessAggregator,
244    ) -> Vec<(CandidateId, f64)> {
245        aggregator.process_response(&response)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::genome::real_vector::RealVector;
253    use crate::interactive::aggregation::AggregationModel;
254
255    #[test]
256    fn test_evaluation_mode_default() {
257        assert_eq!(EvaluationMode::default(), EvaluationMode::Rating);
258    }
259
260    #[test]
261    fn test_evaluation_mode_description() {
262        assert!(!EvaluationMode::Rating.description().is_empty());
263        assert!(!EvaluationMode::Pairwise.description().is_empty());
264        assert!(!EvaluationMode::BatchSelection.description().is_empty());
265        assert!(!EvaluationMode::Adaptive.description().is_empty());
266    }
267
268    #[test]
269    fn test_default_interactive_fitness_rating() {
270        let fitness: DefaultInteractiveFitness<RealVector> =
271            DefaultInteractiveFitness::new(EvaluationMode::Rating);
272
273        let c1 = Candidate::new(CandidateId(0), RealVector::new(vec![1.0]));
274        let c2 = Candidate::new(CandidateId(1), RealVector::new(vec![2.0]));
275
276        let request = fitness.request_evaluation(&[c1, c2]);
277        match request {
278            EvaluationRequest::RateCandidates { candidates, .. } => {
279                assert_eq!(candidates.len(), 2);
280            }
281            _ => panic!("Expected RateCandidates request"),
282        }
283    }
284
285    #[test]
286    fn test_default_interactive_fitness_pairwise() {
287        let fitness: DefaultInteractiveFitness<RealVector> =
288            DefaultInteractiveFitness::new(EvaluationMode::Pairwise);
289
290        let c1 = Candidate::new(CandidateId(0), RealVector::new(vec![1.0]));
291        let c2 = Candidate::new(CandidateId(1), RealVector::new(vec![2.0]));
292
293        let request = fitness.request_evaluation(&[c1, c2]);
294        match request {
295            EvaluationRequest::PairwiseComparison { .. } => {}
296            _ => panic!("Expected PairwiseComparison request"),
297        }
298    }
299
300    #[test]
301    fn test_default_interactive_fitness_batch() {
302        let fitness: DefaultInteractiveFitness<RealVector> =
303            DefaultInteractiveFitness::new(EvaluationMode::BatchSelection)
304                .with_batch_size(4)
305                .with_select_count(2);
306
307        let candidates: Vec<_> = (0..6)
308            .map(|i| Candidate::new(CandidateId(i), RealVector::new(vec![i as f64])))
309            .collect();
310
311        let request = fitness.request_evaluation(&candidates);
312        match request {
313            EvaluationRequest::BatchSelection {
314                candidates,
315                select_count,
316                ..
317            } => {
318                assert_eq!(candidates.len(), 4); // batch_size
319                assert_eq!(select_count, 2);
320            }
321            _ => panic!("Expected BatchSelection request"),
322        }
323    }
324
325    #[test]
326    fn test_default_interactive_fitness_process_response() {
327        let mut fitness: DefaultInteractiveFitness<RealVector> =
328            DefaultInteractiveFitness::new(EvaluationMode::Rating);
329        let mut aggregator = FitnessAggregator::new(AggregationModel::DirectRating {
330            default_rating: 5.0,
331        });
332
333        let response =
334            EvaluationResponse::ratings(vec![(CandidateId(0), 8.0), (CandidateId(1), 6.0)]);
335
336        let updated = fitness.process_response(response, &mut aggregator);
337        assert_eq!(updated.len(), 2);
338    }
339}