Skip to main content

solverforge_solver/stats/
telemetry.rs

1/* Solver statistics (zero-erasure).
2
3Stack-allocated statistics for solver and phase performance tracking.
4*/
5
6use std::time::Duration;
7
8use super::CandidateTraceTelemetry;
9
10/* Solver-level statistics.
11
12Tracks aggregate metrics across all phases of a solve run.
13
14# Example
15
16```
17use solverforge_solver::stats::SolverStats;
18use std::time::Duration;
19
20let mut stats = SolverStats::default();
21stats.start();
22stats.begin_phase("LocalSearch");
23stats.record_step();
24stats.record_generated_move(Duration::from_millis(1));
25stats.record_evaluated_move(Duration::from_millis(2));
26stats.record_move_accepted();
27stats.record_generated_move(Duration::from_millis(1));
28stats.record_evaluated_move(Duration::from_millis(2));
29stats.end_phase();
30
31assert_eq!(stats.step_count(), 1);
32assert_eq!(stats.moves_evaluated(), 2);
33assert_eq!(stats.moves_accepted(), 1);
34assert_eq!(stats.snapshot().phases[0].moves_evaluated, 2);
35```
36*/
37#[derive(Debug, Clone, Default, PartialEq)]
38pub struct SelectorTelemetry {
39    pub selector_index: usize,
40    pub selector_label: String,
41    /// Candidate moves actually yielded by this selector.
42    /// This is runtime work, not the size of an unconsumed neighborhood.
43    pub moves_generated: u64,
44    pub moves_evaluated: u64,
45    pub moves_accepted: u64,
46    pub moves_applied: u64,
47    pub moves_not_doable: u64,
48    pub moves_acceptor_rejected: u64,
49    pub moves_forager_ignored: u64,
50    pub moves_hard_improving: u64,
51    pub moves_hard_neutral: u64,
52    pub moves_hard_worse: u64,
53    pub conflict_repair_provider_generated: u64,
54    pub conflict_repair_duplicate_filtered: u64,
55    pub conflict_repair_illegal_filtered: u64,
56    pub conflict_repair_not_doable_filtered: u64,
57    pub conflict_repair_hard_improving: u64,
58    pub conflict_repair_exposed: u64,
59    pub generation_time: Duration,
60    pub evaluation_time: Duration,
61}
62
63#[derive(Debug, Clone, Default, PartialEq)]
64pub struct MoveTelemetry {
65    pub move_label: String,
66    /// Candidate moves actually yielded to the engine across all phases.
67    /// Exhaust a cursor or query selector sizing separately for logical size.
68    pub moves_generated: u64,
69    pub moves_evaluated: u64,
70    pub moves_accepted: u64,
71    pub moves_applied: u64,
72    pub moves_not_doable: u64,
73    pub moves_acceptor_rejected: u64,
74    pub moves_forager_ignored: u64,
75    pub moves_score_improving: u64,
76    pub moves_applied_improving: u64,
77    pub moves_score_equal: u64,
78    pub moves_score_worse: u64,
79    pub moves_rejected_improving: u64,
80    pub applied_score_improvement: f64,
81}
82
83#[derive(Debug, Clone, Default, PartialEq)]
84pub struct PhaseTelemetry {
85    /// Zero-based position of this phase in the solve's execution order.
86    pub phase_index: usize,
87    pub phase_type: String,
88    pub elapsed: Duration,
89    pub step_count: u64,
90    /// Candidate moves actually yielded to the engine during this phase.
91    /// This is runtime work, not the size of an unconsumed neighborhood.
92    pub moves_generated: u64,
93    pub moves_evaluated: u64,
94    /// Candidates this phase's parallel evaluation evaluated ahead of the
95    /// sequential decision point and then discarded.
96    pub moves_speculative: u64,
97    pub moves_accepted: u64,
98    pub moves_applied: u64,
99    pub moves_score_improving: u64,
100    pub moves_applied_improving: u64,
101    pub score_calculations: u64,
102    pub generation_time: Duration,
103    pub evaluation_time: Duration,
104}
105
106#[derive(Debug, Clone, Copy, Default, PartialEq)]
107pub struct AppliedMoveTelemetry {
108    pub step_index: u64,
109    pub move_label: &'static str,
110    pub selected_candidate_index: usize,
111    pub moves_generated_this_step: u64,
112    pub moves_evaluated_this_step: u64,
113    pub moves_accepted_this_step: u64,
114    pub moves_forager_ignored_this_step: u64,
115    pub score_before: f64,
116    pub score_after: f64,
117    pub score_delta: f64,
118    pub hard_feasible_before: bool,
119    pub hard_feasible_after: bool,
120}
121
122#[derive(Debug, Clone, Default, PartialEq)]
123pub struct SolverTelemetry {
124    pub elapsed: Duration,
125    pub step_count: u64,
126    /// Candidate moves actually yielded to the engine across all phases.
127    /// Exhaust a cursor or query selector sizing separately for logical size.
128    pub moves_generated: u64,
129    pub moves_evaluated: u64,
130    /// Candidates evaluated ahead of the sequential decision point by parallel
131    /// evaluation and then discarded. Zero for sequential evaluation.
132    pub moves_speculative: u64,
133    pub moves_accepted: u64,
134    pub moves_applied: u64,
135    pub moves_score_improving: u64,
136    pub moves_applied_improving: u64,
137    pub moves_not_doable: u64,
138    pub moves_acceptor_rejected: u64,
139    pub moves_forager_ignored: u64,
140    pub moves_hard_improving: u64,
141    pub moves_hard_neutral: u64,
142    pub moves_hard_worse: u64,
143    pub conflict_repair_provider_generated: u64,
144    pub conflict_repair_duplicate_filtered: u64,
145    pub conflict_repair_illegal_filtered: u64,
146    pub conflict_repair_not_doable_filtered: u64,
147    pub conflict_repair_hard_improving: u64,
148    pub conflict_repair_exposed: u64,
149    pub score_calculations: u64,
150    pub construction_slots_assigned: u64,
151    pub construction_slots_kept: u64,
152    pub construction_slots_no_doable: u64,
153    pub scalar_assignment_required_remaining: u64,
154    pub generation_time: Duration,
155    pub evaluation_time: Duration,
156    /// Every phase started so far, in execution order; the last entry of a
157    /// progress snapshot is the phase still running. Run totals above equal
158    /// the sum of these phases plus work done outside any phase, such as the
159    /// initial score calculation.
160    pub phases: Vec<PhaseTelemetry>,
161    pub selector_telemetry: Vec<SelectorTelemetry>,
162    pub move_telemetry: Vec<MoveTelemetry>,
163    pub applied_move_trace: Vec<AppliedMoveTelemetry>,
164    /// Present only when `SolverConfig.candidate_trace` enabled bounded
165    /// core-owned candidate-pull diagnostics for this run.
166    pub candidate_trace: Option<CandidateTraceTelemetry>,
167}
168
169impl SolverTelemetry {
170    pub const fn new_const() -> Self {
171        Self {
172            elapsed: Duration::ZERO,
173            step_count: 0,
174            moves_generated: 0,
175            moves_evaluated: 0,
176            moves_speculative: 0,
177            moves_accepted: 0,
178            moves_applied: 0,
179            moves_score_improving: 0,
180            moves_applied_improving: 0,
181            moves_not_doable: 0,
182            moves_acceptor_rejected: 0,
183            moves_forager_ignored: 0,
184            moves_hard_improving: 0,
185            moves_hard_neutral: 0,
186            moves_hard_worse: 0,
187            conflict_repair_provider_generated: 0,
188            conflict_repair_duplicate_filtered: 0,
189            conflict_repair_illegal_filtered: 0,
190            conflict_repair_not_doable_filtered: 0,
191            conflict_repair_hard_improving: 0,
192            conflict_repair_exposed: 0,
193            score_calculations: 0,
194            construction_slots_assigned: 0,
195            construction_slots_kept: 0,
196            construction_slots_no_doable: 0,
197            scalar_assignment_required_remaining: 0,
198            generation_time: Duration::ZERO,
199            evaluation_time: Duration::ZERO,
200            phases: Vec::new(),
201            selector_telemetry: Vec::new(),
202            move_telemetry: Vec::new(),
203            applied_move_trace: Vec::new(),
204            candidate_trace: None,
205        }
206    }
207
208    /// Removes bounded candidate-pull diagnostic detail before ordinary
209    /// lifecycle publication.
210    ///
211    /// Candidate traces can be intentionally large (up to the configured
212    /// diagnostic ceiling). Progress events, retained status, and solution
213    /// snapshots are all normal control-plane traffic, so they must never
214    /// clone that detail. The retained manager keeps it in its dedicated
215    /// detail store and exposes it only through an explicit accessor.
216    pub(crate) fn take_candidate_trace(&mut self) -> Option<CandidateTraceTelemetry> {
217        self.candidate_trace.take()
218    }
219
220    /// Splits bounded diagnostic detail from compact publication telemetry
221    /// without cloning either payload.
222    pub(crate) fn split_candidate_trace(mut self) -> (Self, Option<CandidateTraceTelemetry>) {
223        let candidate_trace = self.take_candidate_trace();
224        (self, candidate_trace)
225    }
226}
227
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
229pub struct Throughput {
230    pub count: u64,
231    pub elapsed: Duration,
232}
233
234pub(crate) fn whole_units_per_second(count: u64, elapsed: Duration) -> u128 {
235    let nanos = elapsed.as_nanos();
236    if nanos == 0 {
237        0
238    } else {
239        u128::from(count)
240            .saturating_mul(1_000_000_000)
241            .checked_div(nanos)
242            .unwrap_or(0)
243    }
244}
245
246pub(crate) fn format_duration(duration: Duration) -> String {
247    let secs = duration.as_secs();
248    let nanos = duration.subsec_nanos();
249
250    if secs >= 60 {
251        let mins = secs / 60;
252        let rem_secs = secs % 60;
253        return format!("{mins}m {rem_secs}s");
254    }
255
256    if secs > 0 {
257        let millis = nanos / 1_000_000;
258        if millis == 0 {
259            return format!("{secs}s");
260        }
261        return format!("{secs}s {millis}ms");
262    }
263
264    let millis = nanos / 1_000_000;
265    if millis > 0 {
266        return format!("{millis}ms");
267    }
268
269    let micros = nanos / 1_000;
270    if micros > 0 {
271        return format!("{micros}us");
272    }
273
274    format!("{nanos}ns")
275}