Skip to main content

solverforge_solver/stats/
solver.rs

1use std::cmp::Ordering;
2use std::collections::BTreeMap;
3use std::time::{Duration, Instant};
4
5use super::{
6    AppliedMoveTelemetry, CandidatePullTelemetry, CandidateTraceDisposition,
7    CandidateTracePhasePlan, CandidateTracePullToken, CandidateTraceRecordDecision,
8    CandidateTraceTelemetry, MoveTelemetry, SelectorTelemetry, SolverTelemetry, Throughput,
9};
10
11const APPLIED_MOVE_TRACE_LIMIT: usize = 8;
12
13#[derive(Debug, Default)]
14pub struct SolverStats {
15    start_time: Option<Instant>,
16    pause_started_at: Option<Instant>,
17    // Total steps taken across all phases.
18    pub step_count: u64,
19    /// Total candidate moves actually yielded by cursors across all phases.
20    /// Unrequested logical neighborhood tails are not generated work.
21    pub moves_generated: u64,
22    // Total moves evaluated across all phases.
23    pub moves_evaluated: u64,
24    // Total moves accepted across all phases.
25    pub moves_accepted: u64,
26    // Total moves applied across all phases.
27    pub moves_applied: u64,
28    pub moves_not_doable: u64,
29    pub moves_acceptor_rejected: u64,
30    pub moves_forager_ignored: u64,
31    pub moves_hard_improving: u64,
32    pub moves_hard_neutral: u64,
33    pub moves_hard_worse: u64,
34    pub conflict_repair_provider_generated: u64,
35    pub conflict_repair_duplicate_filtered: u64,
36    pub conflict_repair_illegal_filtered: u64,
37    pub conflict_repair_not_doable_filtered: u64,
38    pub conflict_repair_hard_improving: u64,
39    pub conflict_repair_exposed: u64,
40    // Total score calculations performed.
41    pub score_calculations: u64,
42    pub construction_slots_assigned: u64,
43    pub construction_slots_kept: u64,
44    pub construction_slots_no_doable: u64,
45    pub scalar_assignment_required_remaining: u64,
46    scalar_assignment_required_remaining_by_group: BTreeMap<&'static str, u64>,
47    generation_time: Duration,
48    evaluation_time: Duration,
49    selector_stats: Vec<SelectorTelemetry>,
50    move_stats: BTreeMap<&'static str, MoveTelemetry>,
51    applied_move_trace: Vec<AppliedMoveTelemetry>,
52    candidate_trace: Option<CandidateTraceTelemetry>,
53}
54
55impl SolverStats {
56    /// Marks the start of solving.
57    pub fn start(&mut self) {
58        self.start_time = Some(Instant::now());
59        self.pause_started_at = None;
60    }
61
62    pub fn elapsed(&self) -> Duration {
63        match (self.start_time, self.pause_started_at) {
64            (Some(start), Some(paused_at)) => paused_at.duration_since(start),
65            (Some(start), None) => start.elapsed(),
66            _ => Duration::default(),
67        }
68    }
69
70    pub fn pause(&mut self) {
71        if self.start_time.is_some() && self.pause_started_at.is_none() {
72            self.pause_started_at = Some(Instant::now());
73        }
74    }
75
76    pub fn resume(&mut self) {
77        if let (Some(start), Some(paused_at)) = (self.start_time, self.pause_started_at.take()) {
78            self.start_time = Some(start + paused_at.elapsed());
79        }
80    }
81
82    /// Records one or more generated candidate moves and the time spent generating them.
83    pub fn record_generated_batch(&mut self, count: u64, duration: Duration) {
84        self.moves_generated += count;
85        self.generation_time += duration;
86    }
87
88    pub fn record_selector_generated(
89        &mut self,
90        selector_index: usize,
91        count: u64,
92        duration: Duration,
93    ) {
94        self.record_generated_batch(count, duration);
95        let selector = self.selector_stats_entry(selector_index);
96        selector.moves_generated += count;
97        selector.generation_time += duration;
98    }
99
100    /// Records generation time that did not itself yield a counted move.
101    pub fn record_generation_time(&mut self, duration: Duration) {
102        self.generation_time += duration;
103    }
104
105    /// Records a single generated candidate move and the time spent generating it.
106    pub fn record_generated_move(&mut self, duration: Duration) {
107        self.record_generated_batch(1, duration);
108    }
109
110    /// Records a move evaluation and the time spent evaluating it.
111    pub fn record_evaluated_move(&mut self, duration: Duration) {
112        self.moves_evaluated += 1;
113        self.evaluation_time += duration;
114    }
115
116    pub fn record_selector_evaluated(&mut self, selector_index: usize, duration: Duration) {
117        self.record_evaluated_move(duration);
118        let selector = self.selector_stats_entry(selector_index);
119        selector.moves_evaluated += 1;
120        selector.evaluation_time += duration;
121    }
122
123    /// Records an accepted move.
124    pub fn record_move_accepted(&mut self) {
125        self.moves_accepted += 1;
126    }
127
128    pub fn record_selector_accepted(&mut self, selector_index: usize) {
129        self.record_move_accepted();
130        self.selector_stats_entry(selector_index).moves_accepted += 1;
131    }
132
133    pub fn record_move_applied(&mut self) {
134        self.moves_applied += 1;
135    }
136
137    pub fn record_selector_applied(&mut self, selector_index: usize) {
138        self.record_move_applied();
139        self.selector_stats_entry(selector_index).moves_applied += 1;
140    }
141
142    pub fn record_move_not_doable(&mut self) {
143        self.moves_not_doable += 1;
144    }
145
146    pub fn record_selector_not_doable(&mut self, selector_index: usize) {
147        self.record_move_not_doable();
148        self.selector_stats_entry(selector_index).moves_not_doable += 1;
149    }
150
151    pub fn record_move_acceptor_rejected(&mut self) {
152        self.moves_acceptor_rejected += 1;
153    }
154
155    pub fn record_selector_acceptor_rejected(&mut self, selector_index: usize) {
156        self.record_move_acceptor_rejected();
157        self.selector_stats_entry(selector_index)
158            .moves_acceptor_rejected += 1;
159    }
160
161    pub fn record_moves_forager_ignored(&mut self, count: u64) {
162        self.moves_forager_ignored += count;
163    }
164
165    pub fn record_move_hard_improving(&mut self) {
166        self.moves_hard_improving += 1;
167    }
168
169    pub fn record_move_hard_neutral(&mut self) {
170        self.moves_hard_neutral += 1;
171    }
172
173    pub fn record_move_hard_worse(&mut self) {
174        self.moves_hard_worse += 1;
175    }
176
177    pub fn record_conflict_repair_provider_generated(&mut self, count: u64) {
178        self.conflict_repair_provider_generated += count;
179    }
180
181    pub fn record_conflict_repair_duplicate_filtered(&mut self) {
182        self.conflict_repair_duplicate_filtered += 1;
183    }
184
185    pub fn record_conflict_repair_illegal_filtered(&mut self) {
186        self.conflict_repair_illegal_filtered += 1;
187    }
188
189    pub fn record_conflict_repair_not_doable_filtered(&mut self) {
190        self.conflict_repair_not_doable_filtered += 1;
191    }
192
193    pub fn record_conflict_repair_hard_improving(&mut self) {
194        self.conflict_repair_hard_improving += 1;
195    }
196
197    pub fn record_conflict_repair_exposed(&mut self) {
198        self.conflict_repair_exposed += 1;
199    }
200
201    /// Records a step completion.
202    pub fn record_step(&mut self) {
203        self.step_count += 1;
204    }
205
206    /// Records a score calculation.
207    pub fn record_score_calculation(&mut self) {
208        self.score_calculations += 1;
209    }
210
211    pub fn record_construction_slot_assigned(&mut self) {
212        self.construction_slots_assigned += 1;
213    }
214
215    pub fn record_construction_slot_kept(&mut self) {
216        self.construction_slots_kept += 1;
217    }
218
219    pub fn record_construction_slot_no_doable(&mut self) {
220        self.construction_slots_no_doable += 1;
221    }
222
223    pub fn record_scalar_assignment_required_remaining(
224        &mut self,
225        group_name: &'static str,
226        count: u64,
227    ) {
228        self.scalar_assignment_required_remaining_by_group
229            .insert(group_name, count);
230        self.scalar_assignment_required_remaining = self
231            .scalar_assignment_required_remaining_by_group
232            .values()
233            .copied()
234            .sum();
235    }
236
237    pub fn generated_throughput(&self) -> Throughput {
238        Throughput {
239            count: self.moves_generated,
240            elapsed: self.generation_time,
241        }
242    }
243
244    pub fn evaluated_throughput(&self) -> Throughput {
245        Throughput {
246            count: self.moves_evaluated,
247            elapsed: self.evaluation_time,
248        }
249    }
250
251    pub fn acceptance_rate(&self) -> f64 {
252        if self.moves_evaluated == 0 {
253            0.0
254        } else {
255            self.moves_accepted as f64 / self.moves_evaluated as f64
256        }
257    }
258
259    pub fn generation_time(&self) -> Duration {
260        self.generation_time
261    }
262
263    pub fn evaluation_time(&self) -> Duration {
264        self.evaluation_time
265    }
266
267    pub fn snapshot(&self) -> SolverTelemetry {
268        self.snapshot_with_applied_move_trace(true)
269    }
270
271    pub fn snapshot_without_applied_move_trace(&self) -> SolverTelemetry {
272        self.snapshot_with_applied_move_trace(false)
273    }
274
275    fn snapshot_with_applied_move_trace(
276        &self,
277        include_applied_move_trace: bool,
278    ) -> SolverTelemetry {
279        SolverTelemetry {
280            elapsed: self.elapsed(),
281            step_count: self.step_count,
282            moves_generated: self.moves_generated,
283            moves_evaluated: self.moves_evaluated,
284            moves_accepted: self.moves_accepted,
285            moves_applied: self.moves_applied,
286            moves_score_improving: self.moves_score_improving(),
287            moves_applied_improving: self.moves_applied_improving(),
288            moves_not_doable: self.moves_not_doable,
289            moves_acceptor_rejected: self.moves_acceptor_rejected,
290            moves_forager_ignored: self.moves_forager_ignored,
291            moves_hard_improving: self.moves_hard_improving,
292            moves_hard_neutral: self.moves_hard_neutral,
293            moves_hard_worse: self.moves_hard_worse,
294            conflict_repair_provider_generated: self.conflict_repair_provider_generated,
295            conflict_repair_duplicate_filtered: self.conflict_repair_duplicate_filtered,
296            conflict_repair_illegal_filtered: self.conflict_repair_illegal_filtered,
297            conflict_repair_not_doable_filtered: self.conflict_repair_not_doable_filtered,
298            conflict_repair_hard_improving: self.conflict_repair_hard_improving,
299            conflict_repair_exposed: self.conflict_repair_exposed,
300            score_calculations: self.score_calculations,
301            construction_slots_assigned: self.construction_slots_assigned,
302            construction_slots_kept: self.construction_slots_kept,
303            construction_slots_no_doable: self.construction_slots_no_doable,
304            scalar_assignment_required_remaining: self.scalar_assignment_required_remaining,
305            generation_time: self.generation_time,
306            evaluation_time: self.evaluation_time,
307            phase: None,
308            selector_telemetry: self.selector_stats.clone(),
309            move_telemetry: self.move_stats.values().cloned().collect(),
310            applied_move_trace: if include_applied_move_trace {
311                self.applied_move_trace.to_vec()
312            } else {
313                Vec::new()
314            },
315            candidate_trace: if include_applied_move_trace {
316                self.candidate_trace
317                    .as_ref()
318                    .map(CandidateTraceTelemetry::snapshot)
319            } else {
320                None
321            },
322        }
323    }
324
325    pub fn record_move_kind_generated(&mut self, move_label: &'static str) {
326        self.move_stats_entry(move_label).moves_generated += 1;
327    }
328
329    pub fn moves_score_improving(&self) -> u64 {
330        self.move_stats
331            .values()
332            .map(|entry| entry.moves_score_improving)
333            .sum()
334    }
335
336    pub fn moves_applied_improving(&self) -> u64 {
337        self.move_stats
338            .values()
339            .map(|entry| entry.moves_applied_improving)
340            .sum()
341    }
342
343    pub fn record_move_kind_evaluated(
344        &mut self,
345        move_label: &'static str,
346        score_ordering: Ordering,
347    ) {
348        let entry = self.move_stats_entry(move_label);
349        entry.moves_evaluated += 1;
350        match score_ordering {
351            Ordering::Greater => entry.moves_score_improving += 1,
352            Ordering::Equal => entry.moves_score_equal += 1,
353            Ordering::Less => entry.moves_score_worse += 1,
354        }
355    }
356
357    pub fn record_move_kind_evaluated_unscored(&mut self, move_label: &'static str) {
358        self.move_stats_entry(move_label).moves_evaluated += 1;
359    }
360
361    pub fn record_move_kind_accepted(&mut self, move_label: &'static str) {
362        self.move_stats_entry(move_label).moves_accepted += 1;
363    }
364
365    pub fn record_move_kind_applied(&mut self, move_label: &'static str, score_improvement: f64) {
366        let entry = self.move_stats_entry(move_label);
367        entry.moves_applied += 1;
368        if score_improvement > 0.0 {
369            entry.moves_applied_improving += 1;
370            entry.applied_score_improvement += score_improvement;
371        }
372    }
373
374    pub fn record_move_kind_not_doable(&mut self, move_label: &'static str) {
375        self.move_stats_entry(move_label).moves_not_doable += 1;
376    }
377
378    pub fn record_move_kind_acceptor_rejected(
379        &mut self,
380        move_label: &'static str,
381        score_ordering: Ordering,
382    ) {
383        let entry = self.move_stats_entry(move_label);
384        entry.moves_acceptor_rejected += 1;
385        if score_ordering == Ordering::Greater {
386            entry.moves_rejected_improving += 1;
387        }
388    }
389
390    pub fn record_move_kind_forager_ignored(&mut self, move_label: &'static str, count: u64) {
391        if count == 0 {
392            return;
393        }
394        self.move_stats_entry(move_label).moves_forager_ignored += count;
395    }
396
397    pub fn record_applied_move_trace(&mut self, applied_move: AppliedMoveTelemetry) {
398        if self.applied_move_trace.len() < APPLIED_MOVE_TRACE_LIMIT {
399            self.applied_move_trace.push(applied_move);
400        }
401    }
402
403    pub(crate) fn enable_candidate_trace(&mut self, candidate_trace: CandidateTraceTelemetry) {
404        assert!(
405            self.candidate_trace.is_none(),
406            "candidate trace may be enabled only once per solve"
407        );
408        self.candidate_trace = Some(candidate_trace);
409    }
410
411    pub(crate) fn prepare_candidate_trace_pull(&mut self) -> CandidateTraceRecordDecision {
412        match self.candidate_trace.as_mut() {
413            Some(trace) => trace.prepare_pull(),
414            None => CandidateTraceRecordDecision::Disabled,
415        }
416    }
417
418    pub(crate) fn begin_candidate_trace_phase(&mut self) -> Option<usize> {
419        self.candidate_trace
420            .as_mut()
421            .map(CandidateTraceTelemetry::begin_phase)
422    }
423
424    /// Finalizes the exact phase plan for an enabled candidate trace.
425    ///
426    /// A trace-free solve intentionally has no recorder to update. The
427    /// one-way invariant itself is enforced by `CandidateTraceTelemetry` when
428    /// a trace exists.
429    pub(crate) fn finalize_candidate_trace_resolved_phase_plan(
430        &mut self,
431        resolved_phase_plan: CandidateTracePhasePlan,
432    ) {
433        if let Some(trace) = self.candidate_trace.as_mut() {
434            trace.finalize_resolved_phase_plan(resolved_phase_plan);
435        }
436    }
437
438    pub(crate) fn record_prepared_candidate_trace_pull(
439        &mut self,
440        pull: CandidatePullTelemetry,
441    ) -> CandidateTracePullToken {
442        let trace = self
443            .candidate_trace
444            .as_mut()
445            .expect("a prepared candidate trace pull requires an enabled trace");
446        trace.push_prepared(pull)
447    }
448
449    pub(crate) fn record_candidate_trace_disposition(
450        &mut self,
451        token: CandidateTracePullToken,
452        disposition: CandidateTraceDisposition,
453    ) {
454        let trace = self
455            .candidate_trace
456            .as_mut()
457            .expect("a candidate trace disposition requires an enabled trace");
458        trace.record_disposition(token, disposition);
459    }
460
461    pub fn record_selector_generated_with_label(
462        &mut self,
463        selector_index: usize,
464        selector_label: impl Into<String>,
465        count: u64,
466        duration: Duration,
467    ) {
468        self.record_generated_batch(count, duration);
469        let selector = self.selector_stats_entry_with_label(selector_index, selector_label);
470        selector.moves_generated += count;
471        selector.generation_time += duration;
472    }
473
474    fn selector_stats_entry(&mut self, selector_index: usize) -> &mut SelectorTelemetry {
475        self.selector_stats_entry_with_label(selector_index, format!("selector-{selector_index}"))
476    }
477
478    fn selector_stats_entry_with_label(
479        &mut self,
480        selector_index: usize,
481        selector_label: impl Into<String>,
482    ) -> &mut SelectorTelemetry {
483        let selector_label = selector_label.into();
484        if let Some(position) = self
485            .selector_stats
486            .iter()
487            .position(|entry| entry.selector_index == selector_index)
488        {
489            if self.selector_stats[position]
490                .selector_label
491                .starts_with("selector-")
492                && !selector_label.starts_with("selector-")
493            {
494                self.selector_stats[position].selector_label = selector_label;
495            }
496            return &mut self.selector_stats[position];
497        }
498        self.selector_stats.push(SelectorTelemetry {
499            selector_index,
500            selector_label,
501            ..SelectorTelemetry::default()
502        });
503        self.selector_stats
504            .last_mut()
505            .expect("selector stats entry was just inserted")
506    }
507
508    fn move_stats_entry(&mut self, move_label: &'static str) -> &mut MoveTelemetry {
509        self.move_stats
510            .entry(move_label)
511            .or_insert_with(|| MoveTelemetry {
512                move_label: move_label.to_string(),
513                ..MoveTelemetry::default()
514            })
515    }
516}
517
518/* Phase-level statistics.
519
520Tracks metrics for a single solver phase.
521
522# Example
523
524```
525use solverforge_solver::stats::PhaseStats;
526use std::time::Duration;
527
528let mut stats = PhaseStats::new(0, "LocalSearch");
529stats.record_step();
530stats.record_generated_move(Duration::from_millis(1));
531stats.record_evaluated_move(Duration::from_millis(2));
532stats.record_move_accepted();
533
534assert_eq!(stats.phase_index, 0);
535assert_eq!(stats.phase_type, "LocalSearch");
536assert_eq!(stats.step_count, 1);
537assert_eq!(stats.moves_accepted, 1);
538```
539*/