Skip to main content

fugue_evo/interactive/
evaluator.rs

1//! Core types for interactive evaluation
2//!
3//! This module defines the request/response types used for human-in-the-loop
4//! fitness evaluation in interactive genetic algorithms.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::hash::{Hash, Hasher};
9
10use super::uncertainty::FitnessEstimate;
11use crate::genome::traits::EvolutionaryGenome;
12
13/// Unique identifier for a candidate in an interactive session
14///
15/// Each candidate is assigned a unique ID when created, which remains
16/// stable across generations. This allows tracking evaluation history
17/// and aggregating feedback over time.
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
19pub struct CandidateId(pub usize);
20
21impl fmt::Display for CandidateId {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        write!(f, "Candidate({})", self.0)
24    }
25}
26
27impl From<usize> for CandidateId {
28    fn from(id: usize) -> Self {
29        Self(id)
30    }
31}
32
33impl From<CandidateId> for usize {
34    fn from(id: CandidateId) -> Self {
35        id.0
36    }
37}
38
39/// A candidate presented for user evaluation
40///
41/// Wraps a genome with its unique identifier and current fitness estimate.
42/// The fitness estimate is updated as user feedback is received and aggregated.
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[serde(bound = "G: Serialize + for<'a> Deserialize<'a>")]
45pub struct Candidate<G>
46where
47    G: EvolutionaryGenome,
48{
49    /// Unique identifier for this candidate
50    pub id: CandidateId,
51    /// The genome of this candidate
52    pub genome: G,
53    /// Current fitness estimate (updated as feedback arrives)
54    pub fitness_estimate: Option<f64>,
55    /// Full fitness estimate with uncertainty quantification
56    #[serde(default)]
57    pub fitness_with_uncertainty: Option<FitnessEstimate>,
58    /// Generation when this candidate was created
59    pub birth_generation: usize,
60    /// Number of times this candidate has been evaluated
61    pub evaluation_count: usize,
62}
63
64impl<G> Candidate<G>
65where
66    G: EvolutionaryGenome,
67{
68    /// Create a new candidate with the given ID and genome
69    pub fn new(id: CandidateId, genome: G) -> Self {
70        Self {
71            id,
72            genome,
73            fitness_estimate: None,
74            fitness_with_uncertainty: None,
75            birth_generation: 0,
76            evaluation_count: 0,
77        }
78    }
79
80    /// Create a new candidate with birth generation
81    pub fn with_generation(id: CandidateId, genome: G, generation: usize) -> Self {
82        Self {
83            id,
84            genome,
85            fitness_estimate: None,
86            fitness_with_uncertainty: None,
87            birth_generation: generation,
88            evaluation_count: 0,
89        }
90    }
91
92    /// Set the fitness estimate (point estimate only)
93    pub fn set_fitness(&mut self, fitness: f64) {
94        self.fitness_estimate = Some(fitness);
95    }
96
97    /// Set the full fitness estimate with uncertainty
98    pub fn set_fitness_with_uncertainty(&mut self, estimate: FitnessEstimate) {
99        self.fitness_estimate = Some(estimate.mean);
100        self.fitness_with_uncertainty = Some(estimate);
101    }
102
103    /// Get the fitness estimate (point estimate), if available
104    pub fn fitness(&self) -> Option<f64> {
105        self.fitness_estimate
106    }
107
108    /// Get the full fitness estimate with uncertainty, if available
109    pub fn fitness_uncertainty(&self) -> Option<&FitnessEstimate> {
110        self.fitness_with_uncertainty.as_ref()
111    }
112
113    /// Get the variance of the fitness estimate, if available
114    pub fn fitness_variance(&self) -> Option<f64> {
115        self.fitness_with_uncertainty.as_ref().map(|e| e.variance)
116    }
117
118    /// Check if this candidate has been evaluated at least once
119    pub fn is_evaluated(&self) -> bool {
120        self.evaluation_count > 0
121    }
122
123    /// Increment the evaluation count
124    pub fn record_evaluation(&mut self) {
125        self.evaluation_count += 1;
126    }
127
128    /// Get the age of this candidate (generations since birth)
129    pub fn age(&self, current_generation: usize) -> usize {
130        current_generation.saturating_sub(self.birth_generation)
131    }
132
133    /// Check if this candidate has high uncertainty (needs more evaluation)
134    ///
135    /// Returns true if variance is infinite or observation count is below threshold.
136    pub fn is_uncertain(&self, min_observations: usize) -> bool {
137        self.fitness_with_uncertainty
138            .as_ref()
139            .map(|e| e.is_uncertain(min_observations))
140            .unwrap_or(true) // No estimate = uncertain
141    }
142}
143
144impl<G> PartialEq for Candidate<G>
145where
146    G: EvolutionaryGenome,
147{
148    fn eq(&self, other: &Self) -> bool {
149        self.id == other.id
150    }
151}
152
153impl<G> Eq for Candidate<G> where G: EvolutionaryGenome {}
154
155impl<G> Hash for Candidate<G>
156where
157    G: EvolutionaryGenome,
158{
159    fn hash<H: Hasher>(&self, state: &mut H) {
160        self.id.hash(state);
161    }
162}
163
164/// Rating scale configuration for numeric ratings
165///
166/// Defines the valid range and behavior for user ratings.
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
168pub struct RatingScale {
169    /// Minimum rating value
170    pub min: f64,
171    /// Maximum rating value
172    pub max: f64,
173    /// Whether ties are allowed (for pairwise comparisons)
174    pub allow_ties: bool,
175    /// Optional step size (e.g., 0.5 for half-star ratings)
176    pub step: Option<f64>,
177}
178
179impl RatingScale {
180    /// Create a new rating scale
181    pub fn new(min: f64, max: f64) -> Self {
182        Self {
183            min,
184            max,
185            allow_ties: true,
186            step: None,
187        }
188    }
189
190    /// Standard 1-10 rating scale
191    pub fn one_to_ten() -> Self {
192        Self {
193            min: 1.0,
194            max: 10.0,
195            allow_ties: true,
196            step: Some(1.0),
197        }
198    }
199
200    /// Standard 1-5 rating scale (5-star rating)
201    pub fn one_to_five() -> Self {
202        Self {
203            min: 1.0,
204            max: 5.0,
205            allow_ties: true,
206            step: Some(1.0),
207        }
208    }
209
210    /// Binary like/dislike scale
211    pub fn binary() -> Self {
212        Self {
213            min: 0.0,
214            max: 1.0,
215            allow_ties: false,
216            step: Some(1.0),
217        }
218    }
219
220    /// Set whether ties are allowed
221    pub fn with_ties(mut self, allow: bool) -> Self {
222        self.allow_ties = allow;
223        self
224    }
225
226    /// Set the step size for discrete ratings
227    pub fn with_step(mut self, step: f64) -> Self {
228        self.step = Some(step);
229        self
230    }
231
232    /// Validate a rating against this scale
233    pub fn validate(&self, rating: f64) -> bool {
234        if rating < self.min || rating > self.max {
235            return false;
236        }
237        if let Some(step) = self.step {
238            // Check if rating is a valid step from min
239            let steps_from_min = (rating - self.min) / step;
240            (steps_from_min - steps_from_min.round()).abs() < 1e-9
241        } else {
242            true
243        }
244    }
245
246    /// Clamp a rating to the valid range
247    pub fn clamp(&self, rating: f64) -> f64 {
248        rating.clamp(self.min, self.max)
249    }
250
251    /// Normalize a rating to [0, 1] range
252    pub fn normalize(&self, rating: f64) -> f64 {
253        (rating - self.min) / (self.max - self.min)
254    }
255
256    /// Denormalize a [0, 1] value to this scale
257    pub fn denormalize(&self, normalized: f64) -> f64 {
258        normalized * (self.max - self.min) + self.min
259    }
260}
261
262impl Default for RatingScale {
263    fn default() -> Self {
264        Self::one_to_ten()
265    }
266}
267
268/// Evaluation request sent to the user
269///
270/// Represents a request for user feedback on one or more candidates.
271/// The type of feedback requested depends on the variant.
272#[derive(Clone, Debug, Serialize, Deserialize)]
273#[serde(bound = "G: Serialize + for<'a> Deserialize<'a>")]
274pub enum EvaluationRequest<G>
275where
276    G: EvolutionaryGenome,
277{
278    /// Rate individual candidates on a numeric scale
279    ///
280    /// User assigns a rating to each candidate. Ratings may be partial
281    /// (not all candidates need to be rated).
282    RateCandidates {
283        /// Candidates to rate
284        candidates: Vec<Candidate<G>>,
285        /// Rating scale to use
286        scale: RatingScale,
287    },
288
289    /// Compare two candidates - which is better?
290    ///
291    /// User selects the preferred candidate, or indicates a tie
292    /// (if allowed by the scale).
293    PairwiseComparison {
294        /// First candidate
295        candidate_a: Candidate<G>,
296        /// Second candidate
297        candidate_b: Candidate<G>,
298        /// Whether ties are allowed
299        allow_tie: bool,
300    },
301
302    /// Select N favorites from a batch
303    ///
304    /// User selects their top candidates from the presented set.
305    /// Selection implies preference over non-selected candidates.
306    BatchSelection {
307        /// Candidates to choose from
308        candidates: Vec<Candidate<G>>,
309        /// Number of candidates to select
310        select_count: usize,
311        /// Minimum number required (for partial selections)
312        min_select: usize,
313    },
314}
315
316impl<G> EvaluationRequest<G>
317where
318    G: EvolutionaryGenome,
319{
320    /// Create a rate candidates request with default scale
321    pub fn rate(candidates: Vec<Candidate<G>>) -> Self {
322        Self::RateCandidates {
323            candidates,
324            scale: RatingScale::default(),
325        }
326    }
327
328    /// Create a rate candidates request with custom scale
329    pub fn rate_with_scale(candidates: Vec<Candidate<G>>, scale: RatingScale) -> Self {
330        Self::RateCandidates { candidates, scale }
331    }
332
333    /// Create a pairwise comparison request
334    pub fn compare(a: Candidate<G>, b: Candidate<G>) -> Self {
335        Self::PairwiseComparison {
336            candidate_a: a,
337            candidate_b: b,
338            allow_tie: true,
339        }
340    }
341
342    /// Create a batch selection request
343    pub fn select_from_batch(candidates: Vec<Candidate<G>>, select_count: usize) -> Self {
344        Self::BatchSelection {
345            candidates,
346            select_count,
347            min_select: 1,
348        }
349    }
350
351    /// Get the number of candidates in this request
352    pub fn candidate_count(&self) -> usize {
353        match self {
354            Self::RateCandidates { candidates, .. } => candidates.len(),
355            Self::PairwiseComparison { .. } => 2,
356            Self::BatchSelection { candidates, .. } => candidates.len(),
357        }
358    }
359
360    /// Get all candidate IDs in this request
361    pub fn candidate_ids(&self) -> Vec<CandidateId> {
362        match self {
363            Self::RateCandidates { candidates, .. } => candidates.iter().map(|c| c.id).collect(),
364            Self::PairwiseComparison {
365                candidate_a,
366                candidate_b,
367                ..
368            } => vec![candidate_a.id, candidate_b.id],
369            Self::BatchSelection { candidates, .. } => candidates.iter().map(|c| c.id).collect(),
370        }
371    }
372}
373
374/// User response to an evaluation request
375///
376/// Contains the user's feedback on the presented candidates.
377#[derive(Clone, Debug, Serialize, Deserialize)]
378pub enum EvaluationResponse {
379    /// Ratings for candidates
380    ///
381    /// May be partial (not all candidates rated). Each entry is (candidate_id, rating).
382    Ratings(Vec<(CandidateId, f64)>),
383
384    /// Winner of pairwise comparison
385    ///
386    /// `None` indicates a tie (if allowed).
387    PairwiseWinner(Option<CandidateId>),
388
389    /// Selected candidates from batch
390    ///
391    /// IDs of selected candidates. Order may indicate preference ranking.
392    BatchSelected(Vec<CandidateId>),
393
394    /// User chose to skip this evaluation
395    ///
396    /// No feedback provided for this request.
397    Skip,
398}
399
400impl EvaluationResponse {
401    /// Create a ratings response
402    pub fn ratings(ratings: Vec<(CandidateId, f64)>) -> Self {
403        Self::Ratings(ratings)
404    }
405
406    /// Create a pairwise winner response
407    pub fn winner(id: CandidateId) -> Self {
408        Self::PairwiseWinner(Some(id))
409    }
410
411    /// Create a tie response for pairwise comparison
412    pub fn tie() -> Self {
413        Self::PairwiseWinner(None)
414    }
415
416    /// Create a batch selection response
417    pub fn selected(ids: Vec<CandidateId>) -> Self {
418        Self::BatchSelected(ids)
419    }
420
421    /// Create a skip response
422    pub fn skip() -> Self {
423        Self::Skip
424    }
425
426    /// Check if this is a skip response
427    pub fn is_skip(&self) -> bool {
428        matches!(self, Self::Skip)
429    }
430
431    /// Get the candidate IDs mentioned in this response
432    pub fn mentioned_ids(&self) -> Vec<CandidateId> {
433        match self {
434            Self::Ratings(ratings) => ratings.iter().map(|(id, _)| *id).collect(),
435            Self::PairwiseWinner(Some(id)) => vec![*id],
436            Self::PairwiseWinner(None) => vec![],
437            Self::BatchSelected(ids) => ids.clone(),
438            Self::Skip => vec![],
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::genome::real_vector::RealVector;
447
448    #[test]
449    fn test_candidate_id() {
450        let id1 = CandidateId(0);
451        let id2 = CandidateId(1);
452        let id3 = CandidateId(0);
453
454        assert_eq!(id1, id3);
455        assert_ne!(id1, id2);
456        assert_eq!(format!("{}", id1), "Candidate(0)");
457    }
458
459    #[test]
460    fn test_candidate_creation() {
461        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
462        let candidate: Candidate<RealVector> = Candidate::new(CandidateId(0), genome);
463
464        assert_eq!(candidate.id, CandidateId(0));
465        assert!(candidate.fitness_estimate.is_none());
466        assert!(!candidate.is_evaluated());
467        assert_eq!(candidate.evaluation_count, 0);
468    }
469
470    #[test]
471    fn test_candidate_evaluation() {
472        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
473        let mut candidate: Candidate<RealVector> = Candidate::new(CandidateId(0), genome);
474
475        candidate.set_fitness(7.5);
476        candidate.record_evaluation();
477
478        assert_eq!(candidate.fitness(), Some(7.5));
479        assert!(candidate.is_evaluated());
480        assert_eq!(candidate.evaluation_count, 1);
481    }
482
483    #[test]
484    fn test_rating_scale_validation() {
485        let scale = RatingScale::one_to_ten();
486
487        assert!(scale.validate(1.0));
488        assert!(scale.validate(5.0));
489        assert!(scale.validate(10.0));
490        assert!(!scale.validate(0.0));
491        assert!(!scale.validate(11.0));
492        assert!(!scale.validate(5.5)); // Step is 1.0
493    }
494
495    #[test]
496    fn test_rating_scale_normalization() {
497        let scale = RatingScale::one_to_ten();
498
499        assert!((scale.normalize(1.0) - 0.0).abs() < 1e-9);
500        assert!((scale.normalize(5.5) - 0.5).abs() < 1e-9);
501        assert!((scale.normalize(10.0) - 1.0).abs() < 1e-9);
502
503        assert!((scale.denormalize(0.0) - 1.0).abs() < 1e-9);
504        assert!((scale.denormalize(0.5) - 5.5).abs() < 1e-9);
505        assert!((scale.denormalize(1.0) - 10.0).abs() < 1e-9);
506    }
507
508    #[test]
509    fn test_evaluation_request_rate() {
510        let c1: Candidate<RealVector> = Candidate::new(CandidateId(0), RealVector::new(vec![1.0]));
511        let c2: Candidate<RealVector> = Candidate::new(CandidateId(1), RealVector::new(vec![2.0]));
512
513        let request = EvaluationRequest::rate(vec![c1, c2]);
514        assert_eq!(request.candidate_count(), 2);
515        assert_eq!(
516            request.candidate_ids(),
517            vec![CandidateId(0), CandidateId(1)]
518        );
519    }
520
521    #[test]
522    fn test_evaluation_request_compare() {
523        let c1: Candidate<RealVector> = Candidate::new(CandidateId(0), RealVector::new(vec![1.0]));
524        let c2: Candidate<RealVector> = Candidate::new(CandidateId(1), RealVector::new(vec![2.0]));
525
526        let request = EvaluationRequest::compare(c1, c2);
527        assert_eq!(request.candidate_count(), 2);
528    }
529
530    #[test]
531    fn test_evaluation_response() {
532        let response = EvaluationResponse::winner(CandidateId(0));
533        assert_eq!(response.mentioned_ids(), vec![CandidateId(0)]);
534
535        let response = EvaluationResponse::tie();
536        assert!(response.mentioned_ids().is_empty());
537
538        let response = EvaluationResponse::skip();
539        assert!(response.is_skip());
540    }
541}