1use std::{
2 sync::{Arc, Mutex, MutexGuard},
3 time::Instant,
4};
5
6use crate::{
7 Array2, Result,
8 callbacks::{Callback, CallbackAction, StepContext},
9 reconstruction::ReconstructionResult,
10};
11
12use super::{
13 DiagnosticRequest, IterationDiagnostics, ReconstructionDiagnostics, compute_fourier_coverage,
14};
15
16#[derive(Clone, Debug)]
17pub struct DiagnosticRecorderConfig {
18 pub every: usize,
19
20 pub record_iteration_history: bool,
21 pub record_frame_summaries: bool,
22 pub record_raw_stack_stats: bool,
23 pub record_coverage: bool,
24
25 pub record_object_snapshots: bool,
26 pub record_pupil_snapshots: bool,
27 pub snapshot_every: usize,
28}
29
30impl Default for DiagnosticRecorderConfig {
31 fn default() -> Self {
32 Self {
33 every: 1,
34 record_iteration_history: true,
35 record_frame_summaries: false,
36 record_raw_stack_stats: false,
37 record_coverage: false,
38 record_object_snapshots: false,
39 record_pupil_snapshots: false,
40 snapshot_every: 10,
41 }
42 }
43}
44
45#[derive(Default)]
46struct DiagnosticRecorderState {
47 diagnostics: ReconstructionDiagnostics,
48 started_at: Option<Instant>,
49 previous_object: Option<Array2<Complex64Proxy>>,
50 previous_pupil: Option<Array2<Complex64Proxy>>,
51 object_snapshots: Vec<(usize, Array2<f64>, Array2<f64>)>,
52 pupil_snapshots: Vec<(usize, Array2<f64>, Array2<f64>)>,
53}
54
55#[derive(Clone)]
73pub struct DiagnosticRecorder {
74 config: DiagnosticRecorderConfig,
75 state: Arc<Mutex<DiagnosticRecorderState>>,
76}
77
78type Complex64Proxy = num_complex::Complex64;
79
80impl DiagnosticRecorder {
81 pub fn new(config: DiagnosticRecorderConfig) -> Self {
82 Self {
83 config,
84 state: Arc::new(Mutex::new(DiagnosticRecorderState::default())),
85 }
86 }
87
88 pub fn diagnostics(&self) -> ReconstructionDiagnostics {
89 self.lock_state().diagnostics.clone()
90 }
91
92 pub fn into_diagnostics(self) -> ReconstructionDiagnostics {
93 self.diagnostics()
94 }
95
96 pub fn object_snapshots(&self) -> Vec<(usize, Array2<f64>, Array2<f64>)> {
97 self.lock_state().object_snapshots.clone()
98 }
99
100 pub fn pupil_snapshots(&self) -> Vec<(usize, Array2<f64>, Array2<f64>)> {
101 self.lock_state().pupil_snapshots.clone()
102 }
103
104 pub fn reset(&self) {
106 *self.lock_state() = DiagnosticRecorderState::default();
107 }
108
109 fn lock_state(&self) -> MutexGuard<'_, DiagnosticRecorderState> {
110 self.state
111 .lock()
112 .unwrap_or_else(|poisoned| poisoned.into_inner())
113 }
114}
115
116impl Callback for DiagnosticRecorder {
117 fn requires_for(
118 &self,
119 hook: crate::callbacks::CallbackHook,
120 iteration: usize,
121 ) -> Vec<DiagnosticRequest> {
122 let should_record = cadence_matches(iteration, self.config.every);
123 let should_snapshot = cadence_matches(iteration, self.config.snapshot_every);
124 match hook {
125 crate::callbacks::CallbackHook::Start => {
126 let mut requests = Vec::new();
127 if self.config.record_raw_stack_stats {
128 requests.push(DiagnosticRequest::RawFrameStats);
129 }
130 requests
131 }
132 crate::callbacks::CallbackHook::IterationEnd if should_record || should_snapshot => {
133 let mut requests = Vec::new();
134 if should_record && self.config.record_iteration_history {
135 requests.push(DiagnosticRequest::Loss);
136 requests.push(DiagnosticRequest::PerFrameError);
137 }
138 if should_record && self.config.record_frame_summaries {
139 requests.push(DiagnosticRequest::FrameSummaries);
140 }
141 if should_snapshot && self.config.record_object_snapshots {
142 requests.push(DiagnosticRequest::ObjectAmplitude);
143 requests.push(DiagnosticRequest::ObjectPhase);
144 }
145 if should_snapshot && self.config.record_pupil_snapshots {
146 requests.push(DiagnosticRequest::Pupil);
147 }
148 requests
149 }
150 _ => Vec::new(),
151 }
152 }
153
154 fn on_start(&mut self, context: &StepContext<'_>) -> Result<CallbackAction> {
155 let mut state = self.lock_state();
156 *state = DiagnosticRecorderState::default();
157 state.started_at = Some(Instant::now());
158 if self.config.record_coverage {
159 state.diagnostics.coverage = Some(compute_fourier_coverage(context.model)?);
160 }
161 if let Some(raw_frame_stats) = &context.diagnostics.raw_frame_stats {
162 state
163 .diagnostics
164 .raw_frame_stats
165 .extend(raw_frame_stats.iter().cloned());
166 }
167 Ok(CallbackAction::Continue)
168 }
169
170 fn on_iteration_end(&mut self, context: &StepContext<'_>) -> Result<CallbackAction> {
171 let should_record = cadence_matches(context.iteration, self.config.every);
172 let should_snapshot = cadence_matches(context.iteration, self.config.snapshot_every);
173 let mut state = self.lock_state();
174
175 if should_record && self.config.record_iteration_history {
176 let elapsed_ms = state
177 .started_at
178 .map(|started| started.elapsed().as_secs_f64() * 1e3);
179 let object_relative_change = relative_change(
180 state.previous_object.as_ref(),
181 &context.state.object_spectrum,
182 );
183 let pupil_relative_change =
184 relative_change(state.previous_pupil.as_ref(), &context.state.pupil.values);
185 state
186 .diagnostics
187 .iteration_history
188 .push(IterationDiagnostics {
189 iteration: context.iteration,
190 total_loss: context.diagnostics.loss,
191 data_loss: context.diagnostics.loss,
192 regularization_loss: None,
193 object_relative_change,
194 pupil_relative_change,
195 median_frame_loss: context
196 .diagnostics
197 .per_frame_error
198 .as_ref()
199 .and_then(|values| median(values)),
200 worst_frame_loss: context
201 .diagnostics
202 .per_frame_error
203 .as_ref()
204 .and_then(|values| values.iter().copied().reduce(f64::max)),
205 elapsed_ms,
206 });
207 }
208 if should_record {
209 state.previous_object = Some(context.state.object_spectrum.clone());
210 state.previous_pupil = Some(context.state.pupil.values.clone());
211 }
212
213 if should_record && let Some(frame_diagnostics) = &context.diagnostics.frame_diagnostics {
214 state
215 .diagnostics
216 .frame_diagnostics
217 .extend(frame_diagnostics.iter().cloned());
218 }
219 if self.config.record_object_snapshots
220 && should_snapshot
221 && let (Some(amplitude), Some(phase)) = (
222 context.diagnostics.object_amplitude.clone(),
223 context.diagnostics.object_phase.clone(),
224 )
225 {
226 state
227 .object_snapshots
228 .push((context.iteration, amplitude, phase));
229 }
230 if self.config.record_pupil_snapshots
231 && should_snapshot
232 && let (Some(amplitude), Some(phase)) = (
233 context.diagnostics.pupil_amplitude.clone(),
234 context.diagnostics.pupil_phase.clone(),
235 )
236 {
237 state
238 .pupil_snapshots
239 .push((context.iteration, amplitude, phase));
240 }
241 Ok(CallbackAction::Continue)
242 }
243
244 fn on_finish(&mut self, _result: &ReconstructionResult) -> Result<()> {
245 Ok(())
246 }
247}
248
249fn cadence_matches(iteration: usize, every: usize) -> bool {
250 every > 0 && iteration.is_multiple_of(every)
251}
252
253fn relative_change(
254 previous: Option<&Array2<Complex64Proxy>>,
255 current: &Array2<Complex64Proxy>,
256) -> Option<f64> {
257 let previous = previous?;
258 if previous.shape() != current.shape() {
259 return None;
260 }
261 let mut difference = 0.0;
262 let mut reference = 0.0;
263 for (&previous, ¤t) in previous.as_slice().iter().zip(current.as_slice()) {
264 difference += (current - previous).norm_sqr();
265 reference += previous.norm_sqr();
266 }
267 Some(difference.sqrt() / reference.sqrt().max(f64::EPSILON))
268}
269
270fn median(values: &[f64]) -> Option<f64> {
271 if values.is_empty() {
272 return None;
273 }
274 let mut sorted = values.to_vec();
275 sorted.sort_by(|a, b| a.total_cmp(b));
276 let mid = sorted.len() / 2;
277 Some(if sorted.len().is_multiple_of(2) {
278 0.5 * (sorted[mid - 1] + sorted[mid])
279 } else {
280 sorted[mid]
281 })
282}
283
284#[cfg(test)]
285mod tests {
286 use std::panic::{AssertUnwindSafe, catch_unwind};
287
288 use super::*;
289
290 #[test]
291 fn poisoned_recorder_state_remains_resettable_and_readable() {
292 let recorder = DiagnosticRecorder::new(DiagnosticRecorderConfig::default());
293 let state = recorder.state.clone();
294 let result = catch_unwind(AssertUnwindSafe(|| {
295 let _guard = state.lock().unwrap();
296 panic!("intentional recorder-lock poison for test");
297 }));
298 assert!(result.is_err());
299
300 recorder.reset();
301 assert!(recorder.diagnostics().iteration_history.is_empty());
302 assert!(recorder.object_snapshots().is_empty());
303 assert!(recorder.pupil_snapshots().is_empty());
304 }
305}