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