Skip to main content

trustformers_debug/kernel_optimizer/
regression.rs

1//! Performance-regression detection for `KernelOptimizationAnalyzer`.
2//!
3//! [`PerformanceRegressionDetector`] records each kernel's execution-time
4//! history, establishes a real baseline distribution once enough samples
5//! exist, and tests later measurements against that baseline with a
6//! genuine Welch's t-test (see [`super::analysis::compare_to_baseline`]) --
7//! never a hardcoded "stable" verdict. Every field on every type in this
8//! module is either measured from real samples or an honest `None`/empty
9//! collection when there is not yet enough data; nothing here is a
10//! disclosed-but-never-computed placeholder.
11
12use std::collections::HashMap;
13use std::time::{Duration, SystemTime};
14
15use anyhow::Result;
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::ring_buffer::TimestampedRingBuffer;
20
21use super::analysis;
22use super::KernelProfileData;
23
24/// Performance regression detection
25#[derive(Debug)]
26pub struct PerformanceRegressionDetector {
27    baseline_profiles: HashMap<String, BaselineProfile>,
28    regression_alerts: Vec<RegressionAlert>,
29    statistical_analyzer: StatisticalAnalyzer,
30    alert_thresholds: RegressionThresholds,
31    /// Bounded, timestamped execution-time history (seconds) per kernel --
32    /// the real raw data [`Self::check_regression`]/[`Self::get_status`]
33    /// compare against `baseline_profiles`. Bounded per-kernel via
34    /// [`TimestampedRingBuffer`]'s fixed capacity so memory stays bounded
35    /// across a long-running process.
36    execution_history: HashMap<String, TimestampedRingBuffer<f64>>,
37}
38
39/// Per-kernel execution-time history capacity (samples). Old samples are
40/// evicted once a kernel's history exceeds this.
41const EXECUTION_HISTORY_CAPACITY: usize = 500;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct BaselineProfile {
45    pub kernel_name: String,
46    pub baseline_performance: Duration,
47    pub performance_distribution: PerformanceDistribution,
48    pub established_date: SystemTime,
49    pub confidence_interval: (Duration, Duration),
50}
51
52/// Summary statistics of a kernel's baseline execution-time samples.
53///
54/// **Canonical unit: seconds, as `f64`.** These used to be `std::time::Duration`,
55/// which quantizes to whole nanoseconds -- so the same measurements expressed
56/// on a different time scale produced different published statistics, and a
57/// microsecond-scale kernel's standard deviation could round-trip to `0 ns`,
58/// driving the Welch t-statistic to infinity and its p-value to exactly 0.
59/// `Duration` is still used for the human-facing latency fields on
60/// [`BaselineProfile`] and [`RegressionAlert`]; nothing that feeds the
61/// statistics goes through it any more.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PerformanceDistribution {
64    /// Arithmetic mean of the baseline samples, in seconds.
65    pub mean_secs: f64,
66    /// Sample standard deviation of the baseline samples, in seconds.
67    pub std_dev_secs: f64,
68    /// 50th, 90th, 95th and 99th percentiles of the baseline samples, in seconds.
69    pub percentiles: HashMap<u8, f64>,
70    /// `mean + 3 * std_dev`, in seconds.
71    pub outlier_threshold_secs: f64,
72    /// Number of real samples the distribution was estimated from --
73    /// required for the Welch's t-test comparison in
74    /// [`PerformanceRegressionDetector::check_regression`] /
75    /// [`PerformanceRegressionDetector::get_status`].
76    pub sample_count: usize,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RegressionAlert {
81    pub alert_id: Uuid,
82    pub kernel_name: String,
83    pub alert_type: RegressionType,
84    pub severity: RegressionSeverity,
85    pub current_performance: Duration,
86    pub baseline_performance: Duration,
87    pub regression_magnitude: f64,
88    pub detection_timestamp: SystemTime,
89    pub potential_causes: Vec<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub enum RegressionType {
94    PerformanceDegradation,
95    MemoryUsageIncrease,
96    OccupancyDecrease,
97    BandwidthUtilizationDrop,
98    EnergyEfficiencyLoss,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub enum RegressionSeverity {
103    Minor,    // < 5% regression
104    Moderate, // 5-15% regression
105    Major,    // 15-30% regression
106    Critical, // > 30% regression
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RegressionThresholds {
111    /// Minimum magnitude (fraction, e.g. `0.05` = 5%) for a statistically
112    /// significant slowdown to count as a regression at all -- the entry
113    /// gate checked by `super::analysis::detect_regression`, not a
114    /// [`RegressionSeverity`] bucket boundary (see
115    /// `super::analysis::classify_severity`).
116    pub minor_threshold: f64,
117    pub moderate_threshold: f64,
118    pub major_threshold: f64,
119    /// Retained for API/config completeness (a caller may reasonably
120    /// expect a "critical" knob alongside the other three), but not
121    /// currently consumed by `super::analysis::classify_severity`:
122    /// with 4 severities and 4 fields, 3 boundaries already fully
123    /// partition the magnitude axis into 4 buckets once
124    /// `minor_threshold` is spoken for as the entry gate above, so this
125    /// field has no remaining boundary to own without either
126    /// contradicting [`RegressionSeverity`]'s own documented ranges or
127    /// leaving a fifth, unreachable bucket.
128    pub critical_threshold: f64,
129    pub detection_window: Duration,
130    pub confidence_level: f64,
131}
132
133#[derive(Debug)]
134pub struct StatisticalAnalyzer {
135    sample_size_requirements: HashMap<String, usize>,
136    statistical_tests: Vec<StatisticalTest>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct StatisticalTest {
141    pub test_name: String,
142    pub test_type: TestType,
143    /// Alpha the test was judged at (`1 - confidence_level`).
144    pub significance_level: f64,
145    /// Observed (post-hoc) power of the test -- see
146    /// `super::analysis::observed_power` -- or `None` when it is not
147    /// computable from the recorded statistic.
148    ///
149    /// This field was called `power` and was set to `1.0 - p_value`, which is
150    /// the confidence in the observed result, not `P(reject H0 | H1 true)`.
151    pub observed_power: Option<f64>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub enum TestType {
156    TTest,
157    MannWhitneyU,
158    KolmogorovSmirnov,
159    ChangePointDetection,
160    AnomalyDetection,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct RegressionStatus {
165    /// `true` only when the most recent comparison found a statistically
166    /// significant slowdown beyond the detector's `minor_threshold` -- see
167    /// [`PerformanceRegressionDetector::check_regression`]. Structurally
168    /// capable of being `true`: this is not a constant.
169    pub has_regression: bool,
170    /// All alerts raised for this kernel so far (each already gated on
171    /// significance + magnitude at the time it fired).
172    pub regression_alerts: Vec<RegressionAlert>,
173    pub performance_trend: PerformanceTrend,
174    pub baseline_comparison: BaselineComparison,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub enum PerformanceTrend {
179    Improving,
180    Stable,
181    Degrading,
182    Volatile,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct BaselineComparison {
187    /// Percentage difference of the recent sample's mean vs the baseline
188    /// mean (positive = slower). Real per-comparison value, computed from
189    /// `analysis::compare_to_baseline`'s Welch's t-test.
190    pub current_vs_baseline: f64,
191    /// `1.0 - p_value` of the same Welch's t-test (higher = more
192    /// significant a change was detected). Not a fixed confidence-level
193    /// constant.
194    pub statistical_significance: f64,
195    /// 95% confidence interval on `current_vs_baseline`, expressed as a
196    /// fraction (not a percentage) of the baseline mean.
197    pub confidence_interval: (f64, f64),
198}
199
200impl PerformanceRegressionDetector {
201    pub fn new() -> Result<Self> {
202        Ok(Self {
203            baseline_profiles: HashMap::new(),
204            regression_alerts: vec![],
205            statistical_analyzer: StatisticalAnalyzer::new()?,
206            alert_thresholds: RegressionThresholds {
207                minor_threshold: 0.05,
208                moderate_threshold: 0.15,
209                major_threshold: 0.30,
210                critical_threshold: 0.50,
211                detection_window: Duration::from_secs(3600),
212                confidence_level: 0.95,
213            },
214            execution_history: HashMap::new(),
215        })
216    }
217
218    pub fn new_empty() -> Self {
219        Self {
220            baseline_profiles: HashMap::new(),
221            regression_alerts: vec![],
222            statistical_analyzer: StatisticalAnalyzer::new_empty(),
223            alert_thresholds: RegressionThresholds {
224                minor_threshold: 0.05,
225                moderate_threshold: 0.15,
226                major_threshold: 0.30,
227                critical_threshold: 0.50,
228                detection_window: Duration::from_secs(3600),
229                confidence_level: 0.95,
230            },
231            execution_history: HashMap::new(),
232        }
233    }
234
235    /// Real implementation shared by [`Self::check_regression`] (which logs
236    /// a new alert as a side effect) and [`Self::get_status`] (a pure
237    /// query): compute the Welch's t-test outcome for `kernel_name`'s
238    /// recent samples -- those recorded since the baseline was
239    /// established, bounded to `alert_thresholds.detection_window` -- vs
240    /// its established baseline.
241    ///
242    /// Returns `None` when there is no baseline yet, fewer than
243    /// [`analysis::MIN_COMPARISON_SAMPLES`] recent samples fall inside the
244    /// window, or the underlying test itself has no meaningful result to
245    /// report (see [`analysis::compare_to_baseline`]).
246    fn recent_comparison(
247        &self,
248        kernel_name: &str,
249        now_ns: u64,
250    ) -> Option<analysis::BaselineTestOutcome> {
251        let baseline = self.baseline_profiles.get(kernel_name)?;
252        let history = self.execution_history.get(kernel_name)?;
253
254        let established_ns = analysis::system_time_to_ns(baseline.established_date);
255        let window_start_ns = established_ns
256            .max(now_ns.saturating_sub(self.alert_thresholds.detection_window.as_nanos() as u64));
257        let recent = history.values_in_range(window_start_ns, now_ns);
258        if recent.len() < analysis::MIN_COMPARISON_SAMPLES {
259            return None;
260        }
261
262        analysis::compare_to_baseline(
263            baseline.performance_distribution.mean_secs,
264            baseline.performance_distribution.std_dev_secs,
265            baseline.performance_distribution.sample_count,
266            &recent,
267        )
268    }
269
270    /// Record one real execution-time measurement and, once enough history
271    /// exists, either establish this kernel's baseline or test the recent
272    /// window against it -- pushing a real [`RegressionAlert`] only when
273    /// the Welch's t-test finds a statistically significant slowdown
274    /// beyond `alert_thresholds.minor_threshold`. Never a no-op that
275    /// silently discards `profile_data`.
276    pub fn check_regression(
277        &mut self,
278        kernel_name: &str,
279        profile_data: &KernelProfileData,
280    ) -> Result<()> {
281        let now_ns = analysis::system_time_to_ns(SystemTime::now());
282        let sample_secs = profile_data.execution_time.as_secs_f64();
283
284        self.execution_history
285            .entry(kernel_name.to_string())
286            .or_insert_with(|| TimestampedRingBuffer::new(EXECUTION_HISTORY_CAPACITY))
287            .push_now(sample_secs, now_ns);
288
289        if !self.baseline_profiles.contains_key(kernel_name) {
290            // Not enough data to establish a baseline yet is an honest
291            // absence, not a fabricated "no regression" -- only act once
292            // real history has accumulated.
293            if let Some(samples) = self.execution_history.get(kernel_name).and_then(|h| {
294                (h.len() >= analysis::MIN_BASELINE_SAMPLES)
295                    .then(|| h.iter_ordered().map(|v| v.value).collect::<Vec<f64>>())
296            }) {
297                let baseline = analysis::establish_baseline(kernel_name, &samples);
298                self.statistical_analyzer
299                    .sample_size_requirements
300                    .insert(kernel_name.to_string(), analysis::MIN_BASELINE_SAMPLES);
301                self.baseline_profiles.insert(kernel_name.to_string(), baseline);
302            }
303            return Ok(());
304        }
305
306        let Some(test_outcome) = self.recent_comparison(kernel_name, now_ns) else {
307            return Ok(());
308        };
309
310        let alpha = 1.0 - self.alert_thresholds.confidence_level;
311        self.statistical_analyzer.statistical_tests.push(StatisticalTest {
312            test_name: format!("Welch's t-test ({kernel_name})"),
313            test_type: TestType::TTest,
314            significance_level: alpha,
315            observed_power: analysis::observed_power(
316                test_outcome.t_statistic,
317                test_outcome.degrees_of_freedom,
318                alpha,
319            ),
320        });
321
322        let baseline = self
323            .baseline_profiles
324            .get(kernel_name)
325            .ok_or_else(|| anyhow::anyhow!("baseline for '{}' vanished mid-check", kernel_name))?;
326        let check = analysis::detect_regression(
327            kernel_name,
328            baseline,
329            test_outcome,
330            &self.alert_thresholds,
331        );
332        if let Some(alert) = check.new_alert {
333            self.regression_alerts.push(alert);
334        }
335
336        Ok(())
337    }
338
339    /// Real regression status for `kernel_name`, freshly recomputed from
340    /// its execution-time history against its established baseline.
341    ///
342    /// Returns `Ok(None)` -- never a fabricated "stable" status --
343    /// whenever there is not yet a baseline, or not yet enough recent
344    /// samples in the detection window to compare against it. A pure
345    /// query: unlike [`Self::check_regression`], it never appends to
346    /// `regression_alerts`.
347    pub fn get_status(&self, kernel_name: &str) -> Result<Option<RegressionStatus>> {
348        if !self.baseline_profiles.contains_key(kernel_name) {
349            return Ok(None);
350        }
351        let now_ns = analysis::system_time_to_ns(SystemTime::now());
352        let Some(test_outcome) = self.recent_comparison(kernel_name, now_ns) else {
353            return Ok(None);
354        };
355        let baseline = self
356            .baseline_profiles
357            .get(kernel_name)
358            .ok_or_else(|| anyhow::anyhow!("baseline for '{}' vanished mid-check", kernel_name))?;
359        let check = analysis::detect_regression(
360            kernel_name,
361            baseline,
362            test_outcome,
363            &self.alert_thresholds,
364        );
365
366        let regression_alerts: Vec<RegressionAlert> = self
367            .regression_alerts
368            .iter()
369            .filter(|alert| alert.kernel_name == kernel_name)
370            .cloned()
371            .collect();
372
373        Ok(Some(RegressionStatus {
374            has_regression: check.new_alert.is_some(),
375            regression_alerts,
376            performance_trend: check.performance_trend,
377            baseline_comparison: check.baseline_comparison,
378        }))
379    }
380}
381
382impl StatisticalAnalyzer {
383    fn new() -> Result<Self> {
384        Ok(Self {
385            sample_size_requirements: HashMap::new(),
386            statistical_tests: vec![],
387        })
388    }
389
390    fn new_empty() -> Self {
391        Self {
392            sample_size_requirements: HashMap::new(),
393            statistical_tests: vec![],
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::time::Duration as StdDuration;
402
403    fn thresholds() -> RegressionThresholds {
404        RegressionThresholds {
405            minor_threshold: 0.05,
406            moderate_threshold: 0.15,
407            major_threshold: 0.30,
408            critical_threshold: 0.50,
409            detection_window: StdDuration::from_secs(3600),
410            confidence_level: 0.95,
411        }
412    }
413
414    fn profile(exec_secs: f64) -> KernelProfileData {
415        KernelProfileData {
416            execution_time: StdDuration::from_secs_f64(exec_secs),
417            grid_size: (128, 1, 1),
418            block_size: (256, 1, 1),
419            shared_memory_bytes: 4096,
420            registers_per_thread: 32,
421            occupancy: 0.5,
422            compute_utilization: 0.5,
423            memory_bandwidth_utilization: 0.5,
424            warp_efficiency: 0.9,
425            memory_efficiency: 0.8,
426        }
427    }
428
429    #[test]
430    fn test_new_has_no_baseline_and_no_status() {
431        let detector = PerformanceRegressionDetector::new().expect("new ok");
432        assert!(
433            detector.get_status("nope").expect("get_status ok").is_none(),
434            "an unknown kernel has no baseline, so status must be an honest None"
435        );
436    }
437
438    #[test]
439    fn test_status_stays_none_below_baseline_sample_count() {
440        let mut detector = PerformanceRegressionDetector::new().expect("new ok");
441        for _ in 0..(analysis::MIN_BASELINE_SAMPLES - 1) {
442            detector.check_regression("k", &profile(0.001)).expect("check ok");
443        }
444        assert!(
445            detector.get_status("k").expect("get_status ok").is_none(),
446            "fewer than MIN_BASELINE_SAMPLES real measurements must not fabricate a baseline"
447        );
448    }
449
450    #[test]
451    fn test_stable_kernel_reports_no_regression_with_real_stats() {
452        let mut detector = PerformanceRegressionDetector::new().expect("new ok");
453        // Constant 1ms execution time: baseline establishes, then recent
454        // samples exactly match it -- no regression, but the comparison
455        // itself must be real (not the old unconditional constant).
456        for _ in 0..(analysis::MIN_BASELINE_SAMPLES + analysis::MIN_COMPARISON_SAMPLES) {
457            detector.check_regression("stable_kernel", &profile(0.001)).expect("check ok");
458        }
459        let status = detector
460            .get_status("stable_kernel")
461            .expect("get_status ok")
462            .expect("baseline should be established by now");
463        assert!(
464            !status.has_regression,
465            "identical samples must not be flagged as a regression"
466        );
467        assert!(
468            status.baseline_comparison.current_vs_baseline.abs() < 1e-6,
469            "recent mean equals baseline mean -> ~0% difference, got {}",
470            status.baseline_comparison.current_vs_baseline
471        );
472    }
473
474    #[test]
475    fn test_significant_slowdown_produces_real_alert() {
476        let mut detector = PerformanceRegressionDetector::new().expect("new ok");
477        // Establish a tight baseline around 1ms.
478        for _ in 0..20 {
479            detector.check_regression("slow_kernel", &profile(0.0010)).expect("check ok");
480        }
481        // Now feed a consistently, dramatically slower recent window (2x).
482        for _ in 0..10 {
483            detector.check_regression("slow_kernel", &profile(0.0020)).expect("check ok");
484        }
485        let status = detector
486            .get_status("slow_kernel")
487            .expect("get_status ok")
488            .expect("baseline established");
489        assert!(
490            status.has_regression,
491            "a real, sustained 2x slowdown must be detected, got {:?}",
492            status.baseline_comparison
493        );
494        assert!(
495            status.baseline_comparison.current_vs_baseline > 50.0,
496            "current_vs_baseline should reflect the real ~100% slowdown, got {}",
497            status.baseline_comparison.current_vs_baseline
498        );
499        assert!(
500            !status.regression_alerts.is_empty(),
501            "check_regression must have logged a real RegressionAlert"
502        );
503        assert_eq!(status.regression_alerts[0].kernel_name, "slow_kernel");
504    }
505
506    #[test]
507    fn test_speedup_is_improving_not_a_regression() {
508        let mut detector = PerformanceRegressionDetector::new().expect("new ok");
509        for _ in 0..20 {
510            detector.check_regression("fast_kernel", &profile(0.0020)).expect("check ok");
511        }
512        for _ in 0..10 {
513            detector.check_regression("fast_kernel", &profile(0.0005)).expect("check ok");
514        }
515        let status = detector
516            .get_status("fast_kernel")
517            .expect("get_status ok")
518            .expect("baseline established");
519        assert!(
520            !status.has_regression,
521            "getting faster must never be reported as a regression"
522        );
523        assert!(
524            status.baseline_comparison.current_vs_baseline < 0.0,
525            "a real speedup must show a negative current_vs_baseline, got {}",
526            status.baseline_comparison.current_vs_baseline
527        );
528    }
529
530    #[test]
531    fn test_classify_severity_uses_configured_thresholds() {
532        let t = thresholds();
533        assert!(matches!(
534            analysis::classify_severity(0.04, &t),
535            RegressionSeverity::Minor
536        ));
537        assert!(matches!(
538            analysis::classify_severity(0.10, &t),
539            RegressionSeverity::Moderate
540        ));
541        assert!(matches!(
542            analysis::classify_severity(0.20, &t),
543            RegressionSeverity::Major
544        ));
545        assert!(matches!(
546            analysis::classify_severity(0.60, &t),
547            RegressionSeverity::Critical
548        ));
549    }
550
551    #[test]
552    fn test_shrinking_variance_is_not_mislabeled_volatile() {
553        // Regression test for a real bug: `is_volatile` originally fired
554        // symmetrically on `variance_ratio` far from 1.0 in EITHER
555        // direction (>=3x OR <=1/3x baseline variance). A recent window
556        // whose variance SHRANK relative to baseline means the kernel got
557        // MORE consistent -- the opposite of volatile -- and must fall
558        // through to the ordinary Stable/Degrading/Improving
559        // classification, never be reported as "Volatile".
560        let t = thresholds();
561        let baseline_samples = [
562            0.0008, 0.0012, 0.0008, 0.0012, 0.0008, 0.0012, 0.0008, 0.0012,
563        ];
564        let baseline = analysis::establish_baseline("k", &baseline_samples);
565        let outcome = analysis::BaselineTestOutcome {
566            relative_change: 0.0, // no mean shift
567            relative_change_ci: (0.0, 0.0),
568            t_statistic: 0.0,
569            p_value: 1.0, // not statistically significant
570            degrees_of_freedom: 10.0,
571            variance_ratio: 0.1, // recent variance is 1/10th of baseline's -- MORE consistent
572        };
573        let check = analysis::detect_regression("k", &baseline, outcome, &t);
574        assert!(
575            matches!(check.performance_trend, PerformanceTrend::Stable),
576            "a recent window that became MORE consistent (variance_ratio well below 1.0) with \
577             no significant mean shift must be Stable, not mislabeled Volatile, got {:?}",
578            check.performance_trend
579        );
580    }
581
582    #[test]
583    fn test_growing_variance_is_labeled_volatile() {
584        // The intended (one-sided) behavior of the same gate: a recent
585        // window that genuinely got MORE erratic (variance_ratio >= 3x
586        // baseline) must still be Volatile.
587        let t = thresholds();
588        let baseline_samples = [
589            0.0008, 0.0012, 0.0008, 0.0012, 0.0008, 0.0012, 0.0008, 0.0012,
590        ];
591        let baseline = analysis::establish_baseline("k", &baseline_samples);
592        let outcome = analysis::BaselineTestOutcome {
593            relative_change: 0.0,
594            relative_change_ci: (0.0, 0.0),
595            t_statistic: 0.0,
596            p_value: 1.0,
597            degrees_of_freedom: 10.0,
598            variance_ratio: 5.0,
599        };
600        let check = analysis::detect_regression("k", &baseline, outcome, &t);
601        assert!(
602            matches!(check.performance_trend, PerformanceTrend::Volatile),
603            "a recent variance >= 3x baseline must still be Volatile, got {:?}",
604            check.performance_trend
605        );
606    }
607
608    /// The published Welch statistics must depend only on the *shape* of the
609    /// measurements, never on the unit they happen to be expressed in.
610    ///
611    /// `PerformanceDistribution.{mean,std_dev}` used to be `Duration`, which
612    /// quantizes to whole nanoseconds. Feeding the same relative samples in at
613    /// second, millisecond and microsecond scale therefore produced three
614    /// different p-values, and at microsecond scale the standard deviation
615    /// round-tripped to `0 ns`, collapsing the p-value to exactly 0.0.
616    #[test]
617    fn test_welch_statistics_are_invariant_to_the_time_unit() {
618        // Deliberately not round numbers: a Duration round-trip has to lose
619        // something for the assertion to bite.
620        const BASELINE: [f64; 10] = [
621            1.031, 0.987, 1.004, 1.019, 0.973, 1.011, 0.996, 1.027, 0.981, 1.008,
622        ];
623        const RECENT: [f64; 8] = [1.137, 1.152, 1.129, 1.161, 1.143, 1.156, 1.134, 1.148];
624
625        let p_at_scale = |scale: f64| -> f64 {
626            let baseline_samples: Vec<f64> = BASELINE.iter().map(|v| v * scale).collect();
627            let recent_samples: Vec<f64> = RECENT.iter().map(|v| v * scale).collect();
628            let baseline = analysis::establish_baseline("k", &baseline_samples);
629            let outcome = analysis::compare_to_baseline(
630                baseline.performance_distribution.mean_secs,
631                baseline.performance_distribution.std_dev_secs,
632                baseline.performance_distribution.sample_count,
633                &recent_samples,
634            )
635            .expect("both windows have enough real samples");
636            outcome.p_value
637        };
638
639        let seconds = p_at_scale(1.0);
640        let millis = p_at_scale(1e-3);
641        let micros = p_at_scale(1e-6);
642
643        assert!(seconds > 0.0 && seconds < 1.0, "sanity: got {seconds}");
644        assert!(
645            (millis - seconds).abs() < 1e-9,
646            "millisecond scale must give the same p-value: {millis} vs {seconds}"
647        );
648        assert!(
649            (micros - seconds).abs() < 1e-9,
650            "microsecond scale must give the same p-value: {micros} vs {seconds}"
651        );
652        assert!(
653            micros > 0.0,
654            "a microsecond-scale std_dev must not collapse to zero"
655        );
656    }
657
658    /// A far-tail comparison must report the real (tiny) p-value rather than
659    /// underflowing to exactly 0.0, which `2 * (1 - cdf(|t|))` does.
660    #[test]
661    fn test_far_tail_p_value_does_not_underflow_to_zero() {
662        let baseline_samples: Vec<f64> =
663            (0..40).map(|i| 1.0 + if i % 2 == 0 { 0.001 } else { -0.001 }).collect();
664        let recent_samples: Vec<f64> =
665            (0..40).map(|i| 1.5 + if i % 2 == 0 { 0.001 } else { -0.001 }).collect();
666        let baseline = analysis::establish_baseline("k", &baseline_samples);
667        let outcome = analysis::compare_to_baseline(
668            baseline.performance_distribution.mean_secs,
669            baseline.performance_distribution.std_dev_secs,
670            baseline.performance_distribution.sample_count,
671            &recent_samples,
672        )
673        .expect("both windows have enough real samples");
674
675        assert!(
676            outcome.t_statistic.abs() > 100.0,
677            "sanity: got t={}",
678            outcome.t_statistic
679        );
680        assert!(
681            outcome.p_value > 0.0,
682            "an enormous but finite t-statistic has a tiny, non-zero p-value; got exactly 0.0"
683        );
684        assert!(
685            outcome.p_value < 1e-30,
686            "and it must still be tiny: {}",
687            outcome.p_value
688        );
689    }
690
691    /// `StatisticalTest.power` used to be `1 - p_value`. Observed power is a
692    /// different quantity, and the recorded test must carry the real one.
693    #[test]
694    fn test_recorded_statistical_test_reports_observed_power_not_one_minus_p() {
695        let mut detector = PerformanceRegressionDetector::new().expect("new ok");
696        for _ in 0..analysis::MIN_BASELINE_SAMPLES {
697            detector.check_regression("k", &profile(0.001)).expect("check ok");
698        }
699        // A clearly slower recent window, with a little jitter so the recent
700        // variance is real.
701        for i in 0..(analysis::MIN_COMPARISON_SAMPLES + 4) {
702            let jitter = if i % 2 == 0 { 1.0e-6 } else { -1.0e-6 };
703            detector.check_regression("k", &profile(0.0015 + jitter)).expect("check ok");
704        }
705
706        let test = detector
707            .statistical_analyzer
708            .statistical_tests
709            .last()
710            .expect("a comparison must have been recorded");
711        let power = test.observed_power.expect("a finite t-statistic yields a power");
712        assert!(
713            (0.0..=1.0).contains(&power),
714            "power must be a probability, got {power}"
715        );
716        assert!(
717            (test.significance_level - 0.05).abs() < 1e-9,
718            "alpha comes from the configured confidence level"
719        );
720
721        // A huge effect saturates both quantities at 1.0, so the
722        // power-is-not-1-minus-p separation is asserted on a closed-form case
723        // in `test_observed_power_is_not_one_minus_p`.
724    }
725
726    /// Closed form: at exactly the critical value the two-sided test rejects
727    /// half the time, so observed power is ~0.5 -- while `1 - p` is ~0.95.
728    #[test]
729    fn test_observed_power_is_not_one_minus_p() {
730        // t = t_crit(df=20, alpha=0.05) = 2.085963...
731        let t = 2.085_963_447_265_837;
732        let df = 20.0;
733        let alpha = 0.05;
734
735        let power = analysis::observed_power(t, df, alpha).expect("computable");
736        assert!(
737            (power - 0.5).abs() < 1e-3,
738            "at the critical value the noncentral-t power is ~0.5, got {power}"
739        );
740
741        let p =
742            trustformers_core::statistics::student_t_two_sided_p_value(t, df).expect("computable");
743        assert!(
744            (p - alpha).abs() < 1e-9,
745            "sanity: t is the alpha critical value, p={p}"
746        );
747        assert!(
748            ((1.0 - p) - power).abs() > 0.4,
749            "1 - p = {} is a different quantity from power = {power}",
750            1.0 - p
751        );
752
753        // A far larger effect really is near-certain to be detected.
754        let strong = analysis::observed_power(8.0, df, alpha).expect("computable");
755        assert!(strong > 0.99, "got {strong}");
756        // And degenerate inputs are refused rather than invented.
757        assert_eq!(analysis::observed_power(f64::NAN, df, alpha), None);
758        assert_eq!(analysis::observed_power(t, 0.0, alpha), None);
759        assert_eq!(analysis::observed_power(t, df, 0.0), None);
760    }
761
762    #[test]
763    fn test_statistical_analyzer_new() {
764        let analyzer = StatisticalAnalyzer::new().expect("new ok");
765        assert!(analyzer.sample_size_requirements.is_empty());
766        assert!(analyzer.statistical_tests.is_empty());
767    }
768
769    #[test]
770    fn test_statistical_analyzer_new_empty() {
771        let analyzer = StatisticalAnalyzer::new_empty();
772        assert!(analyzer.sample_size_requirements.is_empty());
773    }
774}