Skip to main content

solverforge_solver/
stats.rs

1/* Solver statistics (zero-erasure).
2
3Stack-allocated statistics for solver and phase performance tracking.
4*/
5
6use std::time::{Duration, Instant};
7
8/* Solver-level statistics.
9
10Tracks aggregate metrics across all phases of a solve run.
11
12# Example
13
14```
15use solverforge_solver::stats::SolverStats;
16use std::time::Duration;
17
18let mut stats = SolverStats::default();
19stats.start();
20stats.record_step();
21stats.record_generated_move(Duration::from_millis(1));
22stats.record_evaluated_move(Duration::from_millis(2));
23stats.record_move_accepted();
24stats.record_generated_move(Duration::from_millis(1));
25stats.record_evaluated_move(Duration::from_millis(2));
26
27assert_eq!(stats.step_count, 1);
28assert_eq!(stats.moves_evaluated, 2);
29assert_eq!(stats.moves_accepted, 1);
30```
31*/
32#[derive(Debug, Clone, Copy, Default, PartialEq)]
33pub struct SolverTelemetry {
34    pub elapsed: Duration,
35    pub step_count: u64,
36    pub moves_generated: u64,
37    pub moves_evaluated: u64,
38    pub moves_accepted: u64,
39    pub score_calculations: u64,
40    pub generation_time: Duration,
41    pub evaluation_time: Duration,
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub struct Throughput {
46    pub count: u64,
47    pub elapsed: Duration,
48}
49
50pub(crate) fn whole_units_per_second(count: u64, elapsed: Duration) -> u128 {
51    let nanos = elapsed.as_nanos();
52    if nanos == 0 {
53        0
54    } else {
55        u128::from(count)
56            .saturating_mul(1_000_000_000)
57            .checked_div(nanos)
58            .unwrap_or(0)
59    }
60}
61
62pub(crate) fn format_duration(duration: Duration) -> String {
63    let secs = duration.as_secs();
64    let nanos = duration.subsec_nanos();
65
66    if secs >= 60 {
67        let mins = secs / 60;
68        let rem_secs = secs % 60;
69        return format!("{mins}m {rem_secs}s");
70    }
71
72    if secs > 0 {
73        let millis = nanos / 1_000_000;
74        if millis == 0 {
75            return format!("{secs}s");
76        }
77        return format!("{secs}s {millis}ms");
78    }
79
80    let millis = nanos / 1_000_000;
81    if millis > 0 {
82        return format!("{millis}ms");
83    }
84
85    let micros = nanos / 1_000;
86    if micros > 0 {
87        return format!("{micros}us");
88    }
89
90    format!("{nanos}ns")
91}
92
93#[derive(Debug, Default)]
94pub struct SolverStats {
95    start_time: Option<Instant>,
96    pause_started_at: Option<Instant>,
97    // Total steps taken across all phases.
98    pub step_count: u64,
99    // Total moves generated across all phases.
100    pub moves_generated: u64,
101    // Total moves evaluated across all phases.
102    pub moves_evaluated: u64,
103    // Total moves accepted across all phases.
104    pub moves_accepted: u64,
105    // Total score calculations performed.
106    pub score_calculations: u64,
107    generation_time: Duration,
108    evaluation_time: Duration,
109}
110
111impl SolverStats {
112    /// Marks the start of solving.
113    pub fn start(&mut self) {
114        self.start_time = Some(Instant::now());
115        self.pause_started_at = None;
116    }
117
118    pub fn elapsed(&self) -> Duration {
119        match (self.start_time, self.pause_started_at) {
120            (Some(start), Some(paused_at)) => paused_at.duration_since(start),
121            (Some(start), None) => start.elapsed(),
122            _ => Duration::default(),
123        }
124    }
125
126    pub fn pause(&mut self) {
127        if self.start_time.is_some() && self.pause_started_at.is_none() {
128            self.pause_started_at = Some(Instant::now());
129        }
130    }
131
132    pub fn resume(&mut self) {
133        if let (Some(start), Some(paused_at)) = (self.start_time, self.pause_started_at.take()) {
134            self.start_time = Some(start + paused_at.elapsed());
135        }
136    }
137
138    /// Records one or more generated candidate moves and the time spent generating them.
139    pub fn record_generated_batch(&mut self, count: u64, duration: Duration) {
140        self.moves_generated += count;
141        self.generation_time += duration;
142    }
143
144    /// Records generation time that did not itself yield a counted move.
145    pub fn record_generation_time(&mut self, duration: Duration) {
146        self.generation_time += duration;
147    }
148
149    /// Records a single generated candidate move and the time spent generating it.
150    pub fn record_generated_move(&mut self, duration: Duration) {
151        self.record_generated_batch(1, duration);
152    }
153
154    /// Records a move evaluation and the time spent evaluating it.
155    pub fn record_evaluated_move(&mut self, duration: Duration) {
156        self.moves_evaluated += 1;
157        self.evaluation_time += duration;
158    }
159
160    /// Records an accepted move.
161    pub fn record_move_accepted(&mut self) {
162        self.moves_accepted += 1;
163    }
164
165    /// Records a step completion.
166    pub fn record_step(&mut self) {
167        self.step_count += 1;
168    }
169
170    /// Records a score calculation.
171    pub fn record_score_calculation(&mut self) {
172        self.score_calculations += 1;
173    }
174
175    pub fn generated_throughput(&self) -> Throughput {
176        Throughput {
177            count: self.moves_generated,
178            elapsed: self.generation_time,
179        }
180    }
181
182    pub fn evaluated_throughput(&self) -> Throughput {
183        Throughput {
184            count: self.moves_evaluated,
185            elapsed: self.evaluation_time,
186        }
187    }
188
189    pub fn acceptance_rate(&self) -> f64 {
190        if self.moves_evaluated == 0 {
191            0.0
192        } else {
193            self.moves_accepted as f64 / self.moves_evaluated as f64
194        }
195    }
196
197    pub fn generation_time(&self) -> Duration {
198        self.generation_time
199    }
200
201    pub fn evaluation_time(&self) -> Duration {
202        self.evaluation_time
203    }
204
205    pub fn snapshot(&self) -> SolverTelemetry {
206        SolverTelemetry {
207            elapsed: self.elapsed(),
208            step_count: self.step_count,
209            moves_generated: self.moves_generated,
210            moves_evaluated: self.moves_evaluated,
211            moves_accepted: self.moves_accepted,
212            score_calculations: self.score_calculations,
213            generation_time: self.generation_time,
214            evaluation_time: self.evaluation_time,
215        }
216    }
217}
218
219/* Phase-level statistics.
220
221Tracks metrics for a single solver phase.
222
223# Example
224
225```
226use solverforge_solver::stats::PhaseStats;
227use std::time::Duration;
228
229let mut stats = PhaseStats::new(0, "LocalSearch");
230stats.record_step();
231stats.record_generated_move(Duration::from_millis(1));
232stats.record_evaluated_move(Duration::from_millis(2));
233stats.record_move_accepted();
234
235assert_eq!(stats.phase_index, 0);
236assert_eq!(stats.phase_type, "LocalSearch");
237assert_eq!(stats.step_count, 1);
238assert_eq!(stats.moves_accepted, 1);
239```
240*/
241#[derive(Debug)]
242pub struct PhaseStats {
243    // Index of this phase (0-based).
244    pub phase_index: usize,
245    // Type name of the phase.
246    pub phase_type: &'static str,
247    start_time: Instant,
248    // Number of steps taken in this phase.
249    pub step_count: u64,
250    // Number of moves generated in this phase.
251    pub moves_generated: u64,
252    // Number of moves evaluated in this phase.
253    pub moves_evaluated: u64,
254    // Number of moves accepted in this phase.
255    pub moves_accepted: u64,
256    // Number of score calculations in this phase.
257    pub score_calculations: u64,
258    generation_time: Duration,
259    evaluation_time: Duration,
260}
261
262impl PhaseStats {
263    /// Creates new phase statistics.
264    pub fn new(phase_index: usize, phase_type: &'static str) -> Self {
265        Self {
266            phase_index,
267            phase_type,
268            start_time: Instant::now(),
269            step_count: 0,
270            moves_generated: 0,
271            moves_evaluated: 0,
272            moves_accepted: 0,
273            score_calculations: 0,
274            generation_time: Duration::default(),
275            evaluation_time: Duration::default(),
276        }
277    }
278
279    pub fn elapsed(&self) -> Duration {
280        self.start_time.elapsed()
281    }
282
283    /// Records a step completion.
284    pub fn record_step(&mut self) {
285        self.step_count += 1;
286    }
287
288    /// Records one or more generated candidate moves and the time spent generating them.
289    pub fn record_generated_batch(&mut self, count: u64, duration: Duration) {
290        self.moves_generated += count;
291        self.generation_time += duration;
292    }
293
294    /// Records generation time that did not itself yield a counted move.
295    pub fn record_generation_time(&mut self, duration: Duration) {
296        self.generation_time += duration;
297    }
298
299    /// Records a single generated candidate move and the time spent generating it.
300    pub fn record_generated_move(&mut self, duration: Duration) {
301        self.record_generated_batch(1, duration);
302    }
303
304    /// Records a move evaluation and the time spent evaluating it.
305    pub fn record_evaluated_move(&mut self, duration: Duration) {
306        self.moves_evaluated += 1;
307        self.evaluation_time += duration;
308    }
309
310    /// Records an accepted move.
311    pub fn record_move_accepted(&mut self) {
312        self.moves_accepted += 1;
313    }
314
315    /// Records a score calculation.
316    pub fn record_score_calculation(&mut self) {
317        self.score_calculations += 1;
318    }
319
320    pub fn generated_throughput(&self) -> Throughput {
321        Throughput {
322            count: self.moves_generated,
323            elapsed: self.generation_time,
324        }
325    }
326
327    pub fn evaluated_throughput(&self) -> Throughput {
328        Throughput {
329            count: self.moves_evaluated,
330            elapsed: self.evaluation_time,
331        }
332    }
333
334    pub fn acceptance_rate(&self) -> f64 {
335        if self.moves_evaluated == 0 {
336            0.0
337        } else {
338            self.moves_accepted as f64 / self.moves_evaluated as f64
339        }
340    }
341
342    pub fn generation_time(&self) -> Duration {
343        self.generation_time
344    }
345
346    pub fn evaluation_time(&self) -> Duration {
347        self.evaluation_time
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn solver_snapshot_preserves_exact_counts_and_durations() {
357        let mut stats = SolverStats::default();
358        stats.start();
359        stats.record_step();
360        stats.record_generated_batch(3, Duration::from_millis(4));
361        stats.record_evaluated_move(Duration::from_millis(5));
362        stats.record_move_accepted();
363        stats.record_score_calculation();
364
365        let snapshot = stats.snapshot();
366
367        assert_eq!(snapshot.step_count, 1);
368        assert_eq!(snapshot.moves_generated, 3);
369        assert_eq!(snapshot.moves_evaluated, 1);
370        assert_eq!(snapshot.moves_accepted, 1);
371        assert_eq!(snapshot.score_calculations, 1);
372        assert_eq!(snapshot.generation_time, Duration::from_millis(4));
373        assert_eq!(snapshot.evaluation_time, Duration::from_millis(5));
374    }
375
376    #[test]
377    fn phase_stats_track_generation_and_evaluation_separately() {
378        let mut stats = PhaseStats::new(2, "LocalSearch");
379        stats.record_step();
380        stats.record_generated_batch(7, Duration::from_millis(8));
381        stats.record_evaluated_move(Duration::from_millis(9));
382        stats.record_move_accepted();
383        stats.record_score_calculation();
384
385        assert_eq!(stats.phase_index, 2);
386        assert_eq!(stats.phase_type, "LocalSearch");
387        assert_eq!(stats.step_count, 1);
388        assert_eq!(stats.moves_generated, 7);
389        assert_eq!(stats.moves_evaluated, 1);
390        assert_eq!(stats.moves_accepted, 1);
391        assert_eq!(stats.score_calculations, 1);
392        assert_eq!(stats.generation_time(), Duration::from_millis(8));
393        assert_eq!(stats.evaluation_time(), Duration::from_millis(9));
394    }
395
396    #[test]
397    fn throughput_helpers_use_stage_specific_durations() {
398        let mut solver_stats = SolverStats::default();
399        solver_stats.start();
400        solver_stats.record_generated_batch(5, Duration::from_millis(7));
401        solver_stats.record_evaluated_move(Duration::from_millis(11));
402
403        let mut phase_stats = PhaseStats::new(1, "LocalSearch");
404        phase_stats.record_generated_batch(3, Duration::from_millis(13));
405        phase_stats.record_evaluated_move(Duration::from_millis(17));
406
407        assert_eq!(
408            solver_stats.generated_throughput(),
409            Throughput {
410                count: 5,
411                elapsed: Duration::from_millis(7),
412            }
413        );
414        assert_eq!(
415            solver_stats.evaluated_throughput(),
416            Throughput {
417                count: 1,
418                elapsed: Duration::from_millis(11),
419            }
420        );
421        assert_eq!(
422            phase_stats.generated_throughput(),
423            Throughput {
424                count: 3,
425                elapsed: Duration::from_millis(13),
426            }
427        );
428        assert_eq!(
429            phase_stats.evaluated_throughput(),
430            Throughput {
431                count: 1,
432                elapsed: Duration::from_millis(17),
433            }
434        );
435    }
436
437    #[test]
438    fn whole_units_per_second_uses_integer_rate_math() {
439        assert_eq!(whole_units_per_second(3, Duration::from_millis(2_000)), 1);
440        assert_eq!(whole_units_per_second(9, Duration::from_secs(2)), 4);
441        assert_eq!(whole_units_per_second(5, Duration::ZERO), 0);
442    }
443
444    #[test]
445    fn format_duration_uses_exact_integer_units() {
446        assert_eq!(format_duration(Duration::from_millis(750)), "750ms");
447        assert_eq!(format_duration(Duration::from_millis(2_500)), "2s 500ms");
448        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
449        assert_eq!(format_duration(Duration::from_micros(42)), "42us");
450    }
451}