diff --git a/examples/interactive_evolution.rs b/examples/interactive_evolution.rs new file mode 100644 index 0000000..338ec7f --- /dev/null +++ b/examples/interactive_evolution.rs @@ -0,0 +1,244 @@ +//! Interactive Genetic Algorithm Example +//! +//! This example demonstrates how to use the Interactive GA for human-in-the-loop +//! evolutionary optimization. Instead of an automated fitness function, users +//! provide feedback by rating, comparing, or selecting candidates. +//! +//! In this example, we simulate user feedback with a simple automated scorer, +//! but in a real application, you would present candidates to users via a UI. + +use fugue_evo::genome::bounds::Bounds; +use fugue_evo::interactive::prelude::*; +use fugue_evo::prelude::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +/// Simulates user preference for solutions close to a target +struct SimulatedUserPreference { + target: Vec, +} + +impl SimulatedUserPreference { + fn new(dim: usize) -> Self { + // User prefers solutions where values are around 0.5 + Self { + target: vec![0.5; dim], + } + } + + /// Simulate a user rating (1-10 scale) + fn rate(&self, genome: &RealVector) -> f64 { + let distance: f64 = genome + .genes() + .iter() + .zip(self.target.iter()) + .map(|(g, t)| (g - t).powi(2)) + .sum::() + .sqrt(); + + // Convert distance to rating (closer = higher rating) + let rating = 10.0 - (distance * 5.0).min(9.0); + rating.max(1.0) + } + + /// Simulate pairwise comparison + fn compare(&self, a: &RealVector, b: &RealVector) -> std::cmp::Ordering { + let rating_a = self.rate(a); + let rating_b = self.rate(b); + rating_a.partial_cmp(&rating_b).unwrap() + } +} + +fn main() -> Result<(), Box> { + println!("=== Interactive Genetic Algorithm Demo ===\n"); + + let mut rng = StdRng::seed_from_u64(42); + + const DIM: usize = 5; + let bounds = MultiBounds::uniform(Bounds::new(0.0, 1.0), DIM); + + // Create the simulated user preference + let user = SimulatedUserPreference::new(DIM); + + // Build the Interactive GA + let mut iga = InteractiveGABuilder::::new() + .population_size(12) + .elitism_count(2) + .evaluation_mode(EvaluationMode::Rating) + .batch_size(4) + .min_coverage(0.8) + .max_generations(5) + .aggregation_model(AggregationModel::DirectRating { + default_rating: 5.0, + }) + .bounds(bounds) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build()?; + + println!("Starting interactive evolution...\n"); + println!( + "Configuration: {} individuals, {} mode, {} generations max", + iga.config().population_size, + match iga.config().evaluation_mode { + EvaluationMode::Rating => "rating", + EvaluationMode::Pairwise => "pairwise", + EvaluationMode::BatchSelection => "batch selection", + EvaluationMode::Adaptive => "adaptive", + }, + iga.config().max_generations + ); + println!(); + + // Main evolution loop + loop { + match iga.step(&mut rng) { + StepResult::NeedsEvaluation(request) => { + // In a real app, you'd present this to a user via UI + // Here we simulate user feedback + let response = simulate_user_response(&user, &request); + iga.provide_response(response); + } + + StepResult::GenerationComplete { + generation, + best_fitness, + coverage, + } => { + println!( + "Generation {} complete: best = {:.2}, coverage = {:.0}%", + generation, + best_fitness.unwrap_or(0.0), + coverage * 100.0 + ); + } + + StepResult::Complete(result) => { + println!("\n=== Evolution Complete ==="); + println!("Reason: {}", result.termination_reason); + println!("Generations: {}", result.generations); + println!("Total evaluations: {}", result.total_evaluations); + println!("\nTop 3 candidates:"); + + for (i, candidate) in result.best_candidates.iter().take(3).enumerate() { + println!( + " #{}: fitness = {:.2}, genes = {:?}", + i + 1, + candidate.fitness_estimate.unwrap_or(0.0), + candidate + .genome + .genes() + .iter() + .map(|g| format!("{:.3}", g)) + .collect::>() + .join(", ") + ); + } + break; + } + } + } + + // Demonstrate different evaluation modes + println!("\n=== Batch Selection Mode Demo ===\n"); + + let mut iga_batch = InteractiveGABuilder::::new() + .population_size(12) + .evaluation_mode(EvaluationMode::BatchSelection) + .batch_size(6) + .select_count(2) + .min_coverage(0.5) + .max_generations(3) + .aggregation_model(AggregationModel::ImplicitRanking { + selected_bonus: 1.0, + not_selected_penalty: 0.3, + base_fitness: 5.0, + }) + .bounds(MultiBounds::uniform(Bounds::new(0.0, 1.0), DIM)) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build()?; + + loop { + match iga_batch.step(&mut rng) { + StepResult::NeedsEvaluation(request) => { + let response = simulate_user_response(&user, &request); + iga_batch.provide_response(response); + } + + StepResult::GenerationComplete { + generation, + best_fitness, + .. + } => { + println!( + "Generation {}: best = {:.2}", + generation, + best_fitness.unwrap_or(0.0) + ); + } + + StepResult::Complete(result) => { + println!("\nBatch selection mode complete!"); + println!( + "Best fitness: {:.2}", + result.best_candidates[0].fitness_estimate.unwrap_or(0.0) + ); + break; + } + } + } + + Ok(()) +} + +/// Simulate user response to an evaluation request +fn simulate_user_response( + user: &SimulatedUserPreference, + request: &EvaluationRequest, +) -> EvaluationResponse { + match request { + EvaluationRequest::RateCandidates { candidates, .. } => { + let ratings: Vec<_> = candidates + .iter() + .map(|c| (c.id, user.rate(&c.genome))) + .collect(); + EvaluationResponse::ratings(ratings) + } + + EvaluationRequest::PairwiseComparison { + candidate_a, + candidate_b, + .. + } => { + use std::cmp::Ordering; + match user.compare(&candidate_a.genome, &candidate_b.genome) { + Ordering::Greater => EvaluationResponse::winner(candidate_a.id), + Ordering::Less => EvaluationResponse::winner(candidate_b.id), + Ordering::Equal => EvaluationResponse::tie(), + } + } + + EvaluationRequest::BatchSelection { + candidates, + select_count, + .. + } => { + // Sort by rating and select top N + let mut rated: Vec<_> = candidates + .iter() + .map(|c| (c.id, user.rate(&c.genome))) + .collect(); + rated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + let selected: Vec<_> = rated + .iter() + .take(*select_count) + .map(|(id, _)| *id) + .collect(); + EvaluationResponse::selected(selected) + } + } +} diff --git a/src/checkpoint/state.rs b/src/checkpoint/state.rs index 3a02459..5545109 100644 --- a/src/checkpoint/state.rs +++ b/src/checkpoint/state.rs @@ -135,6 +135,15 @@ pub enum AlgorithmState { island_populations: Vec>, migration_count: usize, }, + /// Interactive GA state + Interactive { + /// Serialized aggregator state (JSON) + aggregator_state: String, + /// Number of pending evaluations + pending_evaluations: usize, + /// Evaluation mode + evaluation_mode: String, + }, /// Custom algorithm state (JSON serialized) Custom(String), } diff --git a/src/error.rs b/src/error.rs index 1541a21..f58a65c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -121,6 +121,19 @@ pub enum EvolutionError { /// Empty population #[error("Empty population")] EmptyPopulation, + + /// Interactive evaluation error + #[error("Interactive evaluation error: {0}")] + InteractiveEvaluation(String), + + /// Insufficient evaluation coverage + #[error("Insufficient coverage: {coverage:.1}% (need {required:.1}%)")] + InsufficientCoverage { + /// Actual coverage achieved + coverage: f64, + /// Required coverage threshold + required: f64, + }, } /// Result type alias for evolution operations diff --git a/src/interactive/aggregation.rs b/src/interactive/aggregation.rs new file mode 100644 index 0000000..2e1967c --- /dev/null +++ b/src/interactive/aggregation.rs @@ -0,0 +1,995 @@ +//! Fitness aggregation models for interactive evaluation +//! +//! This module provides various statistical models for converting user feedback +//! (ratings, comparisons, selections) into fitness values suitable for evolution. +//! +//! # Available Models +//! +//! - **DirectRating**: Simple average of user ratings +//! - **Elo**: Classic Elo rating system from pairwise comparisons +//! - **BradleyTerry**: Maximum likelihood estimation for pairwise data +//! - **ImplicitRanking**: Bonus/penalty system from batch selections +//! +//! # Uncertainty Quantification +//! +//! All models support uncertainty estimation via `get_fitness_estimate()`, +//! which returns a `FitnessEstimate` with variance and confidence intervals. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer}; +use super::evaluator::{CandidateId, EvaluationResponse}; +use super::uncertainty::FitnessEstimate; + +/// Aggregation model for converting user feedback to fitness +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum AggregationModel { + /// Direct rating average + /// + /// Simply averages all ratings received for each candidate. + /// Uses default_rating for candidates with no ratings. + DirectRating { + /// Default rating for unevaluated candidates + default_rating: f64, + }, + + /// Elo rating system + /// + /// Classic chess-style rating from pairwise comparisons. + /// Good for transitive preference modeling. + Elo { + /// Initial rating for new candidates + initial_rating: f64, + /// K-factor controlling rating volatility + k_factor: f64, + }, + + /// Bradley-Terry model + /// + /// Maximum likelihood estimation for pairwise comparison data. + /// Provides more statistically principled estimates than Elo. + /// Now supports proper MLE with Newton-Raphson or MM algorithms. + BradleyTerry { + /// Initial strength parameter + initial_strength: f64, + /// Optimizer configuration (Newton-Raphson or MM) + #[serde(default)] + optimizer: BradleyTerryOptimizer, + }, + + /// Legacy Bradley-Terry model (for backward compatibility) + /// + /// Uses the simplified iterative MM approach from earlier versions. + #[serde(alias = "BradleyTerryLegacy")] + BradleyTerrySimple { + /// Initial strength parameter + initial_strength: f64, + /// Learning rate for iterative updates + learning_rate: f64, + /// Number of iterations + iterations: usize, + }, + + /// Implicit ranking from batch selections + /// + /// Assigns bonuses to selected candidates and penalties to + /// non-selected candidates in each batch. + ImplicitRanking { + /// Fitness bonus for being selected + selected_bonus: f64, + /// Fitness penalty for not being selected + not_selected_penalty: f64, + /// Base fitness for all candidates + base_fitness: f64, + }, +} + +impl Default for AggregationModel { + fn default() -> Self { + Self::DirectRating { + default_rating: 5.0, + } + } +} + +/// Statistics tracked for each candidate +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct CandidateStats { + /// Sum of all ratings received + pub rating_sum: f64, + /// Sum of squared ratings (for variance calculation) + #[serde(default)] + pub rating_sum_squares: f64, + /// Count of ratings received + pub rating_count: usize, + /// Current model-based score (Elo, Bradley-Terry strength, etc.) + pub model_score: f64, + /// Variance of the model score (for uncertainty quantification) + #[serde(default = "default_variance")] + pub model_variance: f64, + /// Number of wins in pairwise comparisons + pub wins: usize, + /// Number of losses in pairwise comparisons + pub losses: usize, + /// Number of ties in pairwise comparisons + pub ties: usize, + /// Times selected in batch selection + pub times_selected: usize, + /// Times presented but not selected + pub times_passed: usize, +} + +fn default_variance() -> f64 { + f64::INFINITY +} + +impl CandidateStats { + /// Create new stats with the given initial model score + pub fn new(initial_score: f64) -> Self { + Self { + model_score: initial_score, + model_variance: f64::INFINITY, + ..Default::default() + } + } + + /// Get the average rating (or None if no ratings) + pub fn average_rating(&self) -> Option { + if self.rating_count > 0 { + Some(self.rating_sum / self.rating_count as f64) + } else { + None + } + } + + /// Get the sample variance of ratings + pub fn rating_variance(&self) -> Option { + if self.rating_count < 2 { + return None; + } + let n = self.rating_count as f64; + let mean = self.rating_sum / n; + // Var = E[X²] - E[X]² + let var = (self.rating_sum_squares / n) - (mean * mean); + // Convert to sample variance (Bessel's correction) + Some(var * n / (n - 1.0)) + } + + /// Get the variance of the mean (standard error squared) + pub fn rating_variance_of_mean(&self) -> Option { + self.rating_variance() + .map(|var| var / self.rating_count as f64) + } + + /// Get total number of comparisons + pub fn total_comparisons(&self) -> usize { + self.wins + self.losses + self.ties + } + + /// Get win rate (0.0 to 1.0) + pub fn win_rate(&self) -> Option { + let total = self.total_comparisons(); + if total > 0 { + Some(self.wins as f64 / total as f64) + } else { + None + } + } + + /// Get selection rate (0.0 to 1.0) + pub fn selection_rate(&self) -> Option { + let total = self.times_selected + self.times_passed; + if total > 0 { + Some(self.times_selected as f64 / total as f64) + } else { + None + } + } +} + +/// Record of a pairwise comparison +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ComparisonRecord { + /// Winner's ID + pub winner: CandidateId, + /// Loser's ID + pub loser: CandidateId, + /// Generation when comparison occurred + pub generation: usize, +} + +/// Aggregates partial/incremental feedback into fitness estimates +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FitnessAggregator { + /// The aggregation model to use + model: AggregationModel, + /// Per-candidate statistics + candidate_stats: HashMap, + /// History of pairwise comparisons (for Bradley-Terry updates) + comparisons: Vec, + /// Current generation + current_generation: usize, +} + +impl FitnessAggregator { + /// Create a new aggregator with the given model + pub fn new(model: AggregationModel) -> Self { + Self { + model, + candidate_stats: HashMap::new(), + comparisons: Vec::new(), + current_generation: 0, + } + } + + /// Get the aggregation model + pub fn model(&self) -> &AggregationModel { + &self.model + } + + /// Set the current generation + pub fn set_generation(&mut self, generation: usize) { + self.current_generation = generation; + } + + /// Ensure a candidate has stats initialized + fn ensure_stats(&mut self, id: CandidateId) { + if !self.candidate_stats.contains_key(&id) { + let initial_score = match &self.model { + AggregationModel::DirectRating { default_rating } => *default_rating, + AggregationModel::Elo { initial_rating, .. } => *initial_rating, + AggregationModel::BradleyTerry { + initial_strength, .. + } => *initial_strength, + AggregationModel::BradleyTerrySimple { + initial_strength, .. + } => *initial_strength, + AggregationModel::ImplicitRanking { base_fitness, .. } => *base_fitness, + }; + self.candidate_stats + .insert(id, CandidateStats::new(initial_score)); + } + } + + /// Get stats for a candidate + pub fn get_stats(&self, id: &CandidateId) -> Option<&CandidateStats> { + self.candidate_stats.get(id) + } + + /// Get current fitness estimate for a candidate (point estimate only) + /// + /// For uncertainty information, use `get_fitness_estimate()` instead. + pub fn get_fitness(&self, id: &CandidateId) -> Option { + let stats = self.candidate_stats.get(id)?; + + Some(match &self.model { + AggregationModel::DirectRating { default_rating } => { + stats.average_rating().unwrap_or(*default_rating) + } + AggregationModel::Elo { .. } => stats.model_score, + AggregationModel::BradleyTerry { .. } => stats.model_score, + AggregationModel::BradleyTerrySimple { .. } => stats.model_score, + AggregationModel::ImplicitRanking { .. } => { + // Score is base + cumulative bonuses/penalties + stats.model_score + } + }) + } + + /// Get fitness estimate with uncertainty quantification + /// + /// Returns a `FitnessEstimate` containing the point estimate, variance, + /// and confidence intervals. + pub fn get_fitness_estimate(&self, id: &CandidateId) -> Option { + let stats = self.candidate_stats.get(id)?; + + Some(match &self.model { + AggregationModel::DirectRating { default_rating } => { + if stats.rating_count == 0 { + FitnessEstimate::uninformative(*default_rating) + } else { + let mean = stats.rating_sum / stats.rating_count as f64; + let variance = stats.rating_variance_of_mean().unwrap_or(f64::INFINITY); + FitnessEstimate::new(mean, variance, stats.rating_count) + } + } + AggregationModel::Elo { k_factor, .. } => { + // Elo variance approximation based on K-factor and game count + let n_games = stats.total_comparisons(); + let variance = if n_games == 0 { + f64::INFINITY + } else { + // Approximate variance: decreases with games, proportional to K² + let base_var = k_factor * k_factor * 0.25; // Bernoulli variance factor + base_var / n_games as f64 + }; + FitnessEstimate::new(stats.model_score, variance, n_games) + } + AggregationModel::BradleyTerry { .. } | AggregationModel::BradleyTerrySimple { .. } => { + // Use stored variance from MLE computation + let n_comparisons = stats.total_comparisons(); + let variance = if stats.model_variance.is_finite() { + stats.model_variance + } else if n_comparisons == 0 { + f64::INFINITY + } else { + // Fallback: approximate variance + 1.0 / n_comparisons as f64 + }; + FitnessEstimate::new(stats.model_score, variance, n_comparisons) + } + AggregationModel::ImplicitRanking { .. } => { + // Binomial variance on selection rate + let n = stats.times_selected + stats.times_passed; + if n == 0 { + FitnessEstimate::uninformative(stats.model_score) + } else { + let p = stats.times_selected as f64 / n as f64; + let variance = p * (1.0 - p) / n as f64; + FitnessEstimate::new(stats.model_score, variance, n) + } + } + }) + } + + /// Get access to comparison records (for Bradley-Terry MLE) + pub fn comparisons(&self) -> &[ComparisonRecord] { + &self.comparisons + } + + /// Record a rating for a candidate + pub fn record_rating(&mut self, id: CandidateId, rating: f64) { + self.ensure_stats(id); + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.rating_sum += rating; + stats.rating_sum_squares += rating * rating; + stats.rating_count += 1; + } + } + + /// Record a pairwise comparison result + pub fn record_comparison(&mut self, winner: CandidateId, loser: CandidateId) { + self.ensure_stats(winner); + self.ensure_stats(loser); + + // Update stats + if let Some(winner_stats) = self.candidate_stats.get_mut(&winner) { + winner_stats.wins += 1; + } + if let Some(loser_stats) = self.candidate_stats.get_mut(&loser) { + loser_stats.losses += 1; + } + + // Record comparison for history + self.comparisons.push(ComparisonRecord { + winner, + loser, + generation: self.current_generation, + }); + + // Update model scores + match &self.model { + AggregationModel::Elo { k_factor, .. } => { + self.update_elo(winner, loser, *k_factor); + } + AggregationModel::BradleyTerry { .. } => { + // Bradley-Terry updates are batched via recompute_all() + } + _ => {} + } + } + + /// Record a tie in pairwise comparison + pub fn record_tie(&mut self, id_a: CandidateId, id_b: CandidateId) { + self.ensure_stats(id_a); + self.ensure_stats(id_b); + + if let Some(stats) = self.candidate_stats.get_mut(&id_a) { + stats.ties += 1; + } + if let Some(stats) = self.candidate_stats.get_mut(&id_b) { + stats.ties += 1; + } + + // For Elo, treat tie as half-win each + if let AggregationModel::Elo { k_factor, .. } = &self.model { + self.update_elo_draw(id_a, id_b, *k_factor); + } + } + + /// Record batch selection results + pub fn record_batch_selection( + &mut self, + selected: &[CandidateId], + not_selected: &[CandidateId], + ) { + if let AggregationModel::ImplicitRanking { + selected_bonus, + not_selected_penalty, + .. + } = &self.model + { + let bonus = *selected_bonus; + let penalty = *not_selected_penalty; + + for &id in selected { + self.ensure_stats(id); + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.times_selected += 1; + stats.model_score += bonus; + } + } + + for &id in not_selected { + self.ensure_stats(id); + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.times_passed += 1; + stats.model_score -= penalty; + } + } + } else { + // For other models, just track selection counts + for &id in selected { + self.ensure_stats(id); + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.times_selected += 1; + } + } + for &id in not_selected { + self.ensure_stats(id); + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.times_passed += 1; + } + } + } + } + + /// Update Elo ratings after a comparison + fn update_elo(&mut self, winner: CandidateId, loser: CandidateId, k: f64) { + let winner_rating = self + .candidate_stats + .get(&winner) + .map(|s| s.model_score) + .unwrap_or(1500.0); + let loser_rating = self + .candidate_stats + .get(&loser) + .map(|s| s.model_score) + .unwrap_or(1500.0); + + // Expected scores + let exp_winner = 1.0 / (1.0 + 10.0_f64.powf((loser_rating - winner_rating) / 400.0)); + let exp_loser = 1.0 - exp_winner; + + // Update ratings + if let Some(stats) = self.candidate_stats.get_mut(&winner) { + stats.model_score += k * (1.0 - exp_winner); + } + if let Some(stats) = self.candidate_stats.get_mut(&loser) { + stats.model_score += k * (0.0 - exp_loser); + } + } + + /// Update Elo ratings after a draw + fn update_elo_draw(&mut self, id_a: CandidateId, id_b: CandidateId, k: f64) { + let rating_a = self + .candidate_stats + .get(&id_a) + .map(|s| s.model_score) + .unwrap_or(1500.0); + let rating_b = self + .candidate_stats + .get(&id_b) + .map(|s| s.model_score) + .unwrap_or(1500.0); + + // Expected scores + let exp_a = 1.0 / (1.0 + 10.0_f64.powf((rating_b - rating_a) / 400.0)); + let exp_b = 1.0 - exp_a; + + // Update ratings (actual = 0.5 for draw) + if let Some(stats) = self.candidate_stats.get_mut(&id_a) { + stats.model_score += k * (0.5 - exp_a); + } + if let Some(stats) = self.candidate_stats.get_mut(&id_b) { + stats.model_score += k * (0.5 - exp_b); + } + } + + /// Recompute all fitness estimates from comparison history + /// + /// This is useful for Bradley-Terry model which uses batch MLE, + /// or after loading a session from checkpoint. + pub fn recompute_all(&mut self) -> HashMap { + match &self.model { + AggregationModel::BradleyTerry { optimizer, .. } => { + self.recompute_bradley_terry_mle(optimizer.clone()); + } + AggregationModel::BradleyTerrySimple { + initial_strength, + learning_rate, + iterations, + } => { + self.recompute_bradley_terry_simple(*initial_strength, *learning_rate, *iterations); + } + _ => {} + } + + // Return current fitness estimates + self.candidate_stats + .keys() + .filter_map(|id| self.get_fitness(id).map(|f| (*id, f))) + .collect() + } + + /// Recompute Bradley-Terry using proper MLE (Newton-Raphson or MM) + fn recompute_bradley_terry_mle(&mut self, optimizer: BradleyTerryOptimizer) { + let ids: Vec = self.candidate_stats.keys().copied().collect(); + if ids.is_empty() || self.comparisons.is_empty() { + return; + } + + let model = BradleyTerryModel::new(optimizer); + let result = model.fit(&self.comparisons, &ids); + + // Update stats with MLE results + for (&id, &strength) in &result.strengths { + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.model_score = strength; + + // Update variance from covariance matrix + if let Some(&idx) = result.id_to_index.get(&id) { + if idx < result.covariance.nrows() { + stats.model_variance = result.covariance[(idx, idx)]; + } + } + } + } + } + + /// Recompute Bradley-Terry using simplified iterative MM (legacy) + fn recompute_bradley_terry_simple( + &mut self, + initial_strength: f64, + learning_rate: f64, + iterations: usize, + ) { + // Initialize strengths + let ids: Vec = self.candidate_stats.keys().copied().collect(); + for &id in &ids { + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.model_score = initial_strength; + } + } + + // Iterative MM algorithm for Bradley-Terry + for _ in 0..iterations { + let mut new_scores: HashMap = HashMap::new(); + + for &id in &ids { + let stats = match self.candidate_stats.get(&id) { + Some(s) => s, + None => continue, + }; + + let wins = stats.wins as f64; + if wins == 0.0 { + new_scores.insert(id, stats.model_score); + continue; + } + + // Compute denominator: sum of 1/(p_i + p_j) over all comparisons + let mut denom = 0.0; + for comparison in &self.comparisons { + if comparison.winner == id { + let other_score = self + .candidate_stats + .get(&comparison.loser) + .map(|s| s.model_score) + .unwrap_or(initial_strength); + denom += 1.0 / (stats.model_score + other_score); + } else if comparison.loser == id { + let other_score = self + .candidate_stats + .get(&comparison.winner) + .map(|s| s.model_score) + .unwrap_or(initial_strength); + denom += 1.0 / (stats.model_score + other_score); + } + } + + let new_score = if denom > 0.0 { + let raw = wins / denom; + // Smooth update with learning rate + stats.model_score + learning_rate * (raw - stats.model_score) + } else { + stats.model_score + }; + + new_scores.insert(id, new_score.max(0.001)); // Avoid zero strength + } + + // Apply new scores + for (id, score) in new_scores { + if let Some(stats) = self.candidate_stats.get_mut(&id) { + stats.model_score = score; + } + } + } + } + + /// Process an evaluation response and return updated fitness values + pub fn process_response(&mut self, response: &EvaluationResponse) -> Vec<(CandidateId, f64)> { + match response { + EvaluationResponse::Ratings(ratings) => { + for (id, rating) in ratings { + self.record_rating(*id, *rating); + } + ratings + .iter() + .filter_map(|(id, _)| self.get_fitness(id).map(|f| (*id, f))) + .collect() + } + EvaluationResponse::PairwiseWinner(Some(winner)) => { + // We need both IDs to record a comparison + // For now, just return the winner's fitness + self.ensure_stats(*winner); + if let Some(f) = self.get_fitness(winner) { + vec![(*winner, f)] + } else { + vec![] + } + } + EvaluationResponse::PairwiseWinner(None) => { + // Tie - nothing to update without both IDs + vec![] + } + EvaluationResponse::BatchSelected(selected) => { + // Update selection counts + for id in selected { + self.ensure_stats(*id); + if let Some(stats) = self.candidate_stats.get_mut(id) { + stats.times_selected += 1; + if let AggregationModel::ImplicitRanking { selected_bonus, .. } = + &self.model + { + stats.model_score += *selected_bonus; + } + } + } + selected + .iter() + .filter_map(|id| self.get_fitness(id).map(|f| (*id, f))) + .collect() + } + EvaluationResponse::Skip => vec![], + } + } + + /// Process a pairwise comparison with both candidate IDs + pub fn process_pairwise( + &mut self, + id_a: CandidateId, + id_b: CandidateId, + winner: Option, + ) -> Vec<(CandidateId, f64)> { + match winner { + Some(w) if w == id_a => { + self.record_comparison(id_a, id_b); + } + Some(w) if w == id_b => { + self.record_comparison(id_b, id_a); + } + Some(_) => { + // Winner ID doesn't match either candidate + } + None => { + self.record_tie(id_a, id_b); + } + } + + vec![id_a, id_b] + .into_iter() + .filter_map(|id| self.get_fitness(&id).map(|f| (id, f))) + .collect() + } + + /// Process batch selection with full context + pub fn process_batch_selection( + &mut self, + all_candidates: &[CandidateId], + selected: &[CandidateId], + ) -> Vec<(CandidateId, f64)> { + let selected_set: std::collections::HashSet<_> = selected.iter().copied().collect(); + let not_selected: Vec<_> = all_candidates + .iter() + .copied() + .filter(|id| !selected_set.contains(id)) + .collect(); + + self.record_batch_selection(selected, ¬_selected); + + all_candidates + .iter() + .filter_map(|id| self.get_fitness(id).map(|f| (*id, f))) + .collect() + } + + /// Get all candidate IDs with fitness estimates + pub fn all_candidates(&self) -> Vec { + self.candidate_stats.keys().copied().collect() + } + + /// Get the number of comparisons recorded + pub fn comparison_count(&self) -> usize { + self.comparisons.len() + } + + /// Clear all recorded data + pub fn clear(&mut self) { + self.candidate_stats.clear(); + self.comparisons.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_direct_rating_aggregation() { + let mut agg = FitnessAggregator::new(AggregationModel::DirectRating { + default_rating: 5.0, + }); + + let id = CandidateId(0); + + // Initially should return default + agg.ensure_stats(id); + assert_eq!(agg.get_fitness(&id), Some(5.0)); + + // After rating + agg.record_rating(id, 8.0); + assert_eq!(agg.get_fitness(&id), Some(8.0)); + + // After second rating, should average + agg.record_rating(id, 6.0); + assert_eq!(agg.get_fitness(&id), Some(7.0)); + } + + #[test] + fn test_elo_rating() { + let mut agg = FitnessAggregator::new(AggregationModel::Elo { + initial_rating: 1500.0, + k_factor: 32.0, + }); + + let id_a = CandidateId(0); + let id_b = CandidateId(1); + + agg.ensure_stats(id_a); + agg.ensure_stats(id_b); + + // Initial ratings should be equal + assert_eq!(agg.get_fitness(&id_a), Some(1500.0)); + assert_eq!(agg.get_fitness(&id_b), Some(1500.0)); + + // After A beats B + agg.record_comparison(id_a, id_b); + + let fitness_a = agg.get_fitness(&id_a).unwrap(); + let fitness_b = agg.get_fitness(&id_b).unwrap(); + + // Winner should gain rating + assert!(fitness_a > 1500.0); + // Loser should lose rating + assert!(fitness_b < 1500.0); + // Total rating should be conserved + assert!((fitness_a + fitness_b - 3000.0).abs() < 0.01); + } + + #[test] + fn test_elo_draw() { + let mut agg = FitnessAggregator::new(AggregationModel::Elo { + initial_rating: 1500.0, + k_factor: 32.0, + }); + + let id_a = CandidateId(0); + let id_b = CandidateId(1); + + agg.ensure_stats(id_a); + agg.ensure_stats(id_b); + + // After tie between equal players, ratings should stay the same + agg.record_tie(id_a, id_b); + + let fitness_a = agg.get_fitness(&id_a).unwrap(); + let fitness_b = agg.get_fitness(&id_b).unwrap(); + + assert!((fitness_a - 1500.0).abs() < 0.01); + assert!((fitness_b - 1500.0).abs() < 0.01); + } + + #[test] + fn test_implicit_ranking() { + let mut agg = FitnessAggregator::new(AggregationModel::ImplicitRanking { + selected_bonus: 1.0, + not_selected_penalty: 0.5, + base_fitness: 5.0, + }); + + let selected = vec![CandidateId(0), CandidateId(1)]; + let not_selected = vec![CandidateId(2), CandidateId(3)]; + + agg.record_batch_selection(&selected, ¬_selected); + + // Selected candidates should have bonus + assert_eq!(agg.get_fitness(&CandidateId(0)), Some(6.0)); + assert_eq!(agg.get_fitness(&CandidateId(1)), Some(6.0)); + + // Not selected should have penalty + assert_eq!(agg.get_fitness(&CandidateId(2)), Some(4.5)); + assert_eq!(agg.get_fitness(&CandidateId(3)), Some(4.5)); + } + + #[test] + fn test_bradley_terry_simple_recompute() { + let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerrySimple { + initial_strength: 1.0, + learning_rate: 0.5, + iterations: 10, + }); + + // A beats B multiple times, B beats C + agg.ensure_stats(CandidateId(0)); + agg.ensure_stats(CandidateId(1)); + agg.ensure_stats(CandidateId(2)); + + agg.record_comparison(CandidateId(0), CandidateId(1)); + agg.record_comparison(CandidateId(0), CandidateId(1)); + agg.record_comparison(CandidateId(1), CandidateId(2)); + + let fitness = agg.recompute_all(); + + // A should have highest strength + assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]); + // B should beat C + assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]); + } + + #[test] + fn test_bradley_terry_mle_recompute() { + use crate::interactive::bradley_terry::BradleyTerryOptimizer; + + let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerry { + initial_strength: 1.0, + optimizer: BradleyTerryOptimizer::default(), + }); + + // A beats B multiple times, B beats C + agg.ensure_stats(CandidateId(0)); + agg.ensure_stats(CandidateId(1)); + agg.ensure_stats(CandidateId(2)); + + agg.record_comparison(CandidateId(0), CandidateId(1)); + agg.record_comparison(CandidateId(0), CandidateId(1)); + agg.record_comparison(CandidateId(1), CandidateId(2)); + + let fitness = agg.recompute_all(); + + // A should have highest strength + assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]); + // B should beat C + assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]); + + // MLE should also provide variance estimates + let estimate_a = agg.get_fitness_estimate(&CandidateId(0)).unwrap(); + assert!(estimate_a.variance.is_finite()); + assert!(estimate_a.observation_count > 0); + } + + #[test] + fn test_fitness_estimate_direct_rating() { + let mut agg = FitnessAggregator::new(AggregationModel::DirectRating { + default_rating: 5.0, + }); + + let id = CandidateId(0); + agg.ensure_stats(id); + + // Initially should be uninformative + let estimate = agg.get_fitness_estimate(&id).unwrap(); + assert_eq!(estimate.mean, 5.0); + assert!(estimate.variance.is_infinite()); + + // After ratings, should have finite variance + agg.record_rating(id, 8.0); + agg.record_rating(id, 6.0); + agg.record_rating(id, 7.0); + + let estimate = agg.get_fitness_estimate(&id).unwrap(); + assert_eq!(estimate.mean, 7.0); + assert!(estimate.variance.is_finite()); + assert_eq!(estimate.observation_count, 3); + } + + #[test] + fn test_candidate_stats() { + let mut stats = CandidateStats::new(1500.0); + + // Test rating tracking + stats.rating_sum = 24.0; + stats.rating_count = 3; + assert_eq!(stats.average_rating(), Some(8.0)); + + // Test win rate + stats.wins = 3; + stats.losses = 1; + assert_eq!(stats.total_comparisons(), 4); + assert_eq!(stats.win_rate(), Some(0.75)); + + // Test selection rate + stats.times_selected = 2; + stats.times_passed = 3; + assert_eq!(stats.selection_rate(), Some(0.4)); + } + + #[test] + fn test_process_response_ratings() { + let mut agg = FitnessAggregator::new(AggregationModel::DirectRating { + default_rating: 5.0, + }); + + let response = + EvaluationResponse::ratings(vec![(CandidateId(0), 8.0), (CandidateId(1), 6.0)]); + + let updated = agg.process_response(&response); + + assert_eq!(updated.len(), 2); + assert!(updated + .iter() + .any(|(id, f)| *id == CandidateId(0) && *f == 8.0)); + assert!(updated + .iter() + .any(|(id, f)| *id == CandidateId(1) && *f == 6.0)); + } + + #[test] + fn test_process_batch_selection() { + let mut agg = FitnessAggregator::new(AggregationModel::ImplicitRanking { + selected_bonus: 1.0, + not_selected_penalty: 0.5, + base_fitness: 5.0, + }); + + let all = vec![ + CandidateId(0), + CandidateId(1), + CandidateId(2), + CandidateId(3), + ]; + let selected = vec![CandidateId(0), CandidateId(2)]; + + let updated = agg.process_batch_selection(&all, &selected); + + assert_eq!(updated.len(), 4); + + // Check selected got bonus + let fitness_0 = updated + .iter() + .find(|(id, _)| *id == CandidateId(0)) + .unwrap() + .1; + assert_eq!(fitness_0, 6.0); + + // Check not selected got penalty + let fitness_1 = updated + .iter() + .find(|(id, _)| *id == CandidateId(1)) + .unwrap() + .1; + assert_eq!(fitness_1, 4.5); + } +} diff --git a/src/interactive/algorithm.rs b/src/interactive/algorithm.rs new file mode 100644 index 0000000..3563a5e --- /dev/null +++ b/src/interactive/algorithm.rs @@ -0,0 +1,1079 @@ +//! Interactive Genetic Algorithm implementation +//! +//! This module provides the `InteractiveGA` algorithm which uses a step-based +//! iterator pattern to allow human-in-the-loop fitness evaluation. + +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; + +use super::aggregation::{AggregationModel, FitnessAggregator}; +use super::evaluator::{Candidate, CandidateId, EvaluationRequest, EvaluationResponse}; +use super::selection_strategy::SelectionStrategy; +use super::session::{CoverageStats, InteractiveSession}; +use super::traits::EvaluationMode; +use crate::error::EvolutionError; +use crate::genome::bounds::MultiBounds; +use crate::genome::traits::EvolutionaryGenome; +use crate::operators::traits::{CrossoverOperator, MutationOperator, SelectionOperator}; + +/// Configuration for Interactive GA +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InteractiveGAConfig { + /// Population size (smaller than standard GA for human evaluation) + pub population_size: usize, + /// Number of elite individuals to preserve + pub elitism_count: usize, + /// Crossover probability + pub crossover_probability: f64, + /// Mutation probability + pub mutation_probability: f64, + /// Evaluation mode + pub evaluation_mode: EvaluationMode, + /// Number of candidates per evaluation batch + pub batch_size: usize, + /// Number to select in batch selection mode + pub select_count: usize, + /// Minimum coverage fraction before proceeding to next generation + pub min_coverage: f64, + /// Number of comparisons per candidate per generation (for pairwise mode) + pub comparisons_per_candidate: usize, + /// Maximum generations (0 = unlimited) + pub max_generations: usize, + /// Aggregation model for fitness computation + pub aggregation_model: AggregationModel, + /// Active learning strategy for candidate selection + #[serde(default)] + pub selection_strategy: SelectionStrategy, +} + +impl Default for InteractiveGAConfig { + fn default() -> Self { + Self { + population_size: 20, // Smaller for human evaluation + elitism_count: 2, + crossover_probability: 0.8, + mutation_probability: 0.2, + evaluation_mode: EvaluationMode::Rating, + batch_size: 6, + select_count: 2, + min_coverage: 0.8, // 80% must be evaluated + comparisons_per_candidate: 3, + max_generations: 0, // Unlimited + aggregation_model: AggregationModel::DirectRating { + default_rating: 5.0, + }, + selection_strategy: SelectionStrategy::Sequential, + } + } +} + +/// Internal state machine for the algorithm +#[derive(Clone, Debug)] +enum AlgorithmState { + /// Need to initialize population + Initializing, + /// Waiting for evaluation responses + AwaitingEvaluation { + /// Current request being processed + pending_request_ids: Vec, + }, + /// Ready to perform selection and create next generation + ReadyForEvolution, + /// Evolution complete + Terminated { reason: String }, +} + +/// Result of calling `step()` on the algorithm +#[derive(Clone, Debug)] +pub enum StepResult +where + G: EvolutionaryGenome, +{ + /// Algorithm needs user input + NeedsEvaluation(EvaluationRequest), + + /// Generation complete, ready to continue + GenerationComplete { + /// Generation number that completed + generation: usize, + /// Best fitness in the generation + best_fitness: Option, + /// Evaluation coverage achieved + coverage: f64, + }, + + /// Evolution terminated + Complete(Box>), +} + +/// Final result of interactive evolution +#[derive(Clone, Debug)] +pub struct InteractiveResult +where + G: EvolutionaryGenome, +{ + /// Best candidates found + pub best_candidates: Vec>, + /// Number of generations completed + pub generations: usize, + /// Total evaluation requests made + pub total_evaluations: usize, + /// Final session state + pub session: InteractiveSession, + /// Termination reason + pub termination_reason: String, +} + +/// Step-based Interactive Genetic Algorithm +/// +/// Unlike standard GA algorithms that run to completion, InteractiveGA yields +/// control between evaluations, allowing the caller to interact with users. +/// +/// # Example +/// +/// ```rust,ignore +/// use fugue_evo::interactive::prelude::*; +/// +/// let mut iga = InteractiveGABuilder::::new() +/// .population_size(12) +/// .evaluation_mode(EvaluationMode::BatchSelection) +/// .build()?; +/// +/// let mut rng = rand::thread_rng(); +/// +/// loop { +/// match iga.step(&mut rng) { +/// StepResult::NeedsEvaluation(request) => { +/// let response = get_user_feedback(&request); +/// iga.provide_response(response); +/// } +/// StepResult::GenerationComplete { generation, .. } => { +/// println!("Generation {} complete", generation); +/// } +/// StepResult::Complete(result) => { +/// println!("Evolution complete: {}", result.termination_reason); +/// break; +/// } +/// } +/// } +/// ``` +pub struct InteractiveGA +where + G: EvolutionaryGenome, +{ + config: InteractiveGAConfig, + bounds: Option, + selection: S, + crossover: C, + mutation: M, + session: InteractiveSession, + state: AlgorithmState, + /// Indices of candidates still needing evaluation this generation + unevaluated_indices: Vec, + /// Index for pairwise comparison scheduling + comparison_index: usize, +} + +impl InteractiveGA +where + G: EvolutionaryGenome + Clone + Send + Sync, + S: SelectionOperator, + C: CrossoverOperator, + M: MutationOperator, +{ + /// Create a new InteractiveGA + pub fn new( + config: InteractiveGAConfig, + bounds: Option, + selection: S, + crossover: C, + mutation: M, + ) -> Self { + let aggregator = FitnessAggregator::new(config.aggregation_model.clone()); + Self { + config, + bounds, + selection, + crossover, + mutation, + session: InteractiveSession::new(aggregator), + state: AlgorithmState::Initializing, + unevaluated_indices: Vec::new(), + comparison_index: 0, + } + } + + /// Resume from a saved session + pub fn from_session( + session: InteractiveSession, + config: InteractiveGAConfig, + bounds: Option, + selection: S, + crossover: C, + mutation: M, + ) -> Self { + let unevaluated: Vec = session + .population + .iter() + .enumerate() + .filter(|(_, c)| !c.is_evaluated()) + .map(|(i, _)| i) + .collect(); + + let state = if session.population.is_empty() { + AlgorithmState::Initializing + } else if unevaluated.is_empty() { + AlgorithmState::ReadyForEvolution + } else { + AlgorithmState::AwaitingEvaluation { + pending_request_ids: Vec::new(), + } + }; + + Self { + config, + bounds, + selection, + crossover, + mutation, + session, + state, + unevaluated_indices: unevaluated, + comparison_index: 0, + } + } + + /// Get the current session + pub fn session(&self) -> &InteractiveSession { + &self.session + } + + /// Get mutable reference to session (for custom modifications) + pub fn session_mut(&mut self) -> &mut InteractiveSession { + &mut self.session + } + + /// Get the configuration + pub fn config(&self) -> &InteractiveGAConfig { + &self.config + } + + /// Get coverage statistics + pub fn coverage_stats(&self) -> CoverageStats { + self.session.coverage_stats() + } + + /// Check if algorithm should terminate + fn should_terminate(&self) -> Option { + if self.config.max_generations > 0 && self.session.generation >= self.config.max_generations + { + return Some(format!( + "Reached maximum generations ({})", + self.config.max_generations + )); + } + None + } + + /// Initialize the population + fn initialize_population(&mut self, rng: &mut R) { + // Need bounds for genome generation + let bounds = self + .bounds + .clone() + .unwrap_or_else(|| MultiBounds::symmetric(1.0, 1)); + + for _ in 0..self.config.population_size { + let genome = G::generate(rng, &bounds); + self.session.add_candidate(genome); + } + + self.unevaluated_indices = (0..self.config.population_size).collect(); + self.comparison_index = 0; + } + + /// Create an evaluation request based on the current mode + fn create_evaluation_request(&mut self, rng: &mut R) -> Option> { + match self.config.evaluation_mode { + EvaluationMode::Rating => self.create_rating_request(rng), + EvaluationMode::Pairwise => self.create_pairwise_request(rng), + EvaluationMode::BatchSelection => self.create_batch_request(rng), + EvaluationMode::Adaptive => self.create_adaptive_request(rng), + } + } + + fn create_rating_request(&mut self, rng: &mut R) -> Option> { + let batch_size = self.config.batch_size.min(self.session.population.len()); + if batch_size == 0 { + return None; + } + + // Use selection strategy to pick candidates + let selected_indices = self.config.selection_strategy.select_batch( + &self.session.population, + &self.session.aggregator, + batch_size, + rng, + ); + + if selected_indices.is_empty() { + return None; + } + + let candidates: Vec> = selected_indices + .iter() + .filter_map(|&i| self.session.population.get(i).cloned()) + .collect(); + + let ids: Vec = candidates.iter().map(|c| c.id).collect(); + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: ids, + }; + + Some(EvaluationRequest::rate(candidates)) + } + + fn create_pairwise_request(&mut self, rng: &mut R) -> Option> { + let pop_size = self.session.population.len(); + if pop_size < 2 { + return None; + } + + // Use selection strategy for intelligent pair selection + let pair = self.config.selection_strategy.select_pair( + &self.session.population, + &self.session.aggregator, + rng, + ); + + let (idx_a, idx_b) = match pair { + Some(p) => p, + None => { + // Fallback to round-robin if strategy returns None + let idx_a = self.comparison_index % pop_size; + let idx_b = (self.comparison_index + 1) % pop_size; + (idx_a, idx_b) + } + }; + + self.comparison_index += 1; + + let candidate_a = self.session.population.get(idx_a)?.clone(); + let candidate_b = self.session.population.get(idx_b)?.clone(); + + let ids = vec![candidate_a.id, candidate_b.id]; + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: ids, + }; + + Some(EvaluationRequest::compare(candidate_a, candidate_b)) + } + + fn create_batch_request(&mut self, rng: &mut R) -> Option> { + let batch_size = self.config.batch_size.min(self.session.population.len()); + if batch_size < 2 { + // Need at least 2 for selection + return self.create_rating_request(rng); // Fall back + } + + // Use selection strategy to pick candidates + let selected_indices = self.config.selection_strategy.select_batch( + &self.session.population, + &self.session.aggregator, + batch_size, + rng, + ); + + if selected_indices.len() < 2 { + return self.create_rating_request(rng); + } + + let candidates: Vec> = selected_indices + .iter() + .filter_map(|&i| self.session.population.get(i).cloned()) + .collect(); + + let ids: Vec = candidates.iter().map(|c| c.id).collect(); + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: ids, + }; + + let select_count = self.config.select_count.min(candidates.len() - 1); + Some(EvaluationRequest::select_from_batch( + candidates, + select_count, + )) + } + + fn create_adaptive_request(&mut self, rng: &mut R) -> Option> { + // Simple adaptive strategy: use rating for initial coverage, + // then switch to pairwise for refinement + let coverage = self.session.coverage_stats().coverage; + if coverage < 0.5 { + self.create_rating_request(rng) + } else { + self.create_pairwise_request(rng) + } + } + + /// Provide user response to an evaluation request + pub fn provide_response(&mut self, response: EvaluationResponse) { + let was_skipped = response.is_skip(); + self.session.record_response(was_skipped); + + if was_skipped { + // Put unevaluated candidates back if skipped + if let AlgorithmState::AwaitingEvaluation { + pending_request_ids, + } = &self.state + { + for id in pending_request_ids { + if let Some(pos) = self.session.population.iter().position(|c| c.id == *id) { + if !self.unevaluated_indices.contains(&pos) { + self.unevaluated_indices.push(pos); + } + } + } + } + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: Vec::new(), + }; + return; + } + + // Process the response + let updated = match &response { + EvaluationResponse::Ratings(ratings) => { + self.session.aggregator.process_response(&response); + ratings.iter().map(|(id, _)| *id).collect::>() + } + EvaluationResponse::PairwiseWinner(winner) => { + if let AlgorithmState::AwaitingEvaluation { + pending_request_ids, + } = &self.state + { + if pending_request_ids.len() == 2 { + let id_a = pending_request_ids[0]; + let id_b = pending_request_ids[1]; + self.session + .aggregator + .process_pairwise(id_a, id_b, *winner); + } + } + winner.map(|w| vec![w]).unwrap_or_default() + } + EvaluationResponse::BatchSelected(selected) => { + if let AlgorithmState::AwaitingEvaluation { + pending_request_ids, + } = &self.state + { + self.session + .aggregator + .process_batch_selection(pending_request_ids, selected); + } + selected.clone() + } + EvaluationResponse::Skip => Vec::new(), + }; + + // Update candidate fitness estimates with uncertainty + for id in updated { + if let Some(estimate) = self.session.aggregator.get_fitness_estimate(&id) { + self.session.update_fitness_with_uncertainty(id, estimate); + } else if let Some(fitness) = self.session.aggregator.get_fitness(&id) { + // Fallback to point estimate only + self.session.update_fitness(id, fitness); + } + } + + // Mark candidates as evaluated based on pending request + if let AlgorithmState::AwaitingEvaluation { + pending_request_ids, + } = &self.state + { + for id in pending_request_ids { + if let Some(candidate) = self.session.get_candidate_mut(*id) { + candidate.record_evaluation(); + } + } + } + + // Transition state + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: Vec::new(), + }; + } + + /// Advance the algorithm one step + pub fn step(&mut self, rng: &mut R) -> StepResult + where + G: Serialize + for<'de> Deserialize<'de>, + { + loop { + match &self.state { + AlgorithmState::Initializing => { + self.initialize_population(rng); + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: Vec::new(), + }; + } + + AlgorithmState::AwaitingEvaluation { + pending_request_ids, + } => { + // If we have a pending request, wait for response + if !pending_request_ids.is_empty() { + // This shouldn't happen in normal flow, but handle it + continue; + } + + // Check if we have enough coverage + let coverage = self.session.coverage_stats(); + + // For pairwise mode, check comparison count instead + let enough_coverage = match self.config.evaluation_mode { + EvaluationMode::Pairwise => { + let target = + self.config.population_size * self.config.comparisons_per_candidate; + self.comparison_index >= target + } + _ => coverage.coverage >= self.config.min_coverage, + }; + + if enough_coverage { + self.state = AlgorithmState::ReadyForEvolution; + continue; + } + + // Create next evaluation request + if let Some(request) = self.create_evaluation_request(rng) { + self.session.record_request(&request); + return StepResult::NeedsEvaluation(request); + } else { + // No more candidates to evaluate + self.state = AlgorithmState::ReadyForEvolution; + } + } + + AlgorithmState::ReadyForEvolution => { + // Check termination + if let Some(reason) = self.should_terminate() { + self.state = AlgorithmState::Terminated { + reason: reason.clone(), + }; + continue; + } + + let generation = self.session.generation; + let best_fitness = self.session.best_candidate().and_then(|c| c.fitness()); + let coverage = self.session.coverage_stats().coverage; + + // Perform evolution + self.evolve_generation(rng); + + return StepResult::GenerationComplete { + generation, + best_fitness, + coverage, + }; + } + + AlgorithmState::Terminated { reason } => { + let best_candidates = self + .session + .ranked_candidates() + .into_iter() + .take(self.config.elitism_count.max(3)) + .cloned() + .collect(); + + return StepResult::Complete(Box::new(InteractiveResult { + best_candidates, + generations: self.session.generation, + total_evaluations: self.session.evaluations_requested, + session: self.session.clone(), + termination_reason: reason.clone(), + })); + } + } + } + } + + /// Perform selection and create next generation + fn evolve_generation(&mut self, rng: &mut R) + where + G: Serialize + for<'de> Deserialize<'de>, + { + let pop_size = self.config.population_size; + + // Get current population with fitness (genome, fitness) pairs + let evaluated: Vec<(G, f64)> = self + .session + .population + .iter() + .filter_map(|c| c.fitness_estimate.map(|f| (c.genome.clone(), f))) + .collect(); + + if evaluated.is_empty() { + // No evaluated individuals, can't evolve + self.session.advance_generation(); + return; + } + + // Preserve elites - collect first to avoid borrow issues + let mut new_population: Vec> = Vec::with_capacity(pop_size); + let elites: Vec<_> = self + .session + .ranked_candidates() + .into_iter() + .take(self.config.elitism_count) + .map(|c| (c.genome.clone(), c.fitness_estimate)) + .collect(); + + let next_gen = self.session.generation + 1; + for (genome, fitness) in elites { + let id = self.session.next_id(); + let mut candidate = Candidate::with_generation(id, genome, next_gen); + // Preserve elite fitness + candidate.fitness_estimate = fitness; + new_population.push(candidate); + } + + // Fill rest with offspring + while new_population.len() < pop_size { + // Selection - returns index into evaluated pool + let parent1_idx = self.selection.select(&evaluated, rng); + let parent2_idx = self.selection.select(&evaluated, rng); + + let parent1 = &evaluated[parent1_idx].0; + let parent2 = &evaluated[parent2_idx].0; + + // Crossover + let (mut child1, mut child2) = if rng.gen::() < self.config.crossover_probability { + match self.crossover.crossover(parent1, parent2, rng).genome() { + Some((c1, c2)) => (c1, c2), + None => (parent1.clone(), parent2.clone()), + } + } else { + (parent1.clone(), parent2.clone()) + }; + + // Mutation (in-place) + if rng.gen::() < self.config.mutation_probability { + self.mutation.mutate(&mut child1, rng); + } + + let id = self.session.next_id(); + new_population.push(Candidate::with_generation( + id, + child1, + self.session.generation + 1, + )); + + if new_population.len() < pop_size { + if rng.gen::() < self.config.mutation_probability { + self.mutation.mutate(&mut child2, rng); + } + + let id = self.session.next_id(); + new_population.push(Candidate::with_generation( + id, + child2, + self.session.generation + 1, + )); + } + } + + // Update session + self.session.replace_population(new_population); + self.session.advance_generation(); + + // Reset evaluation tracking for new generation + self.unevaluated_indices = + (self.config.elitism_count..self.config.population_size).collect(); + self.comparison_index = 0; + self.state = AlgorithmState::AwaitingEvaluation { + pending_request_ids: Vec::new(), + }; + } + + /// Manually terminate the algorithm + pub fn terminate(&mut self, reason: &str) { + self.state = AlgorithmState::Terminated { + reason: reason.to_string(), + }; + } +} + +/// Builder for InteractiveGA +pub struct InteractiveGABuilder +where + G: EvolutionaryGenome, +{ + config: InteractiveGAConfig, + bounds: Option, + selection: Option, + crossover: Option, + mutation: Option, + _phantom: PhantomData, +} + +impl InteractiveGABuilder +where + G: EvolutionaryGenome, +{ + /// Create a new builder with default configuration + pub fn new() -> Self { + Self { + config: InteractiveGAConfig::default(), + bounds: None, + selection: None, + crossover: None, + mutation: None, + _phantom: PhantomData, + } + } +} + +impl Default for InteractiveGABuilder +where + G: EvolutionaryGenome, +{ + fn default() -> Self { + Self::new() + } +} + +impl InteractiveGABuilder +where + G: EvolutionaryGenome, +{ + /// Set the population size + pub fn population_size(mut self, size: usize) -> Self { + self.config.population_size = size; + self + } + + /// Set the elitism count + pub fn elitism_count(mut self, count: usize) -> Self { + self.config.elitism_count = count; + self + } + + /// Set the crossover probability + pub fn crossover_probability(mut self, prob: f64) -> Self { + self.config.crossover_probability = prob; + self + } + + /// Set the mutation probability + pub fn mutation_probability(mut self, prob: f64) -> Self { + self.config.mutation_probability = prob; + self + } + + /// Set the evaluation mode + pub fn evaluation_mode(mut self, mode: EvaluationMode) -> Self { + self.config.evaluation_mode = mode; + self + } + + /// Set the batch size + pub fn batch_size(mut self, size: usize) -> Self { + self.config.batch_size = size; + self + } + + /// Set the select count for batch selection mode + pub fn select_count(mut self, count: usize) -> Self { + self.config.select_count = count; + self + } + + /// Set the minimum coverage threshold + pub fn min_coverage(mut self, coverage: f64) -> Self { + self.config.min_coverage = coverage.clamp(0.0, 1.0); + self + } + + /// Set comparisons per candidate for pairwise mode + pub fn comparisons_per_candidate(mut self, count: usize) -> Self { + self.config.comparisons_per_candidate = count; + self + } + + /// Set maximum generations (0 = unlimited) + pub fn max_generations(mut self, max: usize) -> Self { + self.config.max_generations = max; + self + } + + /// Set the aggregation model + pub fn aggregation_model(mut self, model: AggregationModel) -> Self { + self.config.aggregation_model = model; + self + } + + /// Set the active learning selection strategy + /// + /// # Example + /// + /// ```rust,ignore + /// .selection_strategy(SelectionStrategy::UncertaintySampling { + /// uncertainty_weight: 1.0, + /// }) + /// ``` + pub fn selection_strategy(mut self, strategy: SelectionStrategy) -> Self { + self.config.selection_strategy = strategy; + self + } + + /// Set the search space bounds + pub fn bounds(mut self, bounds: MultiBounds) -> Self { + self.bounds = Some(bounds); + self + } + + /// Set the selection operator + pub fn selection(self, selection: NewS) -> InteractiveGABuilder + where + NewS: SelectionOperator, + { + InteractiveGABuilder { + config: self.config, + bounds: self.bounds, + selection: Some(selection), + crossover: self.crossover, + mutation: self.mutation, + _phantom: PhantomData, + } + } + + /// Set the crossover operator + pub fn crossover(self, crossover: NewC) -> InteractiveGABuilder + where + NewC: CrossoverOperator, + { + InteractiveGABuilder { + config: self.config, + bounds: self.bounds, + selection: self.selection, + crossover: Some(crossover), + mutation: self.mutation, + _phantom: PhantomData, + } + } + + /// Set the mutation operator + pub fn mutation(self, mutation: NewM) -> InteractiveGABuilder + where + NewM: MutationOperator, + { + InteractiveGABuilder { + config: self.config, + bounds: self.bounds, + selection: self.selection, + crossover: self.crossover, + mutation: Some(mutation), + _phantom: PhantomData, + } + } +} + +impl InteractiveGABuilder +where + G: EvolutionaryGenome + Clone + Send + Sync, + S: SelectionOperator, + C: CrossoverOperator, + M: MutationOperator, +{ + /// Build the InteractiveGA + pub fn build(self) -> Result, EvolutionError> { + let selection = self + .selection + .ok_or_else(|| EvolutionError::Configuration("Selection operator required".into()))?; + let crossover = self + .crossover + .ok_or_else(|| EvolutionError::Configuration("Crossover operator required".into()))?; + let mutation = self + .mutation + .ok_or_else(|| EvolutionError::Configuration("Mutation operator required".into()))?; + + Ok(InteractiveGA::new( + self.config, + self.bounds, + selection, + crossover, + mutation, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::genome::real_vector::RealVector; + use crate::operators::crossover::SbxCrossover; + use crate::operators::mutation::PolynomialMutation; + use crate::operators::selection::TournamentSelection; + use rand::SeedableRng; + + #[test] + fn test_interactive_ga_builder() { + let result = InteractiveGABuilder::::new() + .population_size(10) + .evaluation_mode(EvaluationMode::Rating) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build(); + + assert!(result.is_ok()); + let iga = result.unwrap(); + assert_eq!(iga.config().population_size, 10); + } + + #[test] + fn test_interactive_ga_initialization() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + let mut iga = InteractiveGABuilder::::new() + .population_size(5) + .evaluation_mode(EvaluationMode::Rating) + .batch_size(2) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build() + .unwrap(); + + let result = iga.step(&mut rng); + + match result { + StepResult::NeedsEvaluation(request) => { + assert!(request.candidate_count() <= 2); + } + _ => panic!("Expected NeedsEvaluation"), + } + + assert_eq!(iga.session().population.len(), 5); + } + + #[test] + fn test_provide_response() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + let mut iga = InteractiveGABuilder::::new() + .population_size(4) + .evaluation_mode(EvaluationMode::Rating) + .batch_size(4) + .min_coverage(1.0) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build() + .unwrap(); + + // Get first request + let result = iga.step(&mut rng); + let request = match result { + StepResult::NeedsEvaluation(r) => r, + _ => panic!("Expected NeedsEvaluation"), + }; + + // Provide ratings + let ids = request.candidate_ids(); + let ratings: Vec<_> = ids + .into_iter() + .enumerate() + .map(|(i, id)| (id, (i + 1) as f64 * 2.0)) + .collect(); + iga.provide_response(EvaluationResponse::ratings(ratings)); + + // Should be ready for evolution + let result = iga.step(&mut rng); + match result { + StepResult::GenerationComplete { generation, .. } => { + assert_eq!(generation, 0); + } + _ => panic!("Expected GenerationComplete"), + } + } + + #[test] + fn test_pairwise_mode() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + let mut iga = InteractiveGABuilder::::new() + .population_size(4) + .evaluation_mode(EvaluationMode::Pairwise) + .comparisons_per_candidate(2) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build() + .unwrap(); + + let result = iga.step(&mut rng); + + match result { + StepResult::NeedsEvaluation(EvaluationRequest::PairwiseComparison { .. }) => {} + _ => panic!("Expected PairwiseComparison request"), + } + } + + #[test] + fn test_batch_selection_mode() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + let mut iga = InteractiveGABuilder::::new() + .population_size(6) + .evaluation_mode(EvaluationMode::BatchSelection) + .batch_size(4) + .select_count(2) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build() + .unwrap(); + + let result = iga.step(&mut rng); + + match result { + StepResult::NeedsEvaluation(EvaluationRequest::BatchSelection { + candidates, + select_count, + .. + }) => { + assert_eq!(candidates.len(), 4); + assert_eq!(select_count, 2); + } + _ => panic!("Expected BatchSelection request"), + } + } + + #[test] + fn test_skip_response() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + let mut iga = InteractiveGABuilder::::new() + .population_size(4) + .evaluation_mode(EvaluationMode::Rating) + .batch_size(2) + .selection(TournamentSelection::new(2)) + .crossover(SbxCrossover::new(15.0)) + .mutation(PolynomialMutation::new(20.0)) + .build() + .unwrap(); + + // Get request + let _ = iga.step(&mut rng); + + // Skip it + iga.provide_response(EvaluationResponse::skip()); + + assert_eq!(iga.session().skipped, 1); + assert_eq!(iga.session().responses_received, 0); + } +} diff --git a/src/interactive/bradley_terry.rs b/src/interactive/bradley_terry.rs new file mode 100644 index 0000000..a92a07d --- /dev/null +++ b/src/interactive/bradley_terry.rs @@ -0,0 +1,820 @@ +//! Bradley-Terry model implementation with Maximum Likelihood Estimation +//! +//! This module provides proper MLE-based Bradley-Terry model fitting with two +//! optimization algorithms: +//! +//! - **Newton-Raphson**: Fast convergence, provides Fisher Information for uncertainty +//! - **MM (Minorization-Maximization)**: Simple, guaranteed convergence, uses bootstrap for uncertainty +//! +//! # Bradley-Terry Model +//! +//! The Bradley-Terry model estimates the probability that candidate i beats candidate j as: +//! +//! ```text +//! P(i beats j) = π_i / (π_i + π_j) +//! ``` +//! +//! where π_i is the "strength" parameter for candidate i. +//! +//! # Example +//! +//! ```rust,ignore +//! use fugue_evo::interactive::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer}; +//! +//! let comparisons = vec![ +//! ComparisonRecord { winner: CandidateId(0), loser: CandidateId(1), generation: 0 }, +//! ComparisonRecord { winner: CandidateId(0), loser: CandidateId(2), generation: 0 }, +//! ]; +//! +//! let model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); +//! let result = model.fit(&comparisons, &candidate_ids); +//! +//! let estimate = result.get_estimate(CandidateId(0)); +//! println!("Strength: {:.2} ± {:.2}", estimate.mean, estimate.std_error()); +//! ``` + +use nalgebra::{DMatrix, DVector}; +use rand::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::aggregation::ComparisonRecord; +use super::evaluator::CandidateId; +use super::uncertainty::FitnessEstimate; + +/// Internal context for fitting operations +/// +/// Groups common parameters for fit operations to reduce function argument count. +struct FitContext<'a> { + comparisons: &'a [ComparisonRecord], + candidate_ids: &'a [CandidateId], + id_to_index: HashMap, + n: usize, +} + +impl<'a> FitContext<'a> { + fn new(comparisons: &'a [ComparisonRecord], candidate_ids: &'a [CandidateId]) -> Self { + let id_to_index: HashMap = candidate_ids + .iter() + .enumerate() + .map(|(i, &id)| (id, i)) + .collect(); + let n = candidate_ids.len(); + Self { + comparisons, + candidate_ids, + id_to_index, + n, + } + } +} + +/// Bradley-Terry optimizer configuration +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum BradleyTerryOptimizer { + /// Newton-Raphson optimization with Fisher Information for uncertainty + /// + /// Faster convergence, provides analytical covariance matrix from + /// the inverse Fisher Information (negative Hessian). + NewtonRaphson { + /// Maximum iterations (default: 100) + max_iterations: usize, + /// Convergence tolerance for gradient norm (default: 1e-8) + tolerance: f64, + /// L2 regularization for Hessian stability (default: 1e-6) + regularization: f64, + }, + + /// MM (Minorization-Maximization) algorithm with bootstrap for uncertainty + /// + /// Simpler, guaranteed monotonic likelihood increase, uses bootstrap + /// resampling to estimate variance. + MM { + /// Maximum iterations (default: 100) + max_iterations: usize, + /// Convergence tolerance for parameter change (default: 1e-8) + tolerance: f64, + /// Number of bootstrap samples for variance estimation (default: 100) + bootstrap_samples: usize, + }, +} + +impl Default for BradleyTerryOptimizer { + fn default() -> Self { + Self::NewtonRaphson { + max_iterations: 100, + tolerance: 1e-6, // Relaxed for better convergence on small datasets + regularization: 1e-6, + } + } +} + +impl BradleyTerryOptimizer { + /// Create Newton-Raphson optimizer with custom parameters + pub fn newton_raphson(max_iterations: usize, tolerance: f64, regularization: f64) -> Self { + Self::NewtonRaphson { + max_iterations, + tolerance, + regularization, + } + } + + /// Create MM optimizer with custom parameters + pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self { + Self::MM { + max_iterations, + tolerance, + bootstrap_samples, + } + } +} + +/// Result of Bradley-Terry MLE optimization +#[derive(Clone, Debug)] +pub struct BradleyTerryResult { + /// Strength parameters (probability scale, sum to n) + pub strengths: HashMap, + /// Covariance matrix (from Fisher^-1 or bootstrap) + pub covariance: DMatrix, + /// Mapping from CandidateId to matrix index + pub id_to_index: HashMap, + /// Log-likelihood at solution + pub log_likelihood: f64, + /// Number of iterations to convergence + pub iterations: usize, + /// Did the algorithm converge? + pub converged: bool, + /// Final gradient norm (Newton-Raphson) or max parameter change (MM) + pub convergence_metric: f64, +} + +impl BradleyTerryResult { + /// Get fitness estimate for a candidate with uncertainty + pub fn get_estimate(&self, id: CandidateId) -> Option { + let strength = *self.strengths.get(&id)?; + let idx = *self.id_to_index.get(&id)?; + + // Variance is diagonal element of covariance matrix + let variance = if idx < self.covariance.nrows() { + self.covariance[(idx, idx)] + } else { + f64::INFINITY + }; + + // Count total comparisons involving this candidate + let observation_count = self.strengths.len(); // Approximate + + Some(FitnessEstimate::new(strength, variance, observation_count)) + } + + /// Get all estimates as a map + pub fn all_estimates(&self) -> HashMap { + self.strengths + .keys() + .filter_map(|&id| self.get_estimate(id).map(|e| (id, e))) + .collect() + } + + /// Predict probability that candidate a beats candidate b + pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option { + let pa = self.strengths.get(&a)?; + let pb = self.strengths.get(&b)?; + Some(pa / (pa + pb)) + } +} + +/// Bradley-Terry model for pairwise comparison data +pub struct BradleyTerryModel { + optimizer: BradleyTerryOptimizer, +} + +impl BradleyTerryModel { + /// Create a new Bradley-Terry model with specified optimizer + pub fn new(optimizer: BradleyTerryOptimizer) -> Self { + Self { optimizer } + } + + /// Fit the model to comparison data + /// + /// # Arguments + /// + /// * `comparisons` - Historical pairwise comparison records + /// * `candidate_ids` - All candidate IDs to include (may include uncompared candidates) + /// + /// # Returns + /// + /// `BradleyTerryResult` with fitted strengths and uncertainty estimates + pub fn fit( + &self, + comparisons: &[ComparisonRecord], + candidate_ids: &[CandidateId], + ) -> BradleyTerryResult { + if candidate_ids.is_empty() || comparisons.is_empty() { + return self.empty_result(candidate_ids); + } + + let ctx = FitContext::new(comparisons, candidate_ids); + + match &self.optimizer { + BradleyTerryOptimizer::NewtonRaphson { + max_iterations, + tolerance, + regularization, + } => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *regularization), + BradleyTerryOptimizer::MM { + max_iterations, + tolerance, + bootstrap_samples, + } => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples), + } + } + + /// Empty result for edge cases + fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult { + let n = candidate_ids.len(); + let strengths: HashMap = + candidate_ids.iter().map(|&id| (id, 1.0)).collect(); + let id_to_index: HashMap = candidate_ids + .iter() + .enumerate() + .map(|(i, &id)| (id, i)) + .collect(); + + BradleyTerryResult { + strengths, + covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY), + id_to_index, + log_likelihood: 0.0, + iterations: 0, + converged: true, + convergence_metric: 0.0, + } + } + + /// Newton-Raphson optimization + /// + /// Uses log-parameterization: θ_i = log(π_i) + /// This makes the optimization unconstrained. + fn fit_newton_raphson( + &self, + ctx: &FitContext, + max_iterations: usize, + tolerance: f64, + regularization: f64, + ) -> BradleyTerryResult { + let n = ctx.n; + let comparisons = ctx.comparisons; + let candidate_ids = ctx.candidate_ids; + let id_to_index = &ctx.id_to_index; + // Initialize log-strengths to zero + let mut theta = DVector::zeros(n); + + // Count wins for each candidate + let mut wins = vec![0usize; n]; + for comp in comparisons { + if let Some(&idx) = id_to_index.get(&comp.winner) { + wins[idx] += 1; + } + } + + let mut converged = false; + let mut iterations = 0; + let mut gradient_norm = f64::INFINITY; + + for iter in 0..max_iterations { + iterations = iter + 1; + + // Compute gradient and Hessian + let mut gradient = DVector::zeros(n); + let mut hessian = DMatrix::zeros(n, n); + + // Gradient: g_i = wins_i - Σ_j n_ij * σ(θ_i - θ_j) + // Hessian: H_ii = -Σ_j n_ij * σ(θ_i - θ_j) * (1 - σ(θ_i - θ_j)) + // H_ij = n_ij * σ(θ_i - θ_j) * (1 - σ(θ_i - θ_j)) + + for comp in comparisons { + let i = match id_to_index.get(&comp.winner) { + Some(&idx) => idx, + None => continue, + }; + let j = match id_to_index.get(&comp.loser) { + Some(&idx) => idx, + None => continue, + }; + + // σ(θ_i - θ_j) = P(i beats j) + let diff = theta[i] - theta[j]; + let p = sigmoid(diff); + let q = 1.0 - p; // P(j beats i) + + // Gradient contributions + gradient[i] += q; // = 1 - p + gradient[j] -= q; // = -(1 - p) = p - 1 + + // Hessian contributions (second derivatives of log-likelihood) + let h = p * q; + hessian[(i, i)] -= h; + hessian[(j, j)] -= h; + hessian[(i, j)] += h; + hessian[(j, i)] += h; + } + + // Add regularization to diagonal + for i in 0..n { + hessian[(i, i)] -= regularization; + } + + // Check convergence + gradient_norm = gradient.norm(); + if gradient_norm < tolerance { + converged = true; + break; + } + + // Newton step: δ = -H^{-1} * g + // Use LU decomposition for solving + let neg_hessian = -&hessian; + let delta = match neg_hessian.clone().lu().solve(&gradient) { + Some(d) => d, + None => { + // Hessian is singular, add more regularization + let mut reg_hessian = neg_hessian; + for i in 0..n { + reg_hessian[(i, i)] += regularization * 10.0; + } + match reg_hessian.lu().solve(&gradient) { + Some(d) => d, + None => break, // Give up + } + } + }; + + // Update with line search backtracking for stability + let mut step_size = 1.0; + let current_ll = self.log_likelihood(&theta, comparisons, id_to_index); + + for _ in 0..10 { + let new_theta = &theta + step_size * δ + let new_ll = self.log_likelihood(&new_theta, comparisons, id_to_index); + + if new_ll > current_ll - 1e-4 * step_size * gradient.dot(&delta) { + theta = new_theta; + break; + } + step_size *= 0.5; + } + + // Normalize (subtract mean for identifiability) + let mean_theta = theta.mean(); + theta -= DVector::from_element(n, mean_theta); + } + + // Convert to probability scale + let strengths: HashMap = candidate_ids + .iter() + .enumerate() + .map(|(i, &id)| (id, theta[i].exp())) + .collect(); + + // Covariance from inverse negative Hessian (Fisher Information) + let mut final_hessian = DMatrix::zeros(n, n); + for comp in comparisons { + let i = match id_to_index.get(&comp.winner) { + Some(&idx) => idx, + None => continue, + }; + let j = match id_to_index.get(&comp.loser) { + Some(&idx) => idx, + None => continue, + }; + + let p = sigmoid(theta[i] - theta[j]); + let h = p * (1.0 - p); + + final_hessian[(i, i)] -= h; + final_hessian[(j, j)] -= h; + final_hessian[(i, j)] += h; + final_hessian[(j, i)] += h; + } + + // Add regularization + for i in 0..n { + final_hessian[(i, i)] -= regularization; + } + + // Covariance = -H^{-1} + let covariance = match (-&final_hessian).clone().try_inverse() { + Some(inv) => inv, + None => DMatrix::from_diagonal_element(n, n, f64::INFINITY), + }; + + let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index); + + BradleyTerryResult { + strengths, + covariance, + id_to_index: id_to_index.clone(), + log_likelihood, + iterations, + converged, + convergence_metric: gradient_norm, + } + } + + /// MM algorithm optimization + fn fit_mm( + &self, + ctx: &FitContext, + max_iterations: usize, + tolerance: f64, + bootstrap_samples: usize, + ) -> BradleyTerryResult { + let n = ctx.n; + let comparisons = ctx.comparisons; + let candidate_ids = ctx.candidate_ids; + let id_to_index = &ctx.id_to_index; + + // Fit point estimates + let (pi, iterations, converged, max_change) = + self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance); + + // Bootstrap for variance estimation + let covariance = + self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi); + + // Convert to HashMap + let strengths: HashMap = candidate_ids + .iter() + .enumerate() + .map(|(i, &id)| (id, pi[i])) + .collect(); + + // Compute log-likelihood + let theta: DVector = pi.iter().map(|&p| p.ln()).collect::>().into(); + let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index); + + BradleyTerryResult { + strengths, + covariance, + id_to_index: id_to_index.clone(), + log_likelihood, + iterations, + converged, + convergence_metric: max_change, + } + } + + /// Core MM iteration + fn mm_core( + &self, + comparisons: &[ComparisonRecord], + id_to_index: &HashMap, + n: usize, + max_iterations: usize, + tolerance: f64, + ) -> (Vec, usize, bool, f64) { + // Initialize strengths uniformly + let mut pi = vec![1.0; n]; + + // Count wins + let mut wins = vec![0usize; n]; + for comp in comparisons { + if let Some(&idx) = id_to_index.get(&comp.winner) { + wins[idx] += 1; + } + } + + let mut converged = false; + let mut iterations = 0; + let mut max_change = f64::INFINITY; + + for iter in 0..max_iterations { + iterations = iter + 1; + let mut pi_new = vec![0.0; n]; + + for i in 0..n { + if wins[i] == 0 { + // Candidate with no wins - use small regularized value + pi_new[i] = 0.01; + continue; + } + + // Compute denominator: Σ_j n_ij / (π_i + π_j) + let mut denom = 0.0; + for comp in comparisons { + let w_idx = id_to_index.get(&comp.winner).copied(); + let l_idx = id_to_index.get(&comp.loser).copied(); + + match (w_idx, l_idx) { + (Some(wi), Some(li)) if wi == i || li == i => { + let other = if wi == i { li } else { wi }; + denom += 1.0 / (pi[i] + pi[other]); + } + _ => {} + } + } + + if denom > 0.0 { + pi_new[i] = wins[i] as f64 / denom; + } else { + pi_new[i] = pi[i]; // No comparisons, keep current + } + } + + // Normalize so strengths sum to n (arbitrary but stable) + let sum: f64 = pi_new.iter().sum(); + if sum > 0.0 { + for p in &mut pi_new { + *p *= n as f64 / sum; + } + } + + // Check convergence + max_change = pi + .iter() + .zip(pi_new.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + + if max_change < tolerance { + converged = true; + pi = pi_new; + break; + } + + pi = pi_new; + } + + (pi, iterations, converged, max_change) + } + + /// Bootstrap resampling for variance estimation + fn bootstrap_covariance( + &self, + ctx: &FitContext, + max_iterations: usize, + tolerance: f64, + bootstrap_samples: usize, + point_estimate: &[f64], + ) -> DMatrix { + let n = ctx.n; + let comparisons = ctx.comparisons; + let id_to_index = &ctx.id_to_index; + + if bootstrap_samples == 0 || comparisons.is_empty() { + return DMatrix::from_diagonal_element(n, n, f64::INFINITY); + } + + let mut rng = rand::thread_rng(); + let mut bootstrap_estimates: Vec> = Vec::with_capacity(bootstrap_samples); + + for _ in 0..bootstrap_samples { + // Resample comparisons with replacement + let resampled: Vec = (0..comparisons.len()) + .map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone()) + .collect(); + + // Fit to resampled data + let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance); + bootstrap_estimates.push(pi); + } + + // Compute covariance matrix from bootstrap samples + let mut covariance = DMatrix::zeros(n, n); + + for i in 0..n { + for j in 0..n { + let mean_i = point_estimate[i]; + let mean_j = point_estimate[j]; + + let cov: f64 = bootstrap_estimates + .iter() + .map(|est| (est[i] - mean_i) * (est[j] - mean_j)) + .sum::() + / (bootstrap_samples - 1).max(1) as f64; + + covariance[(i, j)] = cov; + } + } + + covariance + } + + /// Compute log-likelihood + fn log_likelihood( + &self, + theta: &DVector, + comparisons: &[ComparisonRecord], + id_to_index: &HashMap, + ) -> f64 { + let mut ll = 0.0; + + for comp in comparisons { + let i = match id_to_index.get(&comp.winner) { + Some(&idx) => idx, + None => continue, + }; + let j = match id_to_index.get(&comp.loser) { + Some(&idx) => idx, + None => continue, + }; + + // log P(i beats j) = log(σ(θ_i - θ_j)) = θ_i - θ_j - log(1 + exp(θ_i - θ_j)) + let diff = theta[i] - theta[j]; + ll += log_sigmoid(diff); + } + + ll + } +} + +/// Sigmoid function: σ(x) = 1 / (1 + exp(-x)) +fn sigmoid(x: f64) -> f64 { + if x >= 0.0 { + 1.0 / (1.0 + (-x).exp()) + } else { + let ex = x.exp(); + ex / (1.0 + ex) + } +} + +/// Log sigmoid: log(σ(x)) = -log(1 + exp(-x)) +fn log_sigmoid(x: f64) -> f64 { + if x >= 0.0 { + -(-x).exp().ln_1p() + } else { + x - x.exp().ln_1p() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_comparisons(pairs: &[(usize, usize)]) -> Vec { + pairs + .iter() + .map(|&(w, l)| ComparisonRecord { + winner: CandidateId(w), + loser: CandidateId(l), + generation: 0, + }) + .collect() + } + + #[test] + fn test_newton_raphson_basic() { + // Simple case: A beats B twice, B beats C twice + let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]); + let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)]; + + let model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); + let result = model.fit(&comparisons, &candidate_ids); + + assert!(result.converged); + + // A should be strongest, C weakest + let pa = result.strengths[&CandidateId(0)]; + let pb = result.strengths[&CandidateId(1)]; + let pc = result.strengths[&CandidateId(2)]; + + assert!(pa > pb); + assert!(pb > pc); + } + + #[test] + fn test_mm_basic() { + let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]); + let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)]; + + let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50)); + let result = model.fit(&comparisons, &candidate_ids); + + assert!(result.converged); + + let pa = result.strengths[&CandidateId(0)]; + let pb = result.strengths[&CandidateId(1)]; + let pc = result.strengths[&CandidateId(2)]; + + assert!(pa > pb); + assert!(pb > pc); + } + + #[test] + fn test_newton_raphson_and_mm_agree() { + let comparisons = make_comparisons(&[ + (0, 1), + (0, 2), + (1, 2), + (0, 1), + (1, 0), + (2, 1), + (0, 2), + (0, 2), + ]); + let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)]; + + let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); + let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0)); + + let nr_result = nr_model.fit(&comparisons, &candidate_ids); + let mm_result = mm_model.fit(&comparisons, &candidate_ids); + + // Rankings should agree + let nr_ranking: Vec<_> = { + let mut r: Vec<_> = candidate_ids + .iter() + .map(|&id| (id, nr_result.strengths[&id])) + .collect(); + r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + r.into_iter().map(|(id, _)| id).collect() + }; + + let mm_ranking: Vec<_> = { + let mut r: Vec<_> = candidate_ids + .iter() + .map(|&id| (id, mm_result.strengths[&id])) + .collect(); + r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + r.into_iter().map(|(id, _)| id).collect() + }; + + assert_eq!(nr_ranking, mm_ranking); + } + + #[test] + fn test_get_estimate() { + let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]); + let candidate_ids = vec![CandidateId(0), CandidateId(1)]; + + let model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); + let result = model.fit(&comparisons, &candidate_ids); + + let estimate = result.get_estimate(CandidateId(0)).unwrap(); + assert!(estimate.variance < f64::INFINITY); + assert!(estimate.variance > 0.0); + } + + #[test] + fn test_predict_win_probability() { + let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]); + let candidate_ids = vec![CandidateId(0), CandidateId(1)]; + + let model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); + let result = model.fit(&comparisons, &candidate_ids); + + let p = result + .predict_win_probability(CandidateId(0), CandidateId(1)) + .unwrap(); + assert!(p > 0.5); // A should be favored + assert!(p < 1.0); + } + + #[test] + fn test_empty_comparisons() { + let comparisons: Vec = vec![]; + let candidate_ids = vec![CandidateId(0), CandidateId(1)]; + + let model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); + let result = model.fit(&comparisons, &candidate_ids); + + // Should return uniform strengths with infinite variance + assert!(result.converged); + assert!(result.covariance[(0, 0)].is_infinite()); + } + + #[test] + fn test_sigmoid() { + assert!((sigmoid(0.0) - 0.5).abs() < 1e-9); + assert!(sigmoid(100.0) > 0.999); + assert!(sigmoid(-100.0) < 0.001); + + // Symmetry: σ(-x) = 1 - σ(x) + for x in [-5.0, -1.0, 0.0, 1.0, 5.0] { + assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9); + } + } + + #[test] + fn test_log_sigmoid() { + // log(σ(x)) should be negative + for x in [-5.0, -1.0, 0.0, 1.0, 5.0] { + assert!(log_sigmoid(x) <= 0.0); + assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9); + } + } + + #[test] + fn test_covariance_positive_semidefinite() { + let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]); + let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)]; + + let model = BradleyTerryModel::new(BradleyTerryOptimizer::default()); + let result = model.fit(&comparisons, &candidate_ids); + + // Diagonal should be non-negative + for i in 0..3 { + assert!(result.covariance[(i, i)] >= 0.0); + } + } +} diff --git a/src/interactive/evaluator.rs b/src/interactive/evaluator.rs new file mode 100644 index 0000000..4e4ebed --- /dev/null +++ b/src/interactive/evaluator.rs @@ -0,0 +1,541 @@ +//! Core types for interactive evaluation +//! +//! This module defines the request/response types used for human-in-the-loop +//! fitness evaluation in interactive genetic algorithms. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::hash::{Hash, Hasher}; + +use super::uncertainty::FitnessEstimate; +use crate::genome::traits::EvolutionaryGenome; + +/// Unique identifier for a candidate in an interactive session +/// +/// Each candidate is assigned a unique ID when created, which remains +/// stable across generations. This allows tracking evaluation history +/// and aggregating feedback over time. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub struct CandidateId(pub usize); + +impl fmt::Display for CandidateId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Candidate({})", self.0) + } +} + +impl From for CandidateId { + fn from(id: usize) -> Self { + Self(id) + } +} + +impl From for usize { + fn from(id: CandidateId) -> Self { + id.0 + } +} + +/// A candidate presented for user evaluation +/// +/// Wraps a genome with its unique identifier and current fitness estimate. +/// The fitness estimate is updated as user feedback is received and aggregated. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(bound = "G: Serialize + for<'a> Deserialize<'a>")] +pub struct Candidate +where + G: EvolutionaryGenome, +{ + /// Unique identifier for this candidate + pub id: CandidateId, + /// The genome of this candidate + pub genome: G, + /// Current fitness estimate (updated as feedback arrives) + pub fitness_estimate: Option, + /// Full fitness estimate with uncertainty quantification + #[serde(default)] + pub fitness_with_uncertainty: Option, + /// Generation when this candidate was created + pub birth_generation: usize, + /// Number of times this candidate has been evaluated + pub evaluation_count: usize, +} + +impl Candidate +where + G: EvolutionaryGenome, +{ + /// Create a new candidate with the given ID and genome + pub fn new(id: CandidateId, genome: G) -> Self { + Self { + id, + genome, + fitness_estimate: None, + fitness_with_uncertainty: None, + birth_generation: 0, + evaluation_count: 0, + } + } + + /// Create a new candidate with birth generation + pub fn with_generation(id: CandidateId, genome: G, generation: usize) -> Self { + Self { + id, + genome, + fitness_estimate: None, + fitness_with_uncertainty: None, + birth_generation: generation, + evaluation_count: 0, + } + } + + /// Set the fitness estimate (point estimate only) + pub fn set_fitness(&mut self, fitness: f64) { + self.fitness_estimate = Some(fitness); + } + + /// Set the full fitness estimate with uncertainty + pub fn set_fitness_with_uncertainty(&mut self, estimate: FitnessEstimate) { + self.fitness_estimate = Some(estimate.mean); + self.fitness_with_uncertainty = Some(estimate); + } + + /// Get the fitness estimate (point estimate), if available + pub fn fitness(&self) -> Option { + self.fitness_estimate + } + + /// Get the full fitness estimate with uncertainty, if available + pub fn fitness_uncertainty(&self) -> Option<&FitnessEstimate> { + self.fitness_with_uncertainty.as_ref() + } + + /// Get the variance of the fitness estimate, if available + pub fn fitness_variance(&self) -> Option { + self.fitness_with_uncertainty.as_ref().map(|e| e.variance) + } + + /// Check if this candidate has been evaluated at least once + pub fn is_evaluated(&self) -> bool { + self.evaluation_count > 0 + } + + /// Increment the evaluation count + pub fn record_evaluation(&mut self) { + self.evaluation_count += 1; + } + + /// Get the age of this candidate (generations since birth) + pub fn age(&self, current_generation: usize) -> usize { + current_generation.saturating_sub(self.birth_generation) + } + + /// Check if this candidate has high uncertainty (needs more evaluation) + /// + /// Returns true if variance is infinite or observation count is below threshold. + pub fn is_uncertain(&self, min_observations: usize) -> bool { + self.fitness_with_uncertainty + .as_ref() + .map(|e| e.is_uncertain(min_observations)) + .unwrap_or(true) // No estimate = uncertain + } +} + +impl PartialEq for Candidate +where + G: EvolutionaryGenome, +{ + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for Candidate where G: EvolutionaryGenome {} + +impl Hash for Candidate +where + G: EvolutionaryGenome, +{ + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +/// Rating scale configuration for numeric ratings +/// +/// Defines the valid range and behavior for user ratings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RatingScale { + /// Minimum rating value + pub min: f64, + /// Maximum rating value + pub max: f64, + /// Whether ties are allowed (for pairwise comparisons) + pub allow_ties: bool, + /// Optional step size (e.g., 0.5 for half-star ratings) + pub step: Option, +} + +impl RatingScale { + /// Create a new rating scale + pub fn new(min: f64, max: f64) -> Self { + Self { + min, + max, + allow_ties: true, + step: None, + } + } + + /// Standard 1-10 rating scale + pub fn one_to_ten() -> Self { + Self { + min: 1.0, + max: 10.0, + allow_ties: true, + step: Some(1.0), + } + } + + /// Standard 1-5 rating scale (5-star rating) + pub fn one_to_five() -> Self { + Self { + min: 1.0, + max: 5.0, + allow_ties: true, + step: Some(1.0), + } + } + + /// Binary like/dislike scale + pub fn binary() -> Self { + Self { + min: 0.0, + max: 1.0, + allow_ties: false, + step: Some(1.0), + } + } + + /// Set whether ties are allowed + pub fn with_ties(mut self, allow: bool) -> Self { + self.allow_ties = allow; + self + } + + /// Set the step size for discrete ratings + pub fn with_step(mut self, step: f64) -> Self { + self.step = Some(step); + self + } + + /// Validate a rating against this scale + pub fn validate(&self, rating: f64) -> bool { + if rating < self.min || rating > self.max { + return false; + } + if let Some(step) = self.step { + // Check if rating is a valid step from min + let steps_from_min = (rating - self.min) / step; + (steps_from_min - steps_from_min.round()).abs() < 1e-9 + } else { + true + } + } + + /// Clamp a rating to the valid range + pub fn clamp(&self, rating: f64) -> f64 { + rating.clamp(self.min, self.max) + } + + /// Normalize a rating to [0, 1] range + pub fn normalize(&self, rating: f64) -> f64 { + (rating - self.min) / (self.max - self.min) + } + + /// Denormalize a [0, 1] value to this scale + pub fn denormalize(&self, normalized: f64) -> f64 { + normalized * (self.max - self.min) + self.min + } +} + +impl Default for RatingScale { + fn default() -> Self { + Self::one_to_ten() + } +} + +/// Evaluation request sent to the user +/// +/// Represents a request for user feedback on one or more candidates. +/// The type of feedback requested depends on the variant. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(bound = "G: Serialize + for<'a> Deserialize<'a>")] +pub enum EvaluationRequest +where + G: EvolutionaryGenome, +{ + /// Rate individual candidates on a numeric scale + /// + /// User assigns a rating to each candidate. Ratings may be partial + /// (not all candidates need to be rated). + RateCandidates { + /// Candidates to rate + candidates: Vec>, + /// Rating scale to use + scale: RatingScale, + }, + + /// Compare two candidates - which is better? + /// + /// User selects the preferred candidate, or indicates a tie + /// (if allowed by the scale). + PairwiseComparison { + /// First candidate + candidate_a: Candidate, + /// Second candidate + candidate_b: Candidate, + /// Whether ties are allowed + allow_tie: bool, + }, + + /// Select N favorites from a batch + /// + /// User selects their top candidates from the presented set. + /// Selection implies preference over non-selected candidates. + BatchSelection { + /// Candidates to choose from + candidates: Vec>, + /// Number of candidates to select + select_count: usize, + /// Minimum number required (for partial selections) + min_select: usize, + }, +} + +impl EvaluationRequest +where + G: EvolutionaryGenome, +{ + /// Create a rate candidates request with default scale + pub fn rate(candidates: Vec>) -> Self { + Self::RateCandidates { + candidates, + scale: RatingScale::default(), + } + } + + /// Create a rate candidates request with custom scale + pub fn rate_with_scale(candidates: Vec>, scale: RatingScale) -> Self { + Self::RateCandidates { candidates, scale } + } + + /// Create a pairwise comparison request + pub fn compare(a: Candidate, b: Candidate) -> Self { + Self::PairwiseComparison { + candidate_a: a, + candidate_b: b, + allow_tie: true, + } + } + + /// Create a batch selection request + pub fn select_from_batch(candidates: Vec>, select_count: usize) -> Self { + Self::BatchSelection { + candidates, + select_count, + min_select: 1, + } + } + + /// Get the number of candidates in this request + pub fn candidate_count(&self) -> usize { + match self { + Self::RateCandidates { candidates, .. } => candidates.len(), + Self::PairwiseComparison { .. } => 2, + Self::BatchSelection { candidates, .. } => candidates.len(), + } + } + + /// Get all candidate IDs in this request + pub fn candidate_ids(&self) -> Vec { + match self { + Self::RateCandidates { candidates, .. } => candidates.iter().map(|c| c.id).collect(), + Self::PairwiseComparison { + candidate_a, + candidate_b, + .. + } => vec![candidate_a.id, candidate_b.id], + Self::BatchSelection { candidates, .. } => candidates.iter().map(|c| c.id).collect(), + } + } +} + +/// User response to an evaluation request +/// +/// Contains the user's feedback on the presented candidates. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum EvaluationResponse { + /// Ratings for candidates + /// + /// May be partial (not all candidates rated). Each entry is (candidate_id, rating). + Ratings(Vec<(CandidateId, f64)>), + + /// Winner of pairwise comparison + /// + /// `None` indicates a tie (if allowed). + PairwiseWinner(Option), + + /// Selected candidates from batch + /// + /// IDs of selected candidates. Order may indicate preference ranking. + BatchSelected(Vec), + + /// User chose to skip this evaluation + /// + /// No feedback provided for this request. + Skip, +} + +impl EvaluationResponse { + /// Create a ratings response + pub fn ratings(ratings: Vec<(CandidateId, f64)>) -> Self { + Self::Ratings(ratings) + } + + /// Create a pairwise winner response + pub fn winner(id: CandidateId) -> Self { + Self::PairwiseWinner(Some(id)) + } + + /// Create a tie response for pairwise comparison + pub fn tie() -> Self { + Self::PairwiseWinner(None) + } + + /// Create a batch selection response + pub fn selected(ids: Vec) -> Self { + Self::BatchSelected(ids) + } + + /// Create a skip response + pub fn skip() -> Self { + Self::Skip + } + + /// Check if this is a skip response + pub fn is_skip(&self) -> bool { + matches!(self, Self::Skip) + } + + /// Get the candidate IDs mentioned in this response + pub fn mentioned_ids(&self) -> Vec { + match self { + Self::Ratings(ratings) => ratings.iter().map(|(id, _)| *id).collect(), + Self::PairwiseWinner(Some(id)) => vec![*id], + Self::PairwiseWinner(None) => vec![], + Self::BatchSelected(ids) => ids.clone(), + Self::Skip => vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::genome::real_vector::RealVector; + + #[test] + fn test_candidate_id() { + let id1 = CandidateId(0); + let id2 = CandidateId(1); + let id3 = CandidateId(0); + + assert_eq!(id1, id3); + assert_ne!(id1, id2); + assert_eq!(format!("{}", id1), "Candidate(0)"); + } + + #[test] + fn test_candidate_creation() { + let genome = RealVector::new(vec![1.0, 2.0, 3.0]); + let candidate: Candidate = Candidate::new(CandidateId(0), genome); + + assert_eq!(candidate.id, CandidateId(0)); + assert!(candidate.fitness_estimate.is_none()); + assert!(!candidate.is_evaluated()); + assert_eq!(candidate.evaluation_count, 0); + } + + #[test] + fn test_candidate_evaluation() { + let genome = RealVector::new(vec![1.0, 2.0, 3.0]); + let mut candidate: Candidate = Candidate::new(CandidateId(0), genome); + + candidate.set_fitness(7.5); + candidate.record_evaluation(); + + assert_eq!(candidate.fitness(), Some(7.5)); + assert!(candidate.is_evaluated()); + assert_eq!(candidate.evaluation_count, 1); + } + + #[test] + fn test_rating_scale_validation() { + let scale = RatingScale::one_to_ten(); + + assert!(scale.validate(1.0)); + assert!(scale.validate(5.0)); + assert!(scale.validate(10.0)); + assert!(!scale.validate(0.0)); + assert!(!scale.validate(11.0)); + assert!(!scale.validate(5.5)); // Step is 1.0 + } + + #[test] + fn test_rating_scale_normalization() { + let scale = RatingScale::one_to_ten(); + + assert!((scale.normalize(1.0) - 0.0).abs() < 1e-9); + assert!((scale.normalize(5.5) - 0.5).abs() < 1e-9); + assert!((scale.normalize(10.0) - 1.0).abs() < 1e-9); + + assert!((scale.denormalize(0.0) - 1.0).abs() < 1e-9); + assert!((scale.denormalize(0.5) - 5.5).abs() < 1e-9); + assert!((scale.denormalize(1.0) - 10.0).abs() < 1e-9); + } + + #[test] + fn test_evaluation_request_rate() { + let c1: Candidate = Candidate::new(CandidateId(0), RealVector::new(vec![1.0])); + let c2: Candidate = Candidate::new(CandidateId(1), RealVector::new(vec![2.0])); + + let request = EvaluationRequest::rate(vec![c1, c2]); + assert_eq!(request.candidate_count(), 2); + assert_eq!( + request.candidate_ids(), + vec![CandidateId(0), CandidateId(1)] + ); + } + + #[test] + fn test_evaluation_request_compare() { + let c1: Candidate = Candidate::new(CandidateId(0), RealVector::new(vec![1.0])); + let c2: Candidate = Candidate::new(CandidateId(1), RealVector::new(vec![2.0])); + + let request = EvaluationRequest::compare(c1, c2); + assert_eq!(request.candidate_count(), 2); + } + + #[test] + fn test_evaluation_response() { + let response = EvaluationResponse::winner(CandidateId(0)); + assert_eq!(response.mentioned_ids(), vec![CandidateId(0)]); + + let response = EvaluationResponse::tie(); + assert!(response.mentioned_ids().is_empty()); + + let response = EvaluationResponse::skip(); + assert!(response.is_skip()); + } +} diff --git a/src/interactive/mod.rs b/src/interactive/mod.rs new file mode 100644 index 0000000..e39a734 --- /dev/null +++ b/src/interactive/mod.rs @@ -0,0 +1,74 @@ +//! Interactive Genetic Algorithm (IGA) module +//! +//! This module provides support for human-in-the-loop evolutionary optimization, +//! where fitness is derived from user preferences rather than an automated function. +//! +//! # Overview +//! +//! Interactive GAs are useful when: +//! - The fitness function cannot be easily formalized +//! - Human aesthetic judgment is needed (art, design, music generation) +//! - User preferences are subjective and vary per individual +//! +//! # Evaluation Modes +//! +//! The module supports three interaction paradigms: +//! +//! - **Rating**: Users assign numeric scores to individual candidates +//! - **Pairwise Comparison**: Users pick the better of two candidates +//! - **Batch Selection**: Users select their favorites from a presented batch +//! +//! # Example +//! +//! ```rust,ignore +//! use fugue_evo::interactive::prelude::*; +//! use fugue_evo::prelude::*; +//! +//! let mut iga = InteractiveGABuilder::::new() +//! .population_size(12) +//! .evaluation_mode(EvaluationMode::BatchSelection) +//! .batch_size(6) +//! .bounds(bounds) +//! .selection(TournamentSelection::new(2)) +//! .crossover(SbxCrossover::new(15.0)) +//! .mutation(PolynomialMutation::new(20.0)) +//! .build()?; +//! +//! loop { +//! match iga.step(&mut rng) { +//! StepResult::NeedsEvaluation(request) => { +//! let response = present_to_user(&request); +//! iga.provide_response(response); +//! } +//! StepResult::GenerationComplete { generation, .. } => { +//! println!("Generation {} complete", generation); +//! } +//! StepResult::Complete(result) => break, +//! } +//! } +//! ``` + +pub mod aggregation; +pub mod algorithm; +pub mod bradley_terry; +pub mod evaluator; +pub mod selection_strategy; +pub mod session; +pub mod traits; +pub mod uncertainty; + +/// Prelude for convenient imports +pub mod prelude { + pub use super::aggregation::{AggregationModel, CandidateStats, FitnessAggregator}; + pub use super::algorithm::{ + InteractiveGA, InteractiveGABuilder, InteractiveGAConfig, InteractiveResult, StepResult, + }; + pub use super::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer, BradleyTerryResult}; + pub use super::evaluator::{ + Candidate, CandidateId, EvaluationRequest, EvaluationResponse, RatingScale, + }; + pub use super::selection_strategy::SelectionStrategy; + pub use super::session::{CoverageStats, InteractiveSession}; + pub use super::traits::{EvaluationMode, InteractiveFitness}; + pub use super::uncertainty::FitnessEstimate; +} diff --git a/src/interactive/selection_strategy.rs b/src/interactive/selection_strategy.rs new file mode 100644 index 0000000..5358f9d --- /dev/null +++ b/src/interactive/selection_strategy.rs @@ -0,0 +1,803 @@ +//! Active learning strategies for intelligent candidate selection +//! +//! This module provides strategies for selecting which candidates to present +//! to users for evaluation. Instead of random or sequential selection, +//! active learning strategies prioritize candidates that will provide the +//! most useful information for ranking. +//! +//! # Available Strategies +//! +//! - **Sequential**: Default behavior - simple sequential/round-robin selection +//! - **UncertaintySampling**: Prioritize candidates with highest uncertainty +//! - **ExpectedInformationGain**: Select pairs that maximize information gain +//! - **CoverageAware**: Balance coverage requirements with exploration +//! +//! # Example +//! +//! ```rust,ignore +//! use fugue_evo::interactive::selection_strategy::SelectionStrategy; +//! +//! // Use uncertainty sampling with coverage bonus +//! let strategy = SelectionStrategy::UncertaintySampling { +//! uncertainty_weight: 1.0, +//! }; +//! +//! let selected = strategy.select_batch(&candidates, &aggregator, 4); +//! ``` + +use rand::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::aggregation::FitnessAggregator; +use super::evaluator::Candidate; +use super::uncertainty::FitnessEstimate; +use crate::genome::traits::EvolutionaryGenome; + +/// Active learning selection strategy +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum SelectionStrategy { + /// Sequential selection (current default behavior) + /// + /// Selects candidates in order of their index, cycling through + /// the population. Simple but not optimal for learning. + Sequential, + + /// Uncertainty sampling + /// + /// Prioritizes candidates with the highest uncertainty (variance) + /// in their fitness estimates. This helps reduce overall uncertainty + /// in the ranking. + UncertaintySampling { + /// Weight for uncertainty vs coverage balance (default: 1.0) + /// Higher values prioritize uncertain candidates more strongly + uncertainty_weight: f64, + }, + + /// Expected information gain (for pairwise comparisons) + /// + /// Selects pairs of candidates where the comparison result is + /// most uncertain (probability close to 0.5). This maximizes + /// the expected reduction in entropy. + ExpectedInformationGain { + /// Temperature for softmax selection (default: 1.0) + /// Higher values make selection more random + temperature: f64, + }, + + /// Coverage-aware selection + /// + /// Ensures minimum coverage before exploring uncertain candidates. + /// Good for balancing exploration with ensuring all candidates + /// are evaluated at least some minimum number of times. + CoverageAware { + /// Minimum evaluations before considering a candidate "covered" + min_evaluations: usize, + /// Bonus weight for under-evaluated candidates + exploration_bonus: f64, + }, +} + +impl Default for SelectionStrategy { + fn default() -> Self { + Self::Sequential + } +} + +impl SelectionStrategy { + /// Create uncertainty sampling strategy + pub fn uncertainty_sampling(uncertainty_weight: f64) -> Self { + Self::UncertaintySampling { uncertainty_weight } + } + + /// Create expected information gain strategy + pub fn information_gain(temperature: f64) -> Self { + Self::ExpectedInformationGain { temperature } + } + + /// Create coverage-aware strategy + pub fn coverage_aware(min_evaluations: usize, exploration_bonus: f64) -> Self { + Self::CoverageAware { + min_evaluations, + exploration_bonus, + } + } + + /// Select a batch of candidates for evaluation + /// + /// # Arguments + /// + /// * `candidates` - All candidates in the population + /// * `aggregator` - Fitness aggregator with current estimates + /// * `batch_size` - Number of candidates to select + /// * `rng` - Random number generator + /// + /// # Returns + /// + /// Indices of selected candidates (into the candidates slice) + pub fn select_batch( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + batch_size: usize, + rng: &mut R, + ) -> Vec + where + G: EvolutionaryGenome, + R: Rng, + { + if candidates.is_empty() || batch_size == 0 { + return vec![]; + } + + let batch_size = batch_size.min(candidates.len()); + + match self { + Self::Sequential => self.select_sequential(candidates, batch_size), + Self::UncertaintySampling { uncertainty_weight } => { + self.select_by_uncertainty(candidates, aggregator, batch_size, *uncertainty_weight) + } + Self::ExpectedInformationGain { temperature } => self.select_by_information_gain( + candidates, + aggregator, + batch_size, + *temperature, + rng, + ), + Self::CoverageAware { + min_evaluations, + exploration_bonus, + } => self.select_coverage_aware( + candidates, + aggregator, + batch_size, + *min_evaluations, + *exploration_bonus, + ), + } + } + + /// Select a pair for pairwise comparison + /// + /// # Arguments + /// + /// * `candidates` - All candidates in the population + /// * `aggregator` - Fitness aggregator with current estimates + /// * `rng` - Random number generator + /// + /// # Returns + /// + /// Tuple of indices for the two candidates to compare + pub fn select_pair( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + rng: &mut R, + ) -> Option<(usize, usize)> + where + G: EvolutionaryGenome, + R: Rng, + { + if candidates.len() < 2 { + return None; + } + + match self { + Self::Sequential => { + // Simple sequential pairing + Some((0, 1)) + } + Self::UncertaintySampling { .. } => { + // Select two most uncertain candidates + let scores = self.compute_uncertainty_scores(candidates, aggregator); + let mut indices: Vec = (0..candidates.len()).collect(); + indices.sort_by(|&a, &b| { + scores[b] + .partial_cmp(&scores[a]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Some((indices[0], indices[1])) + } + Self::ExpectedInformationGain { temperature } => { + self.select_pair_by_information_gain(candidates, aggregator, *temperature, rng) + } + Self::CoverageAware { + min_evaluations, .. + } => { + // Pair candidates with fewest evaluations + let mut indices: Vec<(usize, usize)> = candidates + .iter() + .enumerate() + .map(|(i, c)| (i, c.evaluation_count)) + .collect(); + indices.sort_by_key(|&(_, count)| count); + + let a = indices[0].0; + let b = if indices.len() > 1 { + // Find candidate with fewest evaluations that's also "close" in ranking + let a_eval = candidates[a].evaluation_count; + if a_eval < *min_evaluations { + // First pass: just pick two under-evaluated + indices[1].0 + } else { + // Pick most informative pair among adequately covered + self.find_informative_pair(candidates, aggregator, rng) + } + } else { + return None; + }; + Some((a, b)) + } + } + } + + /// Sequential selection - first N unevaluated, then first N overall + fn select_sequential(&self, candidates: &[Candidate], batch_size: usize) -> Vec + where + G: EvolutionaryGenome, + { + // First, select unevaluated candidates + let mut selected: Vec = candidates + .iter() + .enumerate() + .filter(|(_, c)| c.evaluation_count == 0) + .take(batch_size) + .map(|(i, _)| i) + .collect(); + + // If need more, add from beginning + if selected.len() < batch_size { + for i in 0..candidates.len() { + if selected.len() >= batch_size { + break; + } + if !selected.contains(&i) { + selected.push(i); + } + } + } + + selected + } + + /// Compute uncertainty scores for all candidates + fn compute_uncertainty_scores( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + ) -> Vec + where + G: EvolutionaryGenome, + { + candidates + .iter() + .map(|c| { + aggregator + .get_fitness_estimate(&c.id) + .map(|e| { + if e.variance.is_infinite() { + f64::MAX // Highest priority for unobserved + } else { + e.variance + } + }) + .unwrap_or(f64::MAX) + }) + .collect() + } + + /// Select by uncertainty (highest variance first) + fn select_by_uncertainty( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + batch_size: usize, + uncertainty_weight: f64, + ) -> Vec + where + G: EvolutionaryGenome, + { + let mut scores: Vec<(usize, f64)> = candidates + .iter() + .enumerate() + .map(|(i, c)| { + let uncertainty = aggregator + .get_fitness_estimate(&c.id) + .map(|e| { + if e.variance.is_infinite() { + f64::MAX + } else { + e.variance + } + }) + .unwrap_or(f64::MAX); + + // Bonus for fewer evaluations + let coverage_bonus = 1.0 / (c.evaluation_count as f64 + 1.0); + + let score = uncertainty_weight * uncertainty + coverage_bonus; + (i, score) + }) + .collect(); + + // Sort by score descending + scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + scores + .into_iter() + .take(batch_size) + .map(|(i, _)| i) + .collect() + } + + /// Select by expected information gain + fn select_by_information_gain( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + batch_size: usize, + temperature: f64, + rng: &mut R, + ) -> Vec + where + G: EvolutionaryGenome, + R: Rng, + { + // For batch selection, use a simplified approach: + // Score candidates by how uncertain their ranking position is + let estimates: Vec> = candidates + .iter() + .map(|c| aggregator.get_fitness_estimate(&c.id)) + .collect(); + + // Score each candidate by entropy of pairwise comparisons with others + let mut scores: Vec<(usize, f64)> = candidates + .iter() + .enumerate() + .map(|(i, _)| { + let my_est = &estimates[i]; + let score = estimates + .iter() + .enumerate() + .filter(|(j, _)| *j != i) + .map(|(_, other_est)| pairwise_entropy(my_est.as_ref(), other_est.as_ref())) + .sum::(); + (i, score) + }) + .collect(); + + if temperature > 0.0 { + // Softmax sampling + let max_score = scores + .iter() + .map(|(_, s)| *s) + .fold(f64::NEG_INFINITY, f64::max); + let weights: Vec = scores + .iter() + .map(|(_, s)| ((s - max_score) / temperature).exp()) + .collect(); + let total: f64 = weights.iter().sum(); + + let mut selected = Vec::with_capacity(batch_size); + let mut remaining: Vec<(usize, f64)> = scores + .iter() + .zip(weights.iter()) + .map(|((i, _), w)| (*i, *w / total)) + .collect(); + + for _ in 0..batch_size { + if remaining.is_empty() { + break; + } + + let r: f64 = rng.gen(); + let mut cumsum = 0.0; + let mut chosen_idx = 0; + + for (idx, (_, w)) in remaining.iter().enumerate() { + cumsum += w; + if r < cumsum { + chosen_idx = idx; + break; + } + } + + let (i, _) = remaining.remove(chosen_idx); + selected.push(i); + + // Renormalize remaining weights + let new_total: f64 = remaining.iter().map(|(_, w)| w).sum(); + if new_total > 0.0 { + for (_, w) in &mut remaining { + *w /= new_total; + } + } + } + + selected + } else { + // Deterministic: take top scorers + scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scores + .into_iter() + .take(batch_size) + .map(|(i, _)| i) + .collect() + } + } + + /// Select pair by information gain (for pairwise comparison mode) + fn select_pair_by_information_gain( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + temperature: f64, + rng: &mut R, + ) -> Option<(usize, usize)> + where + G: EvolutionaryGenome, + R: Rng, + { + let n = candidates.len(); + if n < 2 { + return None; + } + + let estimates: Vec> = candidates + .iter() + .map(|c| aggregator.get_fitness_estimate(&c.id)) + .collect(); + + // Compute information gain for each pair + let mut pair_scores: Vec<((usize, usize), f64)> = Vec::new(); + + for i in 0..n { + for j in (i + 1)..n { + let entropy = pairwise_entropy(estimates[i].as_ref(), estimates[j].as_ref()); + pair_scores.push(((i, j), entropy)); + } + } + + if pair_scores.is_empty() { + return Some((0, 1)); + } + + if temperature > 0.0 { + // Softmax selection + let max_score = pair_scores + .iter() + .map(|(_, s)| *s) + .fold(f64::NEG_INFINITY, f64::max); + let weights: Vec = pair_scores + .iter() + .map(|(_, s)| ((s - max_score) / temperature).exp()) + .collect(); + let total: f64 = weights.iter().sum(); + + let r: f64 = rng.gen(); + let mut cumsum = 0.0; + + for ((pair, _), w) in pair_scores.iter().zip(weights.iter()) { + cumsum += w / total; + if r < cumsum { + return Some(*pair); + } + } + } + + // Return highest scoring pair + pair_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + Some(pair_scores[0].0) + } + + /// Coverage-aware selection + fn select_coverage_aware( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + batch_size: usize, + min_evaluations: usize, + exploration_bonus: f64, + ) -> Vec + where + G: EvolutionaryGenome, + { + let mut scores: Vec<(usize, f64)> = candidates + .iter() + .enumerate() + .map(|(i, c)| { + let score = if c.evaluation_count < min_evaluations { + // Must evaluate - infinite priority + f64::MAX + } else { + // Base uncertainty + let uncertainty = aggregator + .get_fitness_estimate(&c.id) + .map(|e| { + if e.variance.is_infinite() { + 1e6 + } else { + e.variance + } + }) + .unwrap_or(1e6); + + // Exploration bonus for fewer evaluations + let bonus = exploration_bonus / (c.evaluation_count as f64 + 1.0); + + uncertainty + bonus + }; + (i, score) + }) + .collect(); + + scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + scores + .into_iter() + .take(batch_size) + .map(|(i, _)| i) + .collect() + } + + /// Find an informative pair among adequately covered candidates + fn find_informative_pair( + &self, + candidates: &[Candidate], + aggregator: &FitnessAggregator, + rng: &mut R, + ) -> usize + where + G: EvolutionaryGenome, + R: Rng, + { + // Find candidate whose ranking is most uncertain relative to others + let estimates: Vec> = candidates + .iter() + .map(|c| aggregator.get_fitness_estimate(&c.id)) + .collect(); + + let mut scores: Vec<(usize, f64)> = candidates + .iter() + .enumerate() + .map(|(i, _)| { + let score = estimates + .iter() + .enumerate() + .filter(|(j, _)| *j != i) + .map(|(_, other)| pairwise_entropy(estimates[i].as_ref(), other.as_ref())) + .sum::(); + (i, score) + }) + .collect(); + + scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Add some randomness to avoid always picking the same + let top_k = 3.min(scores.len()); + let chosen = rng.gen_range(0..top_k); + scores[chosen].0 + } +} + +/// Compute entropy of pairwise comparison outcome +/// +/// Entropy is maximized when P(A beats B) = 0.5 (most uncertain) +fn pairwise_entropy(a: Option<&FitnessEstimate>, b: Option<&FitnessEstimate>) -> f64 { + match (a, b) { + (Some(est_a), Some(est_b)) => { + // Approximate P(A beats B) using normal approximation + let mean_diff = est_a.mean - est_b.mean; + let var_diff = est_a.variance + est_b.variance; + + if var_diff.is_infinite() || var_diff <= 0.0 { + // Maximum entropy when we know nothing + return 1.0; + } + + // P(A > B) ≈ Φ((μ_A - μ_B) / sqrt(σ²_A + σ²_B)) + let z = mean_diff / var_diff.sqrt(); + let p = normal_cdf(z); + + // Binary entropy: -p*log(p) - (1-p)*log(1-p) + binary_entropy(p) + } + _ => 1.0, // Maximum entropy for unobserved + } +} + +/// Binary entropy: H(p) = -p*log(p) - (1-p)*log(1-p) +fn binary_entropy(p: f64) -> f64 { + let p = p.clamp(1e-10, 1.0 - 1e-10); + -(p * p.ln() + (1.0 - p) * (1.0 - p).ln()) +} + +/// Standard normal CDF approximation +fn normal_cdf(x: f64) -> f64 { + // Abramowitz and Stegun approximation + let a1 = 0.254829592; + let a2 = -0.284496736; + let a3 = 1.421413741; + let a4 = -1.453152027; + let a5 = 1.061405429; + let p = 0.3275911; + + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + let x = x.abs() / std::f64::consts::SQRT_2; + + let t = 1.0 / (1.0 + p * x); + let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp(); + + 0.5 * (1.0 + sign * y) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::genome::real_vector::RealVector; + use crate::interactive::aggregation::{AggregationModel, FitnessAggregator}; + use crate::interactive::evaluator::CandidateId; + + fn make_candidates(n: usize) -> Vec> { + (0..n) + .map(|i| { + let mut c = Candidate::new(CandidateId(i), RealVector::new(vec![i as f64])); + c.evaluation_count = 0; + c + }) + .collect() + } + + #[test] + fn test_sequential_selection() { + let candidates = make_candidates(10); + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut rng = rand::thread_rng(); + + let strategy = SelectionStrategy::Sequential; + let selected = strategy.select_batch(&candidates, &aggregator, 3, &mut rng); + + assert_eq!(selected.len(), 3); + // Should select first 3 (all unevaluated) + assert!(selected.contains(&0)); + assert!(selected.contains(&1)); + assert!(selected.contains(&2)); + } + + #[test] + fn test_uncertainty_sampling() { + let mut candidates = make_candidates(5); + let mut aggregator = FitnessAggregator::new(AggregationModel::DirectRating { + default_rating: 5.0, + }); + let mut rng = rand::thread_rng(); + + // Give candidate 0 multiple identical ratings (low variance) + aggregator.record_rating(CandidateId(0), 7.0); + aggregator.record_rating(CandidateId(0), 7.0); + aggregator.record_rating(CandidateId(0), 7.0); + candidates[0].evaluation_count = 3; + + // Give candidate 1 multiple varied ratings (medium variance) + aggregator.record_rating(CandidateId(1), 4.0); + aggregator.record_rating(CandidateId(1), 8.0); + candidates[1].evaluation_count = 2; + + // Candidates 2, 3, 4 are unevaluated (highest uncertainty) + + let strategy = SelectionStrategy::UncertaintySampling { + uncertainty_weight: 1.0, + }; + let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng); + + // Should select high-uncertainty candidates, NOT the well-evaluated candidate 0 + assert_eq!(selected.len(), 2); + for &idx in &selected { + assert!( + idx != 0, + "Should not select the well-evaluated candidate with low variance" + ); + } + } + + #[test] + fn test_coverage_aware() { + let mut candidates = make_candidates(5); + candidates[0].evaluation_count = 3; + candidates[1].evaluation_count = 2; + candidates[2].evaluation_count = 0; // Under min + candidates[3].evaluation_count = 0; // Under min + candidates[4].evaluation_count = 1; + + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut rng = rand::thread_rng(); + + let strategy = SelectionStrategy::CoverageAware { + min_evaluations: 2, + exploration_bonus: 1.0, + }; + let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng); + + // Should prioritize candidates 2 and 3 (under min coverage) + assert!(selected.contains(&2) || selected.contains(&3)); + } + + #[test] + fn test_select_pair_sequential() { + let candidates = make_candidates(5); + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut rng = rand::thread_rng(); + + let strategy = SelectionStrategy::Sequential; + let pair = strategy.select_pair(&candidates, &aggregator, &mut rng); + + assert!(pair.is_some()); + let (a, b) = pair.unwrap(); + assert_ne!(a, b); + } + + #[test] + fn test_select_pair_info_gain() { + let candidates = make_candidates(5); + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut rng = rand::thread_rng(); + + let strategy = SelectionStrategy::ExpectedInformationGain { temperature: 1.0 }; + let pair = strategy.select_pair(&candidates, &aggregator, &mut rng); + + assert!(pair.is_some()); + let (a, b) = pair.unwrap(); + assert_ne!(a, b); + } + + #[test] + fn test_binary_entropy() { + // Max entropy at p = 0.5 + let max_entropy = binary_entropy(0.5); + assert!((max_entropy - std::f64::consts::LN_2).abs() < 1e-6); + + // Zero entropy at p = 0 or 1 + assert!(binary_entropy(0.001) < 0.1); + assert!(binary_entropy(0.999) < 0.1); + } + + #[test] + fn test_normal_cdf() { + // CDF(0) = 0.5 + assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6); + + // CDF(-∞) → 0, CDF(+∞) → 1 + assert!(normal_cdf(-10.0) < 0.001); + assert!(normal_cdf(10.0) > 0.999); + + // Symmetry + assert!((normal_cdf(1.0) + normal_cdf(-1.0) - 1.0).abs() < 1e-6); + } + + #[test] + fn test_empty_candidates() { + let candidates: Vec> = vec![]; + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut rng = rand::thread_rng(); + + let strategy = SelectionStrategy::default(); + let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng); + assert!(selected.is_empty()); + + let pair = strategy.select_pair(&candidates, &aggregator, &mut rng); + assert!(pair.is_none()); + } + + #[test] + fn test_single_candidate() { + let candidates = make_candidates(1); + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut rng = rand::thread_rng(); + + let strategy = SelectionStrategy::default(); + let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng); + assert_eq!(selected.len(), 1); + + let pair = strategy.select_pair(&candidates, &aggregator, &mut rng); + assert!(pair.is_none()); // Can't make a pair from 1 candidate + } +} diff --git a/src/interactive/session.rs b/src/interactive/session.rs new file mode 100644 index 0000000..1b285ca --- /dev/null +++ b/src/interactive/session.rs @@ -0,0 +1,543 @@ +//! Session state management for interactive evolution +//! +//! This module provides serializable session state that allows pausing +//! and resuming interactive evolution sessions. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, BufWriter}; +use std::path::Path; + +use super::aggregation::FitnessAggregator; +use super::evaluator::{Candidate, CandidateId, EvaluationRequest}; +use super::uncertainty::FitnessEstimate; +use crate::error::CheckpointError; +use crate::genome::traits::EvolutionaryGenome; + +/// Current session format version +pub const SESSION_VERSION: u32 = 1; + +/// Statistics about evaluation coverage in a session +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct CoverageStats { + /// Fraction of population with at least one evaluation (0.0 to 1.0) + pub coverage: f64, + /// Average evaluations per candidate + pub avg_evaluations: f64, + /// Minimum evaluations for any candidate + pub min_evaluations: usize, + /// Maximum evaluations for any candidate + pub max_evaluations: usize, + /// Number of candidates with zero evaluations + pub unevaluated_count: usize, + /// Total population size + pub population_size: usize, +} + +impl CoverageStats { + /// Check if coverage meets minimum threshold + pub fn meets_threshold(&self, min_coverage: f64) -> bool { + self.coverage >= min_coverage + } +} + +/// Complete state of an interactive evolution session +/// +/// This struct captures all state needed to pause and resume an +/// interactive evolution session, including population, fitness +/// aggregator state, and session metadata. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(bound = "G: Serialize + for<'a> Deserialize<'a>")] +pub struct InteractiveSession +where + G: EvolutionaryGenome, +{ + /// Schema version for forward compatibility + pub version: u32, + /// Current population with fitness estimates + pub population: Vec>, + /// Current generation number + pub generation: usize, + /// Total evaluation requests made + pub evaluations_requested: usize, + /// Total responses received (excluding skips) + pub responses_received: usize, + /// Number of skipped evaluations + pub skipped: usize, + /// Fitness aggregator state + pub aggregator: FitnessAggregator, + /// History of evaluation requests (limited to recent history) + pub request_history: Vec, + /// Custom session metadata + pub metadata: HashMap, + /// Next candidate ID to assign + pub next_candidate_id: usize, +} + +/// Serialized form of an evaluation request (without genome data) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SerializedRequest { + /// Type of request + pub request_type: String, + /// Candidate IDs involved + pub candidate_ids: Vec, + /// Generation when request was made + pub generation: usize, + /// Whether this request was skipped + pub was_skipped: bool, +} + +impl InteractiveSession +where + G: EvolutionaryGenome, +{ + /// Create a new empty session + pub fn new(aggregator: FitnessAggregator) -> Self { + Self { + version: SESSION_VERSION, + population: Vec::new(), + generation: 0, + evaluations_requested: 0, + responses_received: 0, + skipped: 0, + aggregator, + request_history: Vec::new(), + metadata: HashMap::new(), + next_candidate_id: 0, + } + } + + /// Create a new session with initial population + pub fn with_population(population: Vec>, aggregator: FitnessAggregator) -> Self { + let next_id = population.iter().map(|c| c.id.0).max().unwrap_or(0) + 1; + Self { + version: SESSION_VERSION, + population, + generation: 0, + evaluations_requested: 0, + responses_received: 0, + skipped: 0, + aggregator, + request_history: Vec::new(), + metadata: HashMap::new(), + next_candidate_id: next_id, + } + } + + /// Get the next candidate ID and increment counter + pub fn next_id(&mut self) -> CandidateId { + let id = CandidateId(self.next_candidate_id); + self.next_candidate_id += 1; + id + } + + /// Add a candidate to the population + pub fn add_candidate(&mut self, genome: G) -> CandidateId { + let id = self.next_id(); + let candidate = Candidate::with_generation(id, genome, self.generation); + self.population.push(candidate); + id + } + + /// Get a candidate by ID + pub fn get_candidate(&self, id: CandidateId) -> Option<&Candidate> { + self.population.iter().find(|c| c.id == id) + } + + /// Get a mutable reference to a candidate by ID + pub fn get_candidate_mut(&mut self, id: CandidateId) -> Option<&mut Candidate> { + self.population.iter_mut().find(|c| c.id == id) + } + + /// Get all candidates that haven't been evaluated + pub fn unevaluated_candidates(&self) -> Vec<&Candidate> { + self.population + .iter() + .filter(|c| !c.is_evaluated()) + .collect() + } + + /// Get candidates sorted by fitness (best first) + pub fn ranked_candidates(&self) -> Vec<&Candidate> { + let mut candidates: Vec<_> = self.population.iter().collect(); + candidates.sort_by(|a, b| { + let fa = a.fitness_estimate.unwrap_or(f64::NEG_INFINITY); + let fb = b.fitness_estimate.unwrap_or(f64::NEG_INFINITY); + fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal) + }); + candidates + } + + /// Get the best candidate + pub fn best_candidate(&self) -> Option<&Candidate> { + self.population + .iter() + .filter(|c| c.fitness_estimate.is_some()) + .max_by(|a, b| { + let fa = a.fitness_estimate.unwrap(); + let fb = b.fitness_estimate.unwrap(); + fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal) + }) + } + + /// Calculate coverage statistics + pub fn coverage_stats(&self) -> CoverageStats { + if self.population.is_empty() { + return CoverageStats::default(); + } + + let eval_counts: Vec = self.population.iter().map(|c| c.evaluation_count).collect(); + + let evaluated = eval_counts.iter().filter(|&&c| c > 0).count(); + let total_evals: usize = eval_counts.iter().sum(); + + CoverageStats { + coverage: evaluated as f64 / self.population.len() as f64, + avg_evaluations: total_evals as f64 / self.population.len() as f64, + min_evaluations: eval_counts.iter().copied().min().unwrap_or(0), + max_evaluations: eval_counts.iter().copied().max().unwrap_or(0), + unevaluated_count: self.population.len() - evaluated, + population_size: self.population.len(), + } + } + + /// Record that an evaluation request was made + pub fn record_request(&mut self, request: &EvaluationRequest) { + self.evaluations_requested += 1; + + let serialized = SerializedRequest { + request_type: match request { + EvaluationRequest::RateCandidates { .. } => "rating".to_string(), + EvaluationRequest::PairwiseComparison { .. } => "pairwise".to_string(), + EvaluationRequest::BatchSelection { .. } => "batch".to_string(), + }, + candidate_ids: request.candidate_ids(), + generation: self.generation, + was_skipped: false, + }; + + // Keep limited history + const MAX_HISTORY: usize = 1000; + if self.request_history.len() >= MAX_HISTORY { + self.request_history.remove(0); + } + self.request_history.push(serialized); + } + + /// Record that a response was received + pub fn record_response(&mut self, was_skipped: bool) { + if was_skipped { + self.skipped += 1; + if let Some(last) = self.request_history.last_mut() { + last.was_skipped = true; + } + } else { + self.responses_received += 1; + } + } + + /// Advance to the next generation + pub fn advance_generation(&mut self) { + self.generation += 1; + self.aggregator.set_generation(self.generation); + } + + /// Update fitness estimate for a candidate + pub fn update_fitness(&mut self, id: CandidateId, fitness: f64) { + if let Some(candidate) = self.get_candidate_mut(id) { + candidate.set_fitness(fitness); + candidate.record_evaluation(); + } + } + + /// Update fitness with full uncertainty information + pub fn update_fitness_with_uncertainty(&mut self, id: CandidateId, estimate: FitnessEstimate) { + if let Some(candidate) = self.get_candidate_mut(id) { + candidate.set_fitness_with_uncertainty(estimate); + candidate.record_evaluation(); + } + } + + /// Sync candidate fitness estimates from the aggregator + /// + /// Updates all candidates with their current fitness estimates including uncertainty. + /// Call this after processing responses to ensure candidates have up-to-date estimates. + pub fn sync_fitness_estimates(&mut self) { + for candidate in &mut self.population { + if let Some(estimate) = self.aggregator.get_fitness_estimate(&candidate.id) { + candidate.fitness_estimate = Some(estimate.mean); + candidate.fitness_with_uncertainty = Some(estimate); + } + } + } + + /// Get fitness estimates with uncertainty for all candidates + /// + /// Returns a vector of (CandidateId, FitnessEstimate) pairs. + pub fn all_fitness_estimates(&self) -> Vec<(CandidateId, FitnessEstimate)> { + self.population + .iter() + .filter_map(|c| { + self.aggregator + .get_fitness_estimate(&c.id) + .map(|e| (c.id, e)) + }) + .collect() + } + + /// Get candidates sorted by uncertainty (most uncertain first) + /// + /// Useful for identifying which candidates need more evaluation. + pub fn candidates_by_uncertainty(&self) -> Vec<&Candidate> { + let mut candidates: Vec<_> = self.population.iter().collect(); + candidates.sort_by(|a, b| { + let var_a = self + .aggregator + .get_fitness_estimate(&a.id) + .map(|e| e.variance) + .unwrap_or(f64::INFINITY); + let var_b = self + .aggregator + .get_fitness_estimate(&b.id) + .map(|e| e.variance) + .unwrap_or(f64::INFINITY); + // Sort descending - most uncertain first + var_b + .partial_cmp(&var_a) + .unwrap_or(std::cmp::Ordering::Equal) + }); + candidates + } + + /// Get the average uncertainty across all candidates + pub fn average_uncertainty(&self) -> f64 { + let estimates: Vec<_> = self + .population + .iter() + .filter_map(|c| self.aggregator.get_fitness_estimate(&c.id)) + .collect(); + + if estimates.is_empty() { + return f64::INFINITY; + } + + let total_variance: f64 = estimates + .iter() + .map(|e| { + if e.variance.is_finite() { + e.variance + } else { + 1e6 // Large but finite for averaging + } + }) + .sum(); + + total_variance / estimates.len() as f64 + } + + /// Replace the population with new candidates + pub fn replace_population(&mut self, new_population: Vec>) { + let max_id = new_population.iter().map(|c| c.id.0).max().unwrap_or(0); + self.next_candidate_id = max_id + 1; + self.population = new_population; + } + + /// Add metadata to the session + pub fn set_metadata(&mut self, key: impl Into, value: impl Into) { + self.metadata.insert(key.into(), value.into()); + } + + /// Get metadata value + pub fn get_metadata(&self, key: &str) -> Option<&String> { + self.metadata.get(key) + } + + /// Get response rate (responses / requests) + pub fn response_rate(&self) -> f64 { + if self.evaluations_requested > 0 { + self.responses_received as f64 / self.evaluations_requested as f64 + } else { + 0.0 + } + } + + /// Get skip rate (skips / requests) + pub fn skip_rate(&self) -> f64 { + if self.evaluations_requested > 0 { + self.skipped as f64 / self.evaluations_requested as f64 + } else { + 0.0 + } + } +} + +impl InteractiveSession +where + G: EvolutionaryGenome + Serialize + for<'de> Deserialize<'de>, +{ + /// Save session to a file + pub fn save(&self, path: &Path) -> Result<(), CheckpointError> { + let file = File::create(path)?; + let writer = BufWriter::new(file); + serde_json::to_writer_pretty(writer, self).map_err(|e| { + CheckpointError::Serialization(format!("Failed to serialize session: {}", e)) + })?; + Ok(()) + } + + /// Load session from a file + pub fn load(path: &Path) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let session: Self = serde_json::from_reader(reader).map_err(|e| { + CheckpointError::Deserialization(format!("Failed to deserialize session: {}", e)) + })?; + + // Check version compatibility + if session.version > SESSION_VERSION { + return Err(CheckpointError::VersionTooNew(session.version)); + } + + Ok(session) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::genome::real_vector::RealVector; + use crate::interactive::aggregation::AggregationModel; + + #[test] + fn test_session_creation() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let session: InteractiveSession = InteractiveSession::new(aggregator); + + assert_eq!(session.generation, 0); + assert!(session.population.is_empty()); + assert_eq!(session.evaluations_requested, 0); + } + + #[test] + fn test_add_candidate() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + let genome = RealVector::new(vec![1.0, 2.0, 3.0]); + let id = session.add_candidate(genome); + + assert_eq!(id, CandidateId(0)); + assert_eq!(session.population.len(), 1); + assert_eq!(session.get_candidate(id).unwrap().birth_generation, 0); + } + + #[test] + fn test_coverage_stats() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + // Add 4 candidates + for i in 0..4 { + session.add_candidate(RealVector::new(vec![i as f64])); + } + + // Evaluate 2 of them + session.population[0].record_evaluation(); + session.population[1].record_evaluation(); + session.population[1].record_evaluation(); // Evaluate twice + + let stats = session.coverage_stats(); + + assert_eq!(stats.population_size, 4); + assert_eq!(stats.coverage, 0.5); + assert_eq!(stats.unevaluated_count, 2); + assert_eq!(stats.min_evaluations, 0); + assert_eq!(stats.max_evaluations, 2); + } + + #[test] + fn test_ranked_candidates() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + for i in 0..3 { + let id = session.add_candidate(RealVector::new(vec![i as f64])); + session.update_fitness(id, i as f64 * 10.0); + } + + let ranked = session.ranked_candidates(); + assert_eq!(ranked[0].fitness_estimate, Some(20.0)); // Best first + assert_eq!(ranked[2].fitness_estimate, Some(0.0)); // Worst last + } + + #[test] + fn test_advance_generation() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + session.advance_generation(); + assert_eq!(session.generation, 1); + + let id = session.add_candidate(RealVector::new(vec![1.0])); + assert_eq!(session.get_candidate(id).unwrap().birth_generation, 1); + } + + #[test] + fn test_response_tracking() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + let c1: Candidate = Candidate::new(CandidateId(0), RealVector::new(vec![1.0])); + let request = EvaluationRequest::rate(vec![c1]); + session.record_request(&request); + session.record_response(false); + + session.record_request(&request); + session.record_response(true); // Skip + + assert_eq!(session.evaluations_requested, 2); + assert_eq!(session.responses_received, 1); + assert_eq!(session.skipped, 1); + assert_eq!(session.response_rate(), 0.5); + assert_eq!(session.skip_rate(), 0.5); + } + + #[test] + fn test_metadata() { + let aggregator = FitnessAggregator::new(AggregationModel::default()); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + session.set_metadata("experiment", "test_run"); + session.set_metadata("user", "alice"); + + assert_eq!( + session.get_metadata("experiment"), + Some(&"test_run".to_string()) + ); + assert_eq!(session.get_metadata("user"), Some(&"alice".to_string())); + assert_eq!(session.get_metadata("missing"), None); + } + + #[test] + fn test_session_serialization() { + let aggregator = FitnessAggregator::new(AggregationModel::DirectRating { + default_rating: 5.0, + }); + let mut session: InteractiveSession = InteractiveSession::new(aggregator); + + session.add_candidate(RealVector::new(vec![1.0, 2.0])); + session.add_candidate(RealVector::new(vec![3.0, 4.0])); + session.set_metadata("test", "value"); + + // Serialize to JSON + let json = serde_json::to_string(&session).expect("Failed to serialize"); + + // Deserialize back + let loaded: InteractiveSession = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(loaded.population.len(), 2); + assert_eq!(loaded.get_metadata("test"), Some(&"value".to_string())); + } +} diff --git a/src/interactive/traits.rs b/src/interactive/traits.rs new file mode 100644 index 0000000..80400a2 --- /dev/null +++ b/src/interactive/traits.rs @@ -0,0 +1,339 @@ +//! Interactive fitness traits +//! +//! This module defines the `InteractiveFitness` trait for human-in-the-loop +//! fitness evaluation, as well as supporting types for evaluation modes. + +use serde::{Deserialize, Serialize}; + +use super::aggregation::FitnessAggregator; +use super::evaluator::{Candidate, CandidateId, EvaluationRequest, EvaluationResponse}; +use crate::genome::traits::EvolutionaryGenome; + +/// Evaluation mode for interactive fitness +/// +/// Determines how user feedback is collected during evolution. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EvaluationMode { + /// User rates each candidate independently on a numeric scale + /// + /// Best for: Absolute quality assessment, when users can easily assign scores + Rating, + + /// User compares pairs of candidates and selects the better one + /// + /// Best for: When relative comparisons are easier than absolute ratings, + /// provides consistent transitive preferences + Pairwise, + + /// User selects top N favorites from a batch + /// + /// Best for: Quick evaluation of many candidates, implicit ranking + BatchSelection, + + /// System chooses evaluation mode adaptively based on population state + /// + /// May switch between modes based on coverage, convergence, or user fatigue + Adaptive, +} + +impl EvaluationMode { + /// Returns a human-readable description of this mode + pub fn description(&self) -> &'static str { + match self { + Self::Rating => "Rate each candidate on a numeric scale", + Self::Pairwise => "Compare pairs and select the better one", + Self::BatchSelection => "Select favorites from a batch", + Self::Adaptive => "System adapts evaluation method automatically", + } + } +} + +impl Default for EvaluationMode { + fn default() -> Self { + Self::Rating + } +} + +/// Trait for interactive fitness evaluation +/// +/// Unlike the synchronous [`Fitness`](crate::fitness::traits::Fitness) trait that returns +/// immediate values, `InteractiveFitness` generates evaluation requests that must +/// be fulfilled by user interaction. +/// +/// # Design +/// +/// The trait is designed around a request/response pattern: +/// 1. Algorithm calls `request_evaluation()` with candidates needing feedback +/// 2. UI presents the request to the user and collects their response +/// 3. Algorithm calls `process_response()` to update fitness estimates +/// +/// # Example Implementation +/// +/// ```rust,ignore +/// use fugue_evo::interactive::prelude::*; +/// +/// struct ArtFitness { +/// mode: EvaluationMode, +/// } +/// +/// impl InteractiveFitness for ArtFitness { +/// type Genome = MyArtGenome; +/// +/// fn evaluation_mode(&self) -> EvaluationMode { +/// self.mode +/// } +/// +/// fn request_evaluation( +/// &self, +/// candidates: &[Candidate], +/// ) -> EvaluationRequest { +/// match self.mode { +/// EvaluationMode::Rating => { +/// EvaluationRequest::rate(candidates.to_vec()) +/// } +/// EvaluationMode::BatchSelection => { +/// EvaluationRequest::select_from_batch(candidates.to_vec(), 3) +/// } +/// _ => unimplemented!() +/// } +/// } +/// +/// fn process_response( +/// &mut self, +/// response: EvaluationResponse, +/// aggregator: &mut FitnessAggregator, +/// ) -> Vec<(CandidateId, f64)> { +/// // Delegate to aggregator for standard processing +/// aggregator.process_response(&response) +/// } +/// } +/// ``` +pub trait InteractiveFitness: Send + Sync { + /// The genome type being evaluated + type Genome: EvolutionaryGenome; + + /// Get the preferred evaluation mode for this fitness function + fn evaluation_mode(&self) -> EvaluationMode; + + /// Generate an evaluation request for the given candidates + /// + /// The returned request will be presented to the user for feedback. + /// The implementation should select candidates appropriately for the + /// current evaluation mode. + fn request_evaluation( + &self, + candidates: &[Candidate], + ) -> EvaluationRequest; + + /// Process user response and update fitness estimates + /// + /// Returns the updated fitness values for affected candidates. + /// The aggregator maintains cumulative statistics and should be + /// used for fitness computation. + fn process_response( + &mut self, + response: EvaluationResponse, + aggregator: &mut FitnessAggregator, + ) -> Vec<(CandidateId, f64)>; + + /// Optional: Called at the start of each generation + /// + /// Allows the fitness function to adjust strategy based on + /// population state or user fatigue. + fn on_generation_start(&mut self, _generation: usize, _population_size: usize) {} + + /// Optional: Called when an evaluation is skipped + /// + /// Allows tracking of user fatigue or disengagement. + fn on_evaluation_skipped(&mut self) {} +} + +/// Default interactive fitness implementation using a fixed evaluation mode +/// +/// This provides a simple implementation that delegates all processing +/// to the fitness aggregator. Suitable for most use cases. +#[derive(Clone, Debug)] +pub struct DefaultInteractiveFitness +where + G: EvolutionaryGenome, +{ + mode: EvaluationMode, + batch_size: usize, + select_count: usize, + _marker: std::marker::PhantomData, +} + +impl DefaultInteractiveFitness +where + G: EvolutionaryGenome, +{ + /// Create a new default interactive fitness with the given mode + pub fn new(mode: EvaluationMode) -> Self { + Self { + mode, + batch_size: 6, + select_count: 2, + _marker: std::marker::PhantomData, + } + } + + /// Set the batch size for batch selection mode + pub fn with_batch_size(mut self, size: usize) -> Self { + self.batch_size = size; + self + } + + /// Set how many candidates to select in batch selection mode + pub fn with_select_count(mut self, count: usize) -> Self { + self.select_count = count; + self + } +} + +impl Default for DefaultInteractiveFitness +where + G: EvolutionaryGenome, +{ + fn default() -> Self { + Self::new(EvaluationMode::Rating) + } +} + +impl InteractiveFitness for DefaultInteractiveFitness +where + G: EvolutionaryGenome + Clone + Send + Sync, +{ + type Genome = G; + + fn evaluation_mode(&self) -> EvaluationMode { + self.mode + } + + fn request_evaluation( + &self, + candidates: &[Candidate], + ) -> EvaluationRequest { + match self.mode { + EvaluationMode::Rating => EvaluationRequest::rate(candidates.to_vec()), + EvaluationMode::Pairwise => { + // Select two candidates for comparison + if candidates.len() >= 2 { + EvaluationRequest::compare(candidates[0].clone(), candidates[1].clone()) + } else if candidates.len() == 1 { + // Fall back to rating if only one candidate + EvaluationRequest::rate(candidates.to_vec()) + } else { + EvaluationRequest::rate(vec![]) + } + } + EvaluationMode::BatchSelection => { + let batch: Vec<_> = candidates.iter().take(self.batch_size).cloned().collect(); + EvaluationRequest::select_from_batch(batch, self.select_count) + } + EvaluationMode::Adaptive => { + // Default to rating for adaptive mode + EvaluationRequest::rate(candidates.to_vec()) + } + } + } + + fn process_response( + &mut self, + response: EvaluationResponse, + aggregator: &mut FitnessAggregator, + ) -> Vec<(CandidateId, f64)> { + aggregator.process_response(&response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::genome::real_vector::RealVector; + use crate::interactive::aggregation::AggregationModel; + + #[test] + fn test_evaluation_mode_default() { + assert_eq!(EvaluationMode::default(), EvaluationMode::Rating); + } + + #[test] + fn test_evaluation_mode_description() { + assert!(!EvaluationMode::Rating.description().is_empty()); + assert!(!EvaluationMode::Pairwise.description().is_empty()); + assert!(!EvaluationMode::BatchSelection.description().is_empty()); + assert!(!EvaluationMode::Adaptive.description().is_empty()); + } + + #[test] + fn test_default_interactive_fitness_rating() { + let fitness: DefaultInteractiveFitness = + DefaultInteractiveFitness::new(EvaluationMode::Rating); + + let c1 = Candidate::new(CandidateId(0), RealVector::new(vec![1.0])); + let c2 = Candidate::new(CandidateId(1), RealVector::new(vec![2.0])); + + let request = fitness.request_evaluation(&[c1, c2]); + match request { + EvaluationRequest::RateCandidates { candidates, .. } => { + assert_eq!(candidates.len(), 2); + } + _ => panic!("Expected RateCandidates request"), + } + } + + #[test] + fn test_default_interactive_fitness_pairwise() { + let fitness: DefaultInteractiveFitness = + DefaultInteractiveFitness::new(EvaluationMode::Pairwise); + + let c1 = Candidate::new(CandidateId(0), RealVector::new(vec![1.0])); + let c2 = Candidate::new(CandidateId(1), RealVector::new(vec![2.0])); + + let request = fitness.request_evaluation(&[c1, c2]); + match request { + EvaluationRequest::PairwiseComparison { .. } => {} + _ => panic!("Expected PairwiseComparison request"), + } + } + + #[test] + fn test_default_interactive_fitness_batch() { + let fitness: DefaultInteractiveFitness = + DefaultInteractiveFitness::new(EvaluationMode::BatchSelection) + .with_batch_size(4) + .with_select_count(2); + + let candidates: Vec<_> = (0..6) + .map(|i| Candidate::new(CandidateId(i), RealVector::new(vec![i as f64]))) + .collect(); + + let request = fitness.request_evaluation(&candidates); + match request { + EvaluationRequest::BatchSelection { + candidates, + select_count, + .. + } => { + assert_eq!(candidates.len(), 4); // batch_size + assert_eq!(select_count, 2); + } + _ => panic!("Expected BatchSelection request"), + } + } + + #[test] + fn test_default_interactive_fitness_process_response() { + let mut fitness: DefaultInteractiveFitness = + DefaultInteractiveFitness::new(EvaluationMode::Rating); + let mut aggregator = FitnessAggregator::new(AggregationModel::DirectRating { + default_rating: 5.0, + }); + + let response = + EvaluationResponse::ratings(vec![(CandidateId(0), 8.0), (CandidateId(1), 6.0)]); + + let updated = fitness.process_response(response, &mut aggregator); + assert_eq!(updated.len(), 2); + } +} diff --git a/src/interactive/uncertainty.rs b/src/interactive/uncertainty.rs new file mode 100644 index 0000000..9b2e1c5 --- /dev/null +++ b/src/interactive/uncertainty.rs @@ -0,0 +1,407 @@ +//! Uncertainty quantification for fitness estimates +//! +//! This module provides types and utilities for representing fitness estimates +//! with associated uncertainty (variance and confidence intervals). + +use serde::{Deserialize, Serialize}; + +/// Full fitness estimate with uncertainty quantification +/// +/// Represents a fitness value along with its statistical uncertainty, +/// including variance and confidence intervals. This enables informed +/// decision-making about which candidates need more evaluation. +/// +/// # Example +/// +/// ```rust +/// use fugue_evo::interactive::uncertainty::FitnessEstimate; +/// +/// let estimate = FitnessEstimate::new(7.5, 0.25, 10); +/// println!("Fitness: {:.2} ± {:.2}", estimate.mean, estimate.std_error()); +/// println!("95% CI: [{:.2}, {:.2}]", estimate.ci_lower, estimate.ci_upper); +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FitnessEstimate { + /// Point estimate (mean fitness) + pub mean: f64, + /// Variance of the estimate + pub variance: f64, + /// Lower bound of confidence interval (default 95%) + pub ci_lower: f64, + /// Upper bound of confidence interval + pub ci_upper: f64, + /// Number of observations contributing to this estimate + pub observation_count: usize, +} + +impl FitnessEstimate { + /// Z-score for 95% confidence interval + const Z_95: f64 = 1.96; + + /// Create a new fitness estimate from mean and variance + /// + /// Automatically computes 95% confidence intervals from the variance. + /// + /// # Arguments + /// + /// * `mean` - Point estimate of fitness + /// * `variance` - Variance of the estimate (not the population variance) + /// * `observation_count` - Number of observations used to compute the estimate + pub fn new(mean: f64, variance: f64, observation_count: usize) -> Self { + let std_err = variance.sqrt().max(0.0); + Self { + mean, + variance, + ci_lower: mean - Self::Z_95 * std_err, + ci_upper: mean + Self::Z_95 * std_err, + observation_count, + } + } + + /// Create estimate with custom confidence level + /// + /// # Arguments + /// + /// * `mean` - Point estimate + /// * `variance` - Variance of the estimate + /// * `observation_count` - Number of observations + /// * `z_score` - Z-score for desired confidence level (e.g., 1.96 for 95%, 2.576 for 99%) + pub fn with_confidence( + mean: f64, + variance: f64, + observation_count: usize, + z_score: f64, + ) -> Self { + let std_err = variance.sqrt().max(0.0); + Self { + mean, + variance, + ci_lower: mean - z_score * std_err, + ci_upper: mean + z_score * std_err, + observation_count, + } + } + + /// Create an uninformative estimate (infinite variance) + /// + /// Used for candidates with no evaluations. + pub fn uninformative(default_mean: f64) -> Self { + Self { + mean: default_mean, + variance: f64::INFINITY, + ci_lower: f64::NEG_INFINITY, + ci_upper: f64::INFINITY, + observation_count: 0, + } + } + + /// Standard error (sqrt of variance) + pub fn std_error(&self) -> f64 { + self.variance.sqrt() + } + + /// Width of the confidence interval + pub fn ci_width(&self) -> f64 { + self.ci_upper - self.ci_lower + } + + /// Check if confidence interval contains a value + pub fn ci_contains(&self, value: f64) -> bool { + value >= self.ci_lower && value <= self.ci_upper + } + + /// Check if this estimate overlaps with another's confidence interval + pub fn ci_overlaps(&self, other: &FitnessEstimate) -> bool { + self.ci_lower <= other.ci_upper && self.ci_upper >= other.ci_lower + } + + /// Check if this estimate is significantly better than another + /// + /// Returns true if the lower bound of this estimate's CI is above + /// the upper bound of the other's CI. + pub fn significantly_better_than(&self, other: &FitnessEstimate) -> bool { + self.ci_lower > other.ci_upper + } + + /// Check if this estimate has high uncertainty (needs more data) + /// + /// Returns true if variance is infinite or observation count is below threshold. + pub fn is_uncertain(&self, min_observations: usize) -> bool { + self.variance.is_infinite() || self.observation_count < min_observations + } + + /// Coefficient of variation (relative uncertainty) + /// + /// Returns None if mean is zero to avoid division by zero. + pub fn coefficient_of_variation(&self) -> Option { + if self.mean.abs() < f64::EPSILON { + None + } else { + Some(self.std_error() / self.mean.abs()) + } + } + + /// Merge two independent estimates (weighted by inverse variance) + /// + /// Combines two estimates using inverse-variance weighting, + /// which is optimal for independent normal estimates. + pub fn merge(&self, other: &FitnessEstimate) -> FitnessEstimate { + // Handle infinite variance cases + if self.variance.is_infinite() { + return other.clone(); + } + if other.variance.is_infinite() { + return self.clone(); + } + if self.variance == 0.0 && other.variance == 0.0 { + // Both have zero variance - average + return FitnessEstimate::new( + (self.mean + other.mean) / 2.0, + 0.0, + self.observation_count + other.observation_count, + ); + } + + // Inverse variance weighting + let w1 = 1.0 / self.variance; + let w2 = 1.0 / other.variance; + let w_total = w1 + w2; + + let merged_mean = (w1 * self.mean + w2 * other.mean) / w_total; + let merged_variance = 1.0 / w_total; + let merged_count = self.observation_count + other.observation_count; + + FitnessEstimate::new(merged_mean, merged_variance, merged_count) + } +} + +impl Default for FitnessEstimate { + fn default() -> Self { + Self::uninformative(0.0) + } +} + +/// Compute sample variance using Welford's online algorithm +/// +/// This is numerically stable for computing variance incrementally. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct WelfordVariance { + count: usize, + mean: f64, + m2: f64, // Sum of squared differences from mean +} + +impl WelfordVariance { + /// Create a new variance calculator + pub fn new() -> Self { + Self::default() + } + + /// Add a new observation + pub fn update(&mut self, value: f64) { + self.count += 1; + let delta = value - self.mean; + self.mean += delta / self.count as f64; + let delta2 = value - self.mean; + self.m2 += delta * delta2; + } + + /// Get current count + pub fn count(&self) -> usize { + self.count + } + + /// Get current mean + pub fn mean(&self) -> f64 { + self.mean + } + + /// Get sample variance (unbiased, divided by n-1) + pub fn sample_variance(&self) -> f64 { + if self.count < 2 { + f64::INFINITY + } else { + self.m2 / (self.count - 1) as f64 + } + } + + /// Get population variance (divided by n) + pub fn population_variance(&self) -> f64 { + if self.count == 0 { + f64::INFINITY + } else { + self.m2 / self.count as f64 + } + } + + /// Get variance of the mean (standard error squared) + pub fn variance_of_mean(&self) -> f64 { + if self.count == 0 { + f64::INFINITY + } else { + self.sample_variance() / self.count as f64 + } + } + + /// Convert to FitnessEstimate + pub fn to_estimate(&self) -> FitnessEstimate { + FitnessEstimate::new(self.mean, self.variance_of_mean(), self.count) + } + + /// Merge with another WelfordVariance (for parallel computation) + pub fn merge(&self, other: &WelfordVariance) -> WelfordVariance { + if self.count == 0 { + return other.clone(); + } + if other.count == 0 { + return self.clone(); + } + + let count = self.count + other.count; + let delta = other.mean - self.mean; + let mean = self.mean + delta * other.count as f64 / count as f64; + let m2 = self.m2 + + other.m2 + + delta * delta * self.count as f64 * other.count as f64 / count as f64; + + WelfordVariance { count, mean, m2 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fitness_estimate_creation() { + let est = FitnessEstimate::new(5.0, 0.25, 10); + assert_eq!(est.mean, 5.0); + assert_eq!(est.variance, 0.25); + assert_eq!(est.observation_count, 10); + assert!((est.std_error() - 0.5).abs() < 1e-9); + } + + #[test] + fn test_confidence_interval() { + let est = FitnessEstimate::new(10.0, 1.0, 100); + // 95% CI with std_err = 1.0: [10 - 1.96, 10 + 1.96] + assert!((est.ci_lower - 8.04).abs() < 0.01); + assert!((est.ci_upper - 11.96).abs() < 0.01); + assert!((est.ci_width() - 3.92).abs() < 0.01); + } + + #[test] + fn test_ci_contains() { + let est = FitnessEstimate::new(10.0, 1.0, 100); + assert!(est.ci_contains(10.0)); + assert!(est.ci_contains(9.0)); + assert!(est.ci_contains(11.0)); + assert!(!est.ci_contains(5.0)); + assert!(!est.ci_contains(15.0)); + } + + #[test] + fn test_ci_overlaps() { + let est1 = FitnessEstimate::new(10.0, 1.0, 100); + let est2 = FitnessEstimate::new(11.0, 1.0, 100); + let est3 = FitnessEstimate::new(20.0, 1.0, 100); + + assert!(est1.ci_overlaps(&est2)); // Close estimates overlap + assert!(!est1.ci_overlaps(&est3)); // Far estimates don't overlap + } + + #[test] + fn test_significantly_better_than() { + let good = FitnessEstimate::new(20.0, 0.1, 100); + let bad = FitnessEstimate::new(10.0, 0.1, 100); + let uncertain = FitnessEstimate::new(15.0, 100.0, 5); + + assert!(good.significantly_better_than(&bad)); + assert!(!bad.significantly_better_than(&good)); + assert!(!good.significantly_better_than(&uncertain)); // Uncertain overlaps + } + + #[test] + fn test_uninformative_estimate() { + let est = FitnessEstimate::uninformative(5.0); + assert_eq!(est.mean, 5.0); + assert!(est.variance.is_infinite()); + assert!(est.is_uncertain(1)); + } + + #[test] + fn test_merge_estimates() { + let est1 = FitnessEstimate::new(10.0, 1.0, 10); + let est2 = FitnessEstimate::new(12.0, 1.0, 10); + + let merged = est1.merge(&est2); + assert_eq!(merged.mean, 11.0); // Average when equal weights + assert!(merged.variance < est1.variance); // Variance decreases + assert_eq!(merged.observation_count, 20); + } + + #[test] + fn test_merge_with_uninformative() { + let est = FitnessEstimate::new(10.0, 1.0, 10); + let uninf = FitnessEstimate::uninformative(5.0); + + let merged = est.merge(&uninf); + assert_eq!(merged.mean, est.mean); + assert_eq!(merged.variance, est.variance); + } + + #[test] + fn test_welford_variance() { + let mut welford = WelfordVariance::new(); + let values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]; + + for v in values { + welford.update(v); + } + + assert_eq!(welford.count(), 8); + assert!((welford.mean() - 5.0).abs() < 1e-9); + // Sample variance should be 4.571... (32/7) + assert!((welford.sample_variance() - 32.0 / 7.0).abs() < 1e-9); + } + + #[test] + fn test_welford_merge() { + let mut w1 = WelfordVariance::new(); + let mut w2 = WelfordVariance::new(); + + for v in [1.0, 2.0, 3.0] { + w1.update(v); + } + for v in [4.0, 5.0, 6.0] { + w2.update(v); + } + + let merged = w1.merge(&w2); + assert_eq!(merged.count(), 6); + assert!((merged.mean() - 3.5).abs() < 1e-9); + } + + #[test] + fn test_welford_to_estimate() { + let mut welford = WelfordVariance::new(); + for v in [10.0, 11.0, 9.0, 10.0, 10.0] { + welford.update(v); + } + + let est = welford.to_estimate(); + assert_eq!(est.mean, welford.mean()); + assert_eq!(est.observation_count, 5); + assert_eq!(est.variance, welford.variance_of_mean()); + } + + #[test] + fn test_coefficient_of_variation() { + let est = FitnessEstimate::new(10.0, 1.0, 100); + let cv = est.coefficient_of_variation().unwrap(); + assert!((cv - 0.1).abs() < 1e-9); // std_err / mean = 1.0 / 10.0 + + let zero_mean = FitnessEstimate::new(0.0, 1.0, 100); + assert!(zero_mean.coefficient_of_variation().is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 53126c3..354c0a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,7 @@ pub mod fitness; pub mod fugue_integration; pub mod genome; pub mod hyperparameter; +pub mod interactive; pub mod operators; pub mod population; pub mod termination; @@ -65,6 +66,7 @@ pub mod prelude { pub use crate::fugue_integration::prelude::*; pub use crate::genome::prelude::*; pub use crate::hyperparameter::prelude::*; + pub use crate::interactive::prelude::*; pub use crate::operators::prelude::*; pub use crate::population::prelude::*; pub use crate::termination::prelude::*;