Skip to main content

tacet_core/adaptive/
drift.rs

1//! Condition drift detection for Gate 4 (spec ยง3.5.4).
2//!
3//! Detects when measurement conditions change between calibration and the
4//! adaptive loop, which can invalidate the covariance estimate and cause
5//! false positives or negatives.
6
7use alloc::string::String;
8use alloc::vec::Vec;
9
10use crate::statistics::StatsSnapshot;
11
12/// Snapshot of measurement statistics at a point in time.
13///
14/// Captures per-class statistics for comparison between calibration
15/// and post-test phases.
16#[derive(Debug, Clone)]
17pub struct CalibrationSnapshot {
18    /// Statistics for baseline class.
19    pub baseline: StatsSnapshot,
20    /// Statistics for sample class.
21    pub sample: StatsSnapshot,
22}
23
24impl CalibrationSnapshot {
25    /// Create a new calibration snapshot from per-class statistics.
26    pub fn new(baseline: StatsSnapshot, sample: StatsSnapshot) -> Self {
27        Self { baseline, sample }
28    }
29}
30
31/// Detected drift between calibration and post-test statistics.
32///
33/// Contains per-class drift metrics that indicate how much measurement
34/// conditions changed during the test.
35#[derive(Debug, Clone, Copy)]
36pub struct ConditionDrift {
37    /// Variance ratio for baseline class: post_variance / cal_variance.
38    /// Values far from 1.0 indicate variance changed significantly.
39    pub variance_ratio_baseline: f64,
40
41    /// Variance ratio for sample class.
42    pub variance_ratio_sample: f64,
43
44    /// Absolute change in lag-1 autocorrelation for baseline class.
45    pub autocorr_change_baseline: f64,
46
47    /// Absolute change in lag-1 autocorrelation for sample class.
48    pub autocorr_change_sample: f64,
49
50    /// Mean drift in standard deviations for baseline class:
51    /// |post_mean - cal_mean| / cal_std_dev
52    pub mean_drift_baseline: f64,
53
54    /// Mean drift in standard deviations for sample class.
55    pub mean_drift_sample: f64,
56}
57
58impl ConditionDrift {
59    /// Compute drift between calibration and post-test snapshots.
60    ///
61    /// # Arguments
62    ///
63    /// * `cal` - Statistics snapshot from calibration phase
64    /// * `post` - Statistics snapshot from full test run
65    ///
66    /// # Returns
67    ///
68    /// A `ConditionDrift` struct with per-class drift metrics.
69    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    /// Check if drift exceeds thresholds.
99    ///
100    /// Returns `true` if any drift metric exceeds its threshold, indicating
101    /// measurement conditions changed significantly.
102    pub fn is_significant(&self, thresholds: &DriftThresholds) -> bool {
103        // Variance drift check (either direction)
104        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        // Autocorrelation change check
116        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        // Mean drift check
124        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    /// Get a human-readable description of the most significant drift.
135    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/// Thresholds for condition drift detection.
199///
200/// These control when Gate 6 (ConditionsChanged) triggers.
201#[derive(Debug, Clone, Copy)]
202pub struct DriftThresholds {
203    /// Maximum allowed variance ratio (post/cal). Default: 2.0
204    pub max_variance_ratio: f64,
205
206    /// Minimum allowed variance ratio (post/cal). Default: 0.5
207    pub min_variance_ratio: f64,
208
209    /// Maximum allowed change in lag-1 autocorrelation. Default: 0.3
210    pub max_autocorr_change: f64,
211
212    /// Maximum allowed mean drift in standard deviations. Default: 3.0
213    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
227/// Compute variance ratio, handling edge cases.
228fn compute_variance_ratio(cal_variance: f64, post_variance: f64) -> f64 {
229    if cal_variance < 1e-15 {
230        // If calibration variance was essentially zero, any change is infinite
231        // But if post is also zero, call it 1.0 (no change)
232        if post_variance < 1e-15 {
233            1.0
234        } else {
235            f64::INFINITY
236        }
237    } else {
238        post_variance / cal_variance
239    }
240}
241
242/// Compute mean drift in standard deviations.
243fn 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 calibration had zero variance, any mean change is significant
247        // But if means are equal, call it 0.0
248        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), // 3x variance increase
298            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), // 4x variance decrease
316            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), // 0.5 autocorr change
334            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), // std = 5
348            make_snapshot(100.0, 25.0, 0.3),
349        );
350        let post = CalibrationSnapshot::new(
351            make_snapshot(120.0, 25.0, 0.3), // 20/5 = 4 sigma drift
352            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), // Small changes within thresholds
370            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), // 3x variance
387            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), // 2.4x variance
405            make_snapshot(100.0, 25.0, 0.3),
406        );
407
408        let drift = ConditionDrift::compute(&cal, &post);
409
410        // Default threshold (2.0) would trigger
411        let default_thresholds = DriftThresholds::default();
412        assert!(drift.is_significant(&default_thresholds));
413
414        // Relaxed threshold (3.0) would not trigger
415        let relaxed_thresholds = DriftThresholds {
416            max_variance_ratio: 3.0,
417            ..Default::default()
418        };
419        assert!(!drift.is_significant(&relaxed_thresholds));
420    }
421}