1use alloc::string::String;
8use alloc::vec::Vec;
9
10use crate::statistics::StatsSnapshot;
11
12#[derive(Debug, Clone)]
17pub struct CalibrationSnapshot {
18 pub baseline: StatsSnapshot,
20 pub sample: StatsSnapshot,
22}
23
24impl CalibrationSnapshot {
25 pub fn new(baseline: StatsSnapshot, sample: StatsSnapshot) -> Self {
27 Self { baseline, sample }
28 }
29}
30
31#[derive(Debug, Clone, Copy)]
36pub struct ConditionDrift {
37 pub variance_ratio_baseline: f64,
40
41 pub variance_ratio_sample: f64,
43
44 pub autocorr_change_baseline: f64,
46
47 pub autocorr_change_sample: f64,
49
50 pub mean_drift_baseline: f64,
53
54 pub mean_drift_sample: f64,
56}
57
58impl ConditionDrift {
59 pub fn compute(cal: &CalibrationSnapshot, post: &CalibrationSnapshot) -> Self {
70 Self {
71 variance_ratio_baseline: compute_variance_ratio(
72 cal.baseline.variance,
73 post.baseline.variance,
74 ),
75 variance_ratio_sample: compute_variance_ratio(
76 cal.sample.variance,
77 post.sample.variance,
78 ),
79 autocorr_change_baseline: libm::fabs(
80 post.baseline.autocorr_lag1 - cal.baseline.autocorr_lag1,
81 ),
82 autocorr_change_sample: libm::fabs(
83 post.sample.autocorr_lag1 - cal.sample.autocorr_lag1,
84 ),
85 mean_drift_baseline: compute_mean_drift(
86 cal.baseline.mean,
87 post.baseline.mean,
88 cal.baseline.variance,
89 ),
90 mean_drift_sample: compute_mean_drift(
91 cal.sample.mean,
92 post.sample.mean,
93 cal.sample.variance,
94 ),
95 }
96 }
97
98 pub fn is_significant(&self, thresholds: &DriftThresholds) -> bool {
103 if self.variance_ratio_baseline > thresholds.max_variance_ratio
105 || self.variance_ratio_baseline < thresholds.min_variance_ratio
106 {
107 return true;
108 }
109 if self.variance_ratio_sample > thresholds.max_variance_ratio
110 || self.variance_ratio_sample < thresholds.min_variance_ratio
111 {
112 return true;
113 }
114
115 if self.autocorr_change_baseline > thresholds.max_autocorr_change {
117 return true;
118 }
119 if self.autocorr_change_sample > thresholds.max_autocorr_change {
120 return true;
121 }
122
123 if self.mean_drift_baseline > thresholds.max_mean_drift_sigmas {
125 return true;
126 }
127 if self.mean_drift_sample > thresholds.max_mean_drift_sigmas {
128 return true;
129 }
130
131 false
132 }
133
134 pub fn description(&self, thresholds: &DriftThresholds) -> String {
136 let mut issues = Vec::new();
137
138 if self.variance_ratio_baseline > thresholds.max_variance_ratio {
139 issues.push(alloc::format!(
140 "baseline variance increased {:.1}x",
141 self.variance_ratio_baseline
142 ));
143 } else if self.variance_ratio_baseline < thresholds.min_variance_ratio {
144 issues.push(alloc::format!(
145 "baseline variance decreased to {:.1}x",
146 self.variance_ratio_baseline
147 ));
148 }
149
150 if self.variance_ratio_sample > thresholds.max_variance_ratio {
151 issues.push(alloc::format!(
152 "sample variance increased {:.1}x",
153 self.variance_ratio_sample
154 ));
155 } else if self.variance_ratio_sample < thresholds.min_variance_ratio {
156 issues.push(alloc::format!(
157 "sample variance decreased to {:.1}x",
158 self.variance_ratio_sample
159 ));
160 }
161
162 if self.autocorr_change_baseline > thresholds.max_autocorr_change {
163 issues.push(alloc::format!(
164 "baseline autocorrelation changed by {:.2}",
165 self.autocorr_change_baseline
166 ));
167 }
168
169 if self.autocorr_change_sample > thresholds.max_autocorr_change {
170 issues.push(alloc::format!(
171 "sample autocorrelation changed by {:.2}",
172 self.autocorr_change_sample
173 ));
174 }
175
176 if self.mean_drift_baseline > thresholds.max_mean_drift_sigmas {
177 issues.push(alloc::format!(
178 "baseline mean drifted {:.1}\u{03C3}",
179 self.mean_drift_baseline
180 ));
181 }
182
183 if self.mean_drift_sample > thresholds.max_mean_drift_sigmas {
184 issues.push(alloc::format!(
185 "sample mean drifted {:.1}\u{03C3}",
186 self.mean_drift_sample
187 ));
188 }
189
190 if issues.is_empty() {
191 String::from("no significant drift")
192 } else {
193 issues.join(", ")
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy)]
202pub struct DriftThresholds {
203 pub max_variance_ratio: f64,
205
206 pub min_variance_ratio: f64,
208
209 pub max_autocorr_change: f64,
211
212 pub max_mean_drift_sigmas: f64,
214}
215
216impl Default for DriftThresholds {
217 fn default() -> Self {
218 Self {
219 max_variance_ratio: 2.0,
220 min_variance_ratio: 0.5,
221 max_autocorr_change: 0.3,
222 max_mean_drift_sigmas: 3.0,
223 }
224 }
225}
226
227fn compute_variance_ratio(cal_variance: f64, post_variance: f64) -> f64 {
229 if cal_variance < 1e-15 {
230 if post_variance < 1e-15 {
233 1.0
234 } else {
235 f64::INFINITY
236 }
237 } else {
238 post_variance / cal_variance
239 }
240}
241
242fn compute_mean_drift(cal_mean: f64, post_mean: f64, cal_variance: f64) -> f64 {
244 let cal_std = libm::sqrt(cal_variance);
245 if cal_std < 1e-15 {
246 if libm::fabs(post_mean - cal_mean) < 1e-15 {
249 0.0
250 } else {
251 f64::INFINITY
252 }
253 } else {
254 libm::fabs(post_mean - cal_mean) / cal_std
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn make_snapshot(mean: f64, variance: f64, autocorr: f64) -> StatsSnapshot {
263 StatsSnapshot {
264 mean,
265 variance,
266 autocorr_lag1: autocorr,
267 count: 1000,
268 }
269 }
270
271 #[test]
272 fn test_no_drift() {
273 let cal = CalibrationSnapshot::new(
274 make_snapshot(100.0, 25.0, 0.3),
275 make_snapshot(100.0, 25.0, 0.3),
276 );
277 let post = CalibrationSnapshot::new(
278 make_snapshot(100.0, 25.0, 0.3),
279 make_snapshot(100.0, 25.0, 0.3),
280 );
281
282 let drift = ConditionDrift::compute(&cal, &post);
283 let thresholds = DriftThresholds::default();
284
285 assert!(!drift.is_significant(&thresholds));
286 assert!(libm::fabs(drift.variance_ratio_baseline - 1.0) < 1e-10);
287 assert!(libm::fabs(drift.variance_ratio_sample - 1.0) < 1e-10);
288 }
289
290 #[test]
291 fn test_variance_increase_detected() {
292 let cal = CalibrationSnapshot::new(
293 make_snapshot(100.0, 25.0, 0.3),
294 make_snapshot(100.0, 25.0, 0.3),
295 );
296 let post = CalibrationSnapshot::new(
297 make_snapshot(100.0, 75.0, 0.3), make_snapshot(100.0, 25.0, 0.3),
299 );
300
301 let drift = ConditionDrift::compute(&cal, &post);
302 let thresholds = DriftThresholds::default();
303
304 assert!(drift.is_significant(&thresholds));
305 assert!(libm::fabs(drift.variance_ratio_baseline - 3.0) < 1e-10);
306 }
307
308 #[test]
309 fn test_variance_decrease_detected() {
310 let cal = CalibrationSnapshot::new(
311 make_snapshot(100.0, 100.0, 0.3),
312 make_snapshot(100.0, 100.0, 0.3),
313 );
314 let post = CalibrationSnapshot::new(
315 make_snapshot(100.0, 25.0, 0.3), make_snapshot(100.0, 100.0, 0.3),
317 );
318
319 let drift = ConditionDrift::compute(&cal, &post);
320 let thresholds = DriftThresholds::default();
321
322 assert!(drift.is_significant(&thresholds));
323 assert!(libm::fabs(drift.variance_ratio_baseline - 0.25) < 1e-10);
324 }
325
326 #[test]
327 fn test_autocorr_change_detected() {
328 let cal = CalibrationSnapshot::new(
329 make_snapshot(100.0, 25.0, 0.1),
330 make_snapshot(100.0, 25.0, 0.1),
331 );
332 let post = CalibrationSnapshot::new(
333 make_snapshot(100.0, 25.0, 0.6), make_snapshot(100.0, 25.0, 0.1),
335 );
336
337 let drift = ConditionDrift::compute(&cal, &post);
338 let thresholds = DriftThresholds::default();
339
340 assert!(drift.is_significant(&thresholds));
341 assert!(libm::fabs(drift.autocorr_change_baseline - 0.5) < 1e-10);
342 }
343
344 #[test]
345 fn test_mean_drift_detected() {
346 let cal = CalibrationSnapshot::new(
347 make_snapshot(100.0, 25.0, 0.3), make_snapshot(100.0, 25.0, 0.3),
349 );
350 let post = CalibrationSnapshot::new(
351 make_snapshot(120.0, 25.0, 0.3), make_snapshot(100.0, 25.0, 0.3),
353 );
354
355 let drift = ConditionDrift::compute(&cal, &post);
356 let thresholds = DriftThresholds::default();
357
358 assert!(drift.is_significant(&thresholds));
359 assert!(libm::fabs(drift.mean_drift_baseline - 4.0) < 1e-10);
360 }
361
362 #[test]
363 fn test_small_drift_allowed() {
364 let cal = CalibrationSnapshot::new(
365 make_snapshot(100.0, 25.0, 0.3),
366 make_snapshot(100.0, 25.0, 0.3),
367 );
368 let post = CalibrationSnapshot::new(
369 make_snapshot(102.0, 30.0, 0.35), make_snapshot(98.0, 20.0, 0.25),
371 );
372
373 let drift = ConditionDrift::compute(&cal, &post);
374 let thresholds = DriftThresholds::default();
375
376 assert!(!drift.is_significant(&thresholds));
377 }
378
379 #[test]
380 fn test_description() {
381 let cal = CalibrationSnapshot::new(
382 make_snapshot(100.0, 25.0, 0.1),
383 make_snapshot(100.0, 25.0, 0.1),
384 );
385 let post = CalibrationSnapshot::new(
386 make_snapshot(100.0, 75.0, 0.1), make_snapshot(100.0, 25.0, 0.1),
388 );
389
390 let drift = ConditionDrift::compute(&cal, &post);
391 let thresholds = DriftThresholds::default();
392 let desc = drift.description(&thresholds);
393
394 assert!(desc.contains("baseline variance increased"));
395 }
396
397 #[test]
398 fn test_custom_thresholds() {
399 let cal = CalibrationSnapshot::new(
400 make_snapshot(100.0, 25.0, 0.3),
401 make_snapshot(100.0, 25.0, 0.3),
402 );
403 let post = CalibrationSnapshot::new(
404 make_snapshot(100.0, 60.0, 0.3), make_snapshot(100.0, 25.0, 0.3),
406 );
407
408 let drift = ConditionDrift::compute(&cal, &post);
409
410 let default_thresholds = DriftThresholds::default();
412 assert!(drift.is_significant(&default_thresholds));
413
414 let relaxed_thresholds = DriftThresholds {
416 max_variance_ratio: 3.0,
417 ..Default::default()
418 };
419 assert!(!drift.is_significant(&relaxed_thresholds));
420 }
421}