solverforge_solver/
statistics.rs

1//! Solver statistics collection and reporting.
2//!
3//! This module provides types for tracking solver performance metrics during
4//! solving, including move counts, step counts, timing, and score progression.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use solverforge_core::score::Score;
11
12/// Statistics for a single solver phase.
13#[derive(Debug, Clone)]
14pub struct PhaseStatistics<Sc: Score> {
15    /// Index of this phase (0-based).
16    pub phase_index: usize,
17    /// Type name of the phase (e.g., "ConstructionHeuristic", "LocalSearch").
18    pub phase_type: String,
19    /// Time spent in this phase.
20    pub duration: Duration,
21    /// Number of steps taken in this phase.
22    pub step_count: u64,
23    /// Number of moves evaluated.
24    pub moves_evaluated: u64,
25    /// Number of moves accepted.
26    pub moves_accepted: u64,
27    /// Score at the start of the phase.
28    pub starting_score: Option<Sc>,
29    /// Score at the end of the phase.
30    pub ending_score: Option<Sc>,
31}
32
33impl<Sc: Score> PhaseStatistics<Sc> {
34    /// Creates empty phase statistics.
35    pub fn new(phase_index: usize, phase_type: impl Into<String>) -> Self {
36        Self {
37            phase_index,
38            phase_type: phase_type.into(),
39            duration: Duration::ZERO,
40            step_count: 0,
41            moves_evaluated: 0,
42            moves_accepted: 0,
43            starting_score: None,
44            ending_score: None,
45        }
46    }
47
48    /// Returns the acceptance rate (accepted / evaluated).
49    pub fn acceptance_rate(&self) -> f64 {
50        if self.moves_evaluated == 0 {
51            0.0
52        } else {
53            self.moves_accepted as f64 / self.moves_evaluated as f64
54        }
55    }
56
57    /// Returns the average time per step.
58    pub fn avg_time_per_step(&self) -> Duration {
59        if self.step_count == 0 {
60            Duration::ZERO
61        } else {
62            self.duration / self.step_count as u32
63        }
64    }
65}
66
67/// Record of a score improvement event.
68#[derive(Debug, Clone)]
69pub struct ScoreImprovement<Sc: Score> {
70    /// Time since solving started when improvement occurred.
71    pub time_offset: Duration,
72    /// Step number when improvement occurred.
73    pub step_count: u64,
74    /// The new (improved) score.
75    pub score: Sc,
76}
77
78/// Complete statistics for a solver run.
79#[derive(Debug, Clone)]
80pub struct SolverStatistics<Sc: Score> {
81    /// Total time spent solving.
82    pub total_duration: Duration,
83    /// Total steps taken across all phases.
84    pub total_step_count: u64,
85    /// Total moves evaluated across all phases.
86    pub total_moves_evaluated: u64,
87    /// Total moves accepted across all phases.
88    pub total_moves_accepted: u64,
89    /// Number of score calculations performed.
90    pub score_calculation_count: u64,
91    /// Statistics for each phase.
92    pub phase_statistics: Vec<PhaseStatistics<Sc>>,
93    /// History of score improvements.
94    pub score_history: Vec<ScoreImprovement<Sc>>,
95}
96
97impl<Sc: Score> SolverStatistics<Sc> {
98    /// Creates empty solver statistics.
99    pub fn new() -> Self {
100        Self {
101            total_duration: Duration::ZERO,
102            total_step_count: 0,
103            total_moves_evaluated: 0,
104            total_moves_accepted: 0,
105            score_calculation_count: 0,
106            phase_statistics: Vec::new(),
107            score_history: Vec::new(),
108        }
109    }
110
111    /// Returns the overall acceptance rate.
112    pub fn acceptance_rate(&self) -> f64 {
113        if self.total_moves_evaluated == 0 {
114            0.0
115        } else {
116            self.total_moves_accepted as f64 / self.total_moves_evaluated as f64
117        }
118    }
119
120    /// Returns the number of phases.
121    pub fn phase_count(&self) -> usize {
122        self.phase_statistics.len()
123    }
124
125    /// Returns the best score achieved (last in history, or None).
126    pub fn best_score(&self) -> Option<&Sc> {
127        self.score_history.last().map(|s| &s.score)
128    }
129
130    /// Returns the number of score improvements recorded.
131    pub fn improvement_count(&self) -> usize {
132        self.score_history.len()
133    }
134}
135
136impl<Sc: Score> Default for SolverStatistics<Sc> {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142/// Thread-safe collector for solver statistics.
143///
144/// Use this to record statistics during solving. After solving, call
145/// `into_statistics()` to get the final `SolverStatistics`.
146pub struct StatisticsCollector<Sc: Score> {
147    /// When solving started.
148    start_time: Instant,
149    /// Atomic counter for moves evaluated.
150    moves_evaluated: AtomicU64,
151    /// Atomic counter for moves accepted.
152    moves_accepted: AtomicU64,
153    /// Atomic counter for steps taken.
154    step_count: AtomicU64,
155    /// Atomic counter for score calculations.
156    score_calculations: AtomicU64,
157    /// Phase statistics (protected by mutex for complex updates).
158    phases: Mutex<Vec<PhaseStatistics<Sc>>>,
159    /// Score improvement history (protected by mutex).
160    score_history: Mutex<Vec<ScoreImprovement<Sc>>>,
161}
162
163impl<Sc: Score> StatisticsCollector<Sc> {
164    /// Creates a new statistics collector.
165    ///
166    /// The start time is recorded when this is called.
167    pub fn new() -> Self {
168        Self {
169            start_time: Instant::now(),
170            moves_evaluated: AtomicU64::new(0),
171            moves_accepted: AtomicU64::new(0),
172            step_count: AtomicU64::new(0),
173            score_calculations: AtomicU64::new(0),
174            phases: Mutex::new(Vec::new()),
175            score_history: Mutex::new(Vec::new()),
176        }
177    }
178
179    /// Records a move evaluation.
180    ///
181    /// Call this each time a move is evaluated, regardless of whether
182    /// it was accepted.
183    pub fn record_move_evaluated(&self) {
184        self.moves_evaluated.fetch_add(1, Ordering::Relaxed);
185    }
186
187    /// Records an accepted move.
188    ///
189    /// Call this when a move is accepted (in addition to `record_move_evaluated`).
190    pub fn record_move_accepted(&self) {
191        self.moves_accepted.fetch_add(1, Ordering::Relaxed);
192    }
193
194    /// Records both evaluation and acceptance for a move.
195    ///
196    /// Convenience method when you know the move was accepted.
197    pub fn record_move(&self, accepted: bool) {
198        self.record_move_evaluated();
199        if accepted {
200            self.record_move_accepted();
201        }
202    }
203
204    /// Records a step completion.
205    pub fn record_step(&self) {
206        self.step_count.fetch_add(1, Ordering::Relaxed);
207    }
208
209    /// Records a score calculation.
210    pub fn record_score_calculation(&self) {
211        self.score_calculations.fetch_add(1, Ordering::Relaxed);
212    }
213
214    /// Records a score improvement.
215    ///
216    /// Call this when a new best score is found.
217    pub fn record_improvement(&self, score: Sc) {
218        let time_offset = self.start_time.elapsed();
219        let step_count = self.step_count.load(Ordering::Relaxed);
220
221        let improvement = ScoreImprovement {
222            time_offset,
223            step_count,
224            score,
225        };
226
227        if let Ok(mut history) = self.score_history.lock() {
228            history.push(improvement);
229        }
230    }
231
232    /// Starts a new phase and returns its index.
233    ///
234    /// Call this at the beginning of each phase.
235    pub fn start_phase(&self, phase_type: impl Into<String>) -> usize {
236        let mut phases = self.phases.lock().unwrap();
237        let index = phases.len();
238        phases.push(PhaseStatistics::new(index, phase_type));
239        index
240    }
241
242    /// Ends the current phase with the given statistics.
243    ///
244    /// Call this at the end of each phase.
245    #[allow(clippy::too_many_arguments)]
246    pub fn end_phase(
247        &self,
248        phase_index: usize,
249        duration: Duration,
250        step_count: u64,
251        moves_evaluated: u64,
252        moves_accepted: u64,
253        starting_score: Option<Sc>,
254        ending_score: Option<Sc>,
255    ) {
256        if let Ok(mut phases) = self.phases.lock() {
257            if let Some(phase) = phases.get_mut(phase_index) {
258                phase.duration = duration;
259                phase.step_count = step_count;
260                phase.moves_evaluated = moves_evaluated;
261                phase.moves_accepted = moves_accepted;
262                phase.starting_score = starting_score;
263                phase.ending_score = ending_score;
264            }
265        }
266    }
267
268    /// Returns the elapsed time since solving started.
269    pub fn elapsed(&self) -> Duration {
270        self.start_time.elapsed()
271    }
272
273    /// Returns the current step count.
274    pub fn current_step_count(&self) -> u64 {
275        self.step_count.load(Ordering::Relaxed)
276    }
277
278    /// Returns the current moves evaluated count.
279    pub fn current_moves_evaluated(&self) -> u64 {
280        self.moves_evaluated.load(Ordering::Relaxed)
281    }
282
283    /// Returns the current moves accepted count.
284    pub fn current_moves_accepted(&self) -> u64 {
285        self.moves_accepted.load(Ordering::Relaxed)
286    }
287
288    /// Returns the current score calculation count.
289    pub fn current_score_calculations(&self) -> u64 {
290        self.score_calculations.load(Ordering::Relaxed)
291    }
292
293    /// Converts this collector into final statistics.
294    ///
295    /// This consumes the collector and returns the complete statistics.
296    pub fn into_statistics(self) -> SolverStatistics<Sc> {
297        SolverStatistics {
298            total_duration: self.start_time.elapsed(),
299            total_step_count: self.step_count.load(Ordering::Relaxed),
300            total_moves_evaluated: self.moves_evaluated.load(Ordering::Relaxed),
301            total_moves_accepted: self.moves_accepted.load(Ordering::Relaxed),
302            score_calculation_count: self.score_calculations.load(Ordering::Relaxed),
303            phase_statistics: self.phases.into_inner().unwrap(),
304            score_history: self.score_history.into_inner().unwrap(),
305        }
306    }
307
308    /// Takes a snapshot of current statistics without consuming the collector.
309    pub fn snapshot(&self) -> SolverStatistics<Sc> {
310        SolverStatistics {
311            total_duration: self.start_time.elapsed(),
312            total_step_count: self.step_count.load(Ordering::Relaxed),
313            total_moves_evaluated: self.moves_evaluated.load(Ordering::Relaxed),
314            total_moves_accepted: self.moves_accepted.load(Ordering::Relaxed),
315            score_calculation_count: self.score_calculations.load(Ordering::Relaxed),
316            phase_statistics: self.phases.lock().unwrap().clone(),
317            score_history: self.score_history.lock().unwrap().clone(),
318        }
319    }
320}
321
322impl<Sc: Score> Default for StatisticsCollector<Sc> {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use solverforge_core::score::SimpleScore;
332    use std::thread;
333
334    #[test]
335    fn test_phase_statistics_new() {
336        let stats: PhaseStatistics<SimpleScore> = PhaseStatistics::new(0, "ConstructionHeuristic");
337        assert_eq!(stats.phase_index, 0);
338        assert_eq!(stats.phase_type, "ConstructionHeuristic");
339        assert_eq!(stats.step_count, 0);
340    }
341
342    #[test]
343    fn test_phase_statistics_acceptance_rate() {
344        let mut stats: PhaseStatistics<SimpleScore> = PhaseStatistics::new(0, "LocalSearch");
345        stats.moves_evaluated = 100;
346        stats.moves_accepted = 25;
347        assert!((stats.acceptance_rate() - 0.25).abs() < f64::EPSILON);
348    }
349
350    #[test]
351    fn test_phase_statistics_acceptance_rate_zero() {
352        let stats: PhaseStatistics<SimpleScore> = PhaseStatistics::new(0, "Test");
353        assert_eq!(stats.acceptance_rate(), 0.0);
354    }
355
356    #[test]
357    fn test_solver_statistics_new() {
358        let stats: SolverStatistics<SimpleScore> = SolverStatistics::new();
359        assert_eq!(stats.total_step_count, 0);
360        assert_eq!(stats.phase_count(), 0);
361        assert!(stats.best_score().is_none());
362    }
363
364    #[test]
365    fn test_collector_record_move() {
366        let collector: StatisticsCollector<SimpleScore> = StatisticsCollector::new();
367
368        collector.record_move(true);
369        collector.record_move(false);
370        collector.record_move(true);
371
372        assert_eq!(collector.current_moves_evaluated(), 3);
373        assert_eq!(collector.current_moves_accepted(), 2);
374    }
375
376    #[test]
377    fn test_collector_record_step() {
378        let collector: StatisticsCollector<SimpleScore> = StatisticsCollector::new();
379
380        collector.record_step();
381        collector.record_step();
382        collector.record_step();
383
384        assert_eq!(collector.current_step_count(), 3);
385    }
386
387    #[test]
388    fn test_collector_record_improvement() {
389        let collector: StatisticsCollector<SimpleScore> = StatisticsCollector::new();
390
391        collector.record_improvement(SimpleScore::of(-10));
392        collector.record_improvement(SimpleScore::of(-5));
393        collector.record_improvement(SimpleScore::of(0));
394
395        let stats = collector.into_statistics();
396        assert_eq!(stats.improvement_count(), 3);
397        assert_eq!(*stats.best_score().unwrap(), SimpleScore::of(0));
398    }
399
400    #[test]
401    fn test_collector_phases() {
402        let collector: StatisticsCollector<SimpleScore> = StatisticsCollector::new();
403
404        let phase0 = collector.start_phase("Construction");
405        let phase1 = collector.start_phase("LocalSearch");
406
407        assert_eq!(phase0, 0);
408        assert_eq!(phase1, 1);
409
410        collector.end_phase(
411            phase0,
412            Duration::from_millis(100),
413            5,
414            10,
415            5,
416            None,
417            Some(SimpleScore::of(-5)),
418        );
419
420        collector.end_phase(
421            phase1,
422            Duration::from_millis(200),
423            20,
424            100,
425            50,
426            Some(SimpleScore::of(-5)),
427            Some(SimpleScore::of(0)),
428        );
429
430        let stats = collector.into_statistics();
431        assert_eq!(stats.phase_count(), 2);
432
433        let p0 = &stats.phase_statistics[0];
434        assert_eq!(p0.phase_type, "Construction");
435        assert_eq!(p0.step_count, 5);
436
437        let p1 = &stats.phase_statistics[1];
438        assert_eq!(p1.phase_type, "LocalSearch");
439        assert_eq!(p1.moves_evaluated, 100);
440        assert!((p1.acceptance_rate() - 0.5).abs() < f64::EPSILON);
441    }
442
443    #[test]
444    fn test_collector_snapshot() {
445        let collector: StatisticsCollector<SimpleScore> = StatisticsCollector::new();
446
447        collector.record_step();
448        collector.record_step();
449
450        let snapshot = collector.snapshot();
451        assert_eq!(snapshot.total_step_count, 2);
452
453        // Can still use collector after snapshot
454        collector.record_step();
455        assert_eq!(collector.current_step_count(), 3);
456    }
457
458    #[test]
459    fn test_collector_thread_safety() {
460        use std::sync::Arc;
461
462        let collector: Arc<StatisticsCollector<SimpleScore>> = Arc::new(StatisticsCollector::new());
463
464        let handles: Vec<_> = (0..4)
465            .map(|_| {
466                let c = collector.clone();
467                thread::spawn(move || {
468                    for _ in 0..1000 {
469                        c.record_move_evaluated();
470                        c.record_step();
471                    }
472                })
473            })
474            .collect();
475
476        for h in handles {
477            h.join().unwrap();
478        }
479
480        assert_eq!(collector.current_moves_evaluated(), 4000);
481        assert_eq!(collector.current_step_count(), 4000);
482    }
483
484    #[test]
485    fn test_solver_statistics_acceptance_rate() {
486        let stats: SolverStatistics<SimpleScore> = SolverStatistics {
487            total_duration: Duration::from_secs(1),
488            total_step_count: 100,
489            total_moves_evaluated: 1000,
490            total_moves_accepted: 100,
491            score_calculation_count: 1000,
492            phase_statistics: Vec::new(),
493            score_history: Vec::new(),
494        };
495
496        assert!((stats.acceptance_rate() - 0.1).abs() < f64::EPSILON);
497    }
498}