Skip to main content

fugue_evo/interactive/
session.rs

1//! Session state management for interactive evolution
2//!
3//! This module provides serializable session state that allows pausing
4//! and resuming interactive evolution sessions.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[cfg(all(feature = "checkpoint", not(target_arch = "wasm32")))]
10use std::fs::File;
11#[cfg(all(feature = "checkpoint", not(target_arch = "wasm32")))]
12use std::io::{BufReader, BufWriter};
13#[cfg(all(feature = "checkpoint", not(target_arch = "wasm32")))]
14use std::path::Path;
15
16use super::aggregation::FitnessAggregator;
17use super::evaluator::{Candidate, CandidateId, EvaluationRequest};
18use super::uncertainty::FitnessEstimate;
19use crate::error::CheckpointError;
20use crate::genome::traits::EvolutionaryGenome;
21
22/// Current session format version
23pub const SESSION_VERSION: u32 = 1;
24
25/// Statistics about evaluation coverage in a session
26#[derive(Clone, Debug, Default, Serialize, Deserialize)]
27pub struct CoverageStats {
28    /// Fraction of population with at least one evaluation (0.0 to 1.0)
29    pub coverage: f64,
30    /// Average evaluations per candidate
31    pub avg_evaluations: f64,
32    /// Minimum evaluations for any candidate
33    pub min_evaluations: usize,
34    /// Maximum evaluations for any candidate
35    pub max_evaluations: usize,
36    /// Number of candidates with zero evaluations
37    pub unevaluated_count: usize,
38    /// Total population size
39    pub population_size: usize,
40}
41
42impl CoverageStats {
43    /// Check if coverage meets minimum threshold
44    pub fn meets_threshold(&self, min_coverage: f64) -> bool {
45        self.coverage >= min_coverage
46    }
47}
48
49/// Complete state of an interactive evolution session
50///
51/// This struct captures all state needed to pause and resume an
52/// interactive evolution session, including population, fitness
53/// aggregator state, and session metadata.
54#[derive(Clone, Debug, Serialize, Deserialize)]
55#[serde(bound = "G: Serialize + for<'a> Deserialize<'a>")]
56pub struct InteractiveSession<G>
57where
58    G: EvolutionaryGenome,
59{
60    /// Schema version for forward compatibility
61    pub version: u32,
62    /// Current population with fitness estimates
63    pub population: Vec<Candidate<G>>,
64    /// Current generation number
65    pub generation: usize,
66    /// Total evaluation requests made
67    pub evaluations_requested: usize,
68    /// Total responses received (excluding skips)
69    pub responses_received: usize,
70    /// Number of skipped evaluations
71    pub skipped: usize,
72    /// Fitness aggregator state
73    pub aggregator: FitnessAggregator,
74    /// History of evaluation requests (limited to recent history)
75    pub request_history: Vec<SerializedRequest>,
76    /// Custom session metadata
77    pub metadata: HashMap<String, String>,
78    /// Next candidate ID to assign
79    pub next_candidate_id: usize,
80}
81
82/// Serialized form of an evaluation request (without genome data)
83#[derive(Clone, Debug, Serialize, Deserialize)]
84pub struct SerializedRequest {
85    /// Type of request
86    pub request_type: String,
87    /// Candidate IDs involved
88    pub candidate_ids: Vec<CandidateId>,
89    /// Generation when request was made
90    pub generation: usize,
91    /// Whether this request was skipped
92    pub was_skipped: bool,
93}
94
95impl<G> InteractiveSession<G>
96where
97    G: EvolutionaryGenome,
98{
99    /// Create a new empty session
100    pub fn new(aggregator: FitnessAggregator) -> Self {
101        Self {
102            version: SESSION_VERSION,
103            population: Vec::new(),
104            generation: 0,
105            evaluations_requested: 0,
106            responses_received: 0,
107            skipped: 0,
108            aggregator,
109            request_history: Vec::new(),
110            metadata: HashMap::new(),
111            next_candidate_id: 0,
112        }
113    }
114
115    /// Create a new session with initial population
116    pub fn with_population(population: Vec<Candidate<G>>, aggregator: FitnessAggregator) -> Self {
117        let next_id = population.iter().map(|c| c.id.0).max().unwrap_or(0) + 1;
118        Self {
119            version: SESSION_VERSION,
120            population,
121            generation: 0,
122            evaluations_requested: 0,
123            responses_received: 0,
124            skipped: 0,
125            aggregator,
126            request_history: Vec::new(),
127            metadata: HashMap::new(),
128            next_candidate_id: next_id,
129        }
130    }
131
132    /// Get the next candidate ID and increment counter
133    pub fn next_id(&mut self) -> CandidateId {
134        let id = CandidateId(self.next_candidate_id);
135        self.next_candidate_id += 1;
136        id
137    }
138
139    /// Add a candidate to the population
140    pub fn add_candidate(&mut self, genome: G) -> CandidateId {
141        let id = self.next_id();
142        let candidate = Candidate::with_generation(id, genome, self.generation);
143        self.population.push(candidate);
144        id
145    }
146
147    /// Get a candidate by ID
148    pub fn get_candidate(&self, id: CandidateId) -> Option<&Candidate<G>> {
149        self.population.iter().find(|c| c.id == id)
150    }
151
152    /// Get a mutable reference to a candidate by ID
153    pub fn get_candidate_mut(&mut self, id: CandidateId) -> Option<&mut Candidate<G>> {
154        self.population.iter_mut().find(|c| c.id == id)
155    }
156
157    /// Get all candidates that haven't been evaluated
158    pub fn unevaluated_candidates(&self) -> Vec<&Candidate<G>> {
159        self.population
160            .iter()
161            .filter(|c| !c.is_evaluated())
162            .collect()
163    }
164
165    /// Get candidates sorted by fitness (best first)
166    pub fn ranked_candidates(&self) -> Vec<&Candidate<G>> {
167        let mut candidates: Vec<_> = self.population.iter().collect();
168        candidates.sort_by(|a, b| {
169            let fa = a.fitness_estimate.unwrap_or(f64::NEG_INFINITY);
170            let fb = b.fitness_estimate.unwrap_or(f64::NEG_INFINITY);
171            fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
172        });
173        candidates
174    }
175
176    /// Get the best candidate
177    pub fn best_candidate(&self) -> Option<&Candidate<G>> {
178        self.population
179            .iter()
180            .filter(|c| c.fitness_estimate.is_some())
181            .max_by(|a, b| {
182                let fa = a.fitness_estimate.unwrap();
183                let fb = b.fitness_estimate.unwrap();
184                fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
185            })
186    }
187
188    /// Calculate coverage statistics
189    pub fn coverage_stats(&self) -> CoverageStats {
190        if self.population.is_empty() {
191            return CoverageStats::default();
192        }
193
194        let eval_counts: Vec<usize> = self.population.iter().map(|c| c.evaluation_count).collect();
195
196        let evaluated = eval_counts.iter().filter(|&&c| c > 0).count();
197        let total_evals: usize = eval_counts.iter().sum();
198
199        CoverageStats {
200            coverage: evaluated as f64 / self.population.len() as f64,
201            avg_evaluations: total_evals as f64 / self.population.len() as f64,
202            min_evaluations: eval_counts.iter().copied().min().unwrap_or(0),
203            max_evaluations: eval_counts.iter().copied().max().unwrap_or(0),
204            unevaluated_count: self.population.len() - evaluated,
205            population_size: self.population.len(),
206        }
207    }
208
209    /// Record that an evaluation request was made
210    pub fn record_request<GG: EvolutionaryGenome>(&mut self, request: &EvaluationRequest<GG>) {
211        self.evaluations_requested += 1;
212
213        let serialized = SerializedRequest {
214            request_type: match request {
215                EvaluationRequest::RateCandidates { .. } => "rating".to_string(),
216                EvaluationRequest::PairwiseComparison { .. } => "pairwise".to_string(),
217                EvaluationRequest::BatchSelection { .. } => "batch".to_string(),
218            },
219            candidate_ids: request.candidate_ids(),
220            generation: self.generation,
221            was_skipped: false,
222        };
223
224        // Keep limited history
225        const MAX_HISTORY: usize = 1000;
226        if self.request_history.len() >= MAX_HISTORY {
227            self.request_history.remove(0);
228        }
229        self.request_history.push(serialized);
230    }
231
232    /// Record that a response was received
233    pub fn record_response(&mut self, was_skipped: bool) {
234        if was_skipped {
235            self.skipped += 1;
236            if let Some(last) = self.request_history.last_mut() {
237                last.was_skipped = true;
238            }
239        } else {
240            self.responses_received += 1;
241        }
242    }
243
244    /// Advance to the next generation
245    pub fn advance_generation(&mut self) {
246        self.generation += 1;
247        self.aggregator.set_generation(self.generation);
248    }
249
250    /// Update fitness estimate for a candidate.
251    ///
252    /// This is a pure setter: it does **not** increment `evaluation_count`.
253    /// Evaluation counting is owned solely by the caller (see
254    /// `InteractiveGA::provide_response`), which counts each presented candidate
255    /// exactly once per response. Previously this method also called
256    /// `record_evaluation()`, which — combined with the caller's explicit loop —
257    /// double-counted every evaluation (EV-26 / EV-64).
258    pub fn update_fitness(&mut self, id: CandidateId, fitness: f64) {
259        if let Some(candidate) = self.get_candidate_mut(id) {
260            candidate.set_fitness(fitness);
261        }
262    }
263
264    /// Update fitness with full uncertainty information.
265    ///
266    /// Pure setter; does **not** increment `evaluation_count` (see
267    /// [`InteractiveSession::update_fitness`]).
268    pub fn update_fitness_with_uncertainty(&mut self, id: CandidateId, estimate: FitnessEstimate) {
269        if let Some(candidate) = self.get_candidate_mut(id) {
270            candidate.set_fitness_with_uncertainty(estimate);
271        }
272    }
273
274    /// Sync candidate fitness estimates from the aggregator
275    ///
276    /// Updates all candidates with their current fitness estimates including uncertainty.
277    /// Call this after processing responses to ensure candidates have up-to-date estimates.
278    pub fn sync_fitness_estimates(&mut self) {
279        for candidate in &mut self.population {
280            if let Some(estimate) = self.aggregator.get_fitness_estimate(&candidate.id) {
281                candidate.fitness_estimate = Some(estimate.mean);
282                candidate.fitness_with_uncertainty = Some(estimate);
283            }
284        }
285    }
286
287    /// Get fitness estimates with uncertainty for all candidates
288    ///
289    /// Returns a vector of (CandidateId, FitnessEstimate) pairs.
290    pub fn all_fitness_estimates(&self) -> Vec<(CandidateId, FitnessEstimate)> {
291        self.population
292            .iter()
293            .filter_map(|c| {
294                self.aggregator
295                    .get_fitness_estimate(&c.id)
296                    .map(|e| (c.id, e))
297            })
298            .collect()
299    }
300
301    /// Get candidates sorted by uncertainty (most uncertain first)
302    ///
303    /// Useful for identifying which candidates need more evaluation.
304    pub fn candidates_by_uncertainty(&self) -> Vec<&Candidate<G>> {
305        let mut candidates: Vec<_> = self.population.iter().collect();
306        candidates.sort_by(|a, b| {
307            let var_a = self
308                .aggregator
309                .get_fitness_estimate(&a.id)
310                .map(|e| e.variance)
311                .unwrap_or(f64::INFINITY);
312            let var_b = self
313                .aggregator
314                .get_fitness_estimate(&b.id)
315                .map(|e| e.variance)
316                .unwrap_or(f64::INFINITY);
317            // Sort descending - most uncertain first
318            var_b
319                .partial_cmp(&var_a)
320                .unwrap_or(std::cmp::Ordering::Equal)
321        });
322        candidates
323    }
324
325    /// Get the average uncertainty across all candidates
326    pub fn average_uncertainty(&self) -> f64 {
327        let estimates: Vec<_> = self
328            .population
329            .iter()
330            .filter_map(|c| self.aggregator.get_fitness_estimate(&c.id))
331            .collect();
332
333        if estimates.is_empty() {
334            return f64::INFINITY;
335        }
336
337        let total_variance: f64 = estimates
338            .iter()
339            .map(|e| {
340                if e.variance.is_finite() {
341                    e.variance
342                } else {
343                    1e6 // Large but finite for averaging
344                }
345            })
346            .sum();
347
348        total_variance / estimates.len() as f64
349    }
350
351    /// Replace the population with new candidates
352    pub fn replace_population(&mut self, new_population: Vec<Candidate<G>>) {
353        let max_id = new_population.iter().map(|c| c.id.0).max().unwrap_or(0);
354        self.next_candidate_id = max_id + 1;
355        self.population = new_population;
356    }
357
358    /// Add metadata to the session
359    pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
360        self.metadata.insert(key.into(), value.into());
361    }
362
363    /// Get metadata value
364    pub fn get_metadata(&self, key: &str) -> Option<&String> {
365        self.metadata.get(key)
366    }
367
368    /// Get response rate (responses / requests)
369    pub fn response_rate(&self) -> f64 {
370        if self.evaluations_requested > 0 {
371            self.responses_received as f64 / self.evaluations_requested as f64
372        } else {
373            0.0
374        }
375    }
376
377    /// Get skip rate (skips / requests)
378    pub fn skip_rate(&self) -> f64 {
379        if self.evaluations_requested > 0 {
380            self.skipped as f64 / self.evaluations_requested as f64
381        } else {
382            0.0
383        }
384    }
385}
386
387/// File-based session persistence (requires the `checkpoint` feature and a
388/// target with a filesystem — see [`crate::checkpoint`] for why the second
389/// half of that is not a new restriction).
390#[cfg(all(feature = "checkpoint", not(target_arch = "wasm32")))]
391impl<G> InteractiveSession<G>
392where
393    G: EvolutionaryGenome + Serialize + for<'de> Deserialize<'de>,
394{
395    /// Save session to a file
396    pub fn save(&self, path: &Path) -> Result<(), CheckpointError> {
397        let file = File::create(path)?;
398        let writer = BufWriter::new(file);
399        serde_json::to_writer_pretty(writer, self).map_err(|e| {
400            CheckpointError::Serialization(format!("Failed to serialize session: {}", e))
401        })?;
402        Ok(())
403    }
404
405    /// Load session from a file
406    pub fn load(path: &Path) -> Result<Self, CheckpointError> {
407        let file = File::open(path)?;
408        let reader = BufReader::new(file);
409        let session: Self = serde_json::from_reader(reader).map_err(|e| {
410            CheckpointError::Deserialization(format!("Failed to deserialize session: {}", e))
411        })?;
412
413        // Check version compatibility
414        if session.version > SESSION_VERSION {
415            return Err(CheckpointError::VersionTooNew(session.version));
416        }
417
418        Ok(session)
419    }
420}
421
422impl<G> InteractiveSession<G>
423where
424    G: EvolutionaryGenome + Serialize + for<'de> Deserialize<'de>,
425{
426    /// Serialize session to JSON string (WASM-compatible)
427    pub fn to_json(&self) -> Result<String, CheckpointError> {
428        serde_json::to_string_pretty(self).map_err(|e| {
429            CheckpointError::Serialization(format!("Failed to serialize session: {}", e))
430        })
431    }
432
433    /// Deserialize session from JSON string (WASM-compatible)
434    pub fn from_json(json: &str) -> Result<Self, CheckpointError> {
435        let session: Self = serde_json::from_str(json).map_err(|e| {
436            CheckpointError::Deserialization(format!("Failed to deserialize session: {}", e))
437        })?;
438
439        // Check version compatibility
440        if session.version > SESSION_VERSION {
441            return Err(CheckpointError::VersionTooNew(session.version));
442        }
443
444        Ok(session)
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::genome::real_vector::RealVector;
452    use crate::interactive::aggregation::AggregationModel;
453
454    #[test]
455    fn test_session_creation() {
456        let aggregator = FitnessAggregator::new(AggregationModel::default());
457        let session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
458
459        assert_eq!(session.generation, 0);
460        assert!(session.population.is_empty());
461        assert_eq!(session.evaluations_requested, 0);
462    }
463
464    #[test]
465    fn test_add_candidate() {
466        let aggregator = FitnessAggregator::new(AggregationModel::default());
467        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
468
469        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
470        let id = session.add_candidate(genome);
471
472        assert_eq!(id, CandidateId(0));
473        assert_eq!(session.population.len(), 1);
474        assert_eq!(session.get_candidate(id).unwrap().birth_generation, 0);
475    }
476
477    #[test]
478    fn test_coverage_stats() {
479        let aggregator = FitnessAggregator::new(AggregationModel::default());
480        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
481
482        // Add 4 candidates
483        for i in 0..4 {
484            session.add_candidate(RealVector::new(vec![i as f64]));
485        }
486
487        // Evaluate 2 of them
488        session.population[0].record_evaluation();
489        session.population[1].record_evaluation();
490        session.population[1].record_evaluation(); // Evaluate twice
491
492        let stats = session.coverage_stats();
493
494        assert_eq!(stats.population_size, 4);
495        assert_eq!(stats.coverage, 0.5);
496        assert_eq!(stats.unevaluated_count, 2);
497        assert_eq!(stats.min_evaluations, 0);
498        assert_eq!(stats.max_evaluations, 2);
499    }
500
501    #[test]
502    fn test_ranked_candidates() {
503        let aggregator = FitnessAggregator::new(AggregationModel::default());
504        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
505
506        for i in 0..3 {
507            let id = session.add_candidate(RealVector::new(vec![i as f64]));
508            session.update_fitness(id, i as f64 * 10.0);
509        }
510
511        let ranked = session.ranked_candidates();
512        assert_eq!(ranked[0].fitness_estimate, Some(20.0)); // Best first
513        assert_eq!(ranked[2].fitness_estimate, Some(0.0)); // Worst last
514    }
515
516    #[test]
517    fn test_advance_generation() {
518        let aggregator = FitnessAggregator::new(AggregationModel::default());
519        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
520
521        session.advance_generation();
522        assert_eq!(session.generation, 1);
523
524        let id = session.add_candidate(RealVector::new(vec![1.0]));
525        assert_eq!(session.get_candidate(id).unwrap().birth_generation, 1);
526    }
527
528    #[test]
529    fn test_response_tracking() {
530        let aggregator = FitnessAggregator::new(AggregationModel::default());
531        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
532
533        let c1: Candidate<RealVector> = Candidate::new(CandidateId(0), RealVector::new(vec![1.0]));
534        let request = EvaluationRequest::rate(vec![c1]);
535        session.record_request(&request);
536        session.record_response(false);
537
538        session.record_request(&request);
539        session.record_response(true); // Skip
540
541        assert_eq!(session.evaluations_requested, 2);
542        assert_eq!(session.responses_received, 1);
543        assert_eq!(session.skipped, 1);
544        assert_eq!(session.response_rate(), 0.5);
545        assert_eq!(session.skip_rate(), 0.5);
546    }
547
548    #[test]
549    fn test_metadata() {
550        let aggregator = FitnessAggregator::new(AggregationModel::default());
551        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
552
553        session.set_metadata("experiment", "test_run");
554        session.set_metadata("user", "alice");
555
556        assert_eq!(
557            session.get_metadata("experiment"),
558            Some(&"test_run".to_string())
559        );
560        assert_eq!(session.get_metadata("user"), Some(&"alice".to_string()));
561        assert_eq!(session.get_metadata("missing"), None);
562    }
563
564    #[test]
565    fn test_session_serialization() {
566        let aggregator = FitnessAggregator::new(AggregationModel::DirectRating {
567            default_rating: 5.0,
568        });
569        let mut session: InteractiveSession<RealVector> = InteractiveSession::new(aggregator);
570
571        session.add_candidate(RealVector::new(vec![1.0, 2.0]));
572        session.add_candidate(RealVector::new(vec![3.0, 4.0]));
573        session.set_metadata("test", "value");
574
575        // Serialize to JSON
576        let json = serde_json::to_string(&session).expect("Failed to serialize");
577
578        // Deserialize back
579        let loaded: InteractiveSession<RealVector> =
580            serde_json::from_str(&json).expect("Failed to deserialize");
581
582        assert_eq!(loaded.population.len(), 2);
583        assert_eq!(loaded.get_metadata("test"), Some(&"value".to_string()));
584    }
585}