Skip to main content

solverforge_solver/stats/
solver.rs

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