Skip to main content

anno_eval/eval/
multi_run.rs

1//! Multi-run evaluation for statistical robustness.
2//!
3//! Running evaluations multiple times with different random seeds or data
4//! shuffles provides confidence intervals and detects instability.
5//!
6//! # Research Context
7//!
8//! Reporting mean ± standard deviation across multiple runs is a best practice
9//! from ML research. The Sommerschield (2023) survey on ancient languages notes
10//! that single-run results can be misleading due to:
11//!
12//! - Random initialization effects
13//! - Data ordering sensitivity  
14//! - Stochastic dropout/sampling
15//!
16//! This module provides tools to run evaluations N times and aggregate results
17//! with proper statistical reporting.
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use anno_eval::eval::{MultiRunConfig, MultiRunEvaluator, MetricWithVariance};
23//!
24//! let config = MultiRunConfig::new()
25//!     .with_runs(5)
26//!     .with_shuffle(true);
27//!
28//! let evaluator = MultiRunEvaluator::new(config);
29//! let results = evaluator.evaluate(&model, &test_cases)?;
30//!
31//! println!("F1: {} (n={})", results.f1, results.f1.n);
32//! // F1: 85.2% ± 1.3% (n=5)
33//! ```
34
35use super::{evaluate_ner_model, GoldEntity, MetricWithVariance, NEREvaluationResults};
36use anno::{Error, Model, Result};
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39
40// =============================================================================
41// Configuration
42// =============================================================================
43
44/// Configuration for multi-run evaluation.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MultiRunConfig {
47    /// Number of evaluation runs (default: 5)
48    pub num_runs: usize,
49    /// Whether to shuffle data between runs (default: true)
50    pub shuffle: bool,
51    /// Random seed base (seeds will be base, base+1, base+2, ...)
52    pub seed_base: u64,
53    /// Whether to parallelize runs (requires rayon feature)
54    pub parallel: bool,
55    /// Minimum runs required for CI calculation (default: 3)
56    pub min_runs_for_ci: usize,
57}
58
59impl Default for MultiRunConfig {
60    fn default() -> Self {
61        Self {
62            num_runs: 5,
63            shuffle: true,
64            seed_base: 42,
65            parallel: false,
66            min_runs_for_ci: 3,
67        }
68    }
69}
70
71impl MultiRunConfig {
72    /// Create a new config with default settings.
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Set the number of runs.
78    pub fn with_runs(mut self, n: usize) -> Self {
79        self.num_runs = n.max(1);
80        self
81    }
82
83    /// Set whether to shuffle data between runs.
84    pub fn with_shuffle(mut self, shuffle: bool) -> Self {
85        self.shuffle = shuffle;
86        self
87    }
88
89    /// Set the random seed base.
90    pub fn with_seed_base(mut self, seed: u64) -> Self {
91        self.seed_base = seed;
92        self
93    }
94
95    /// Enable or disable parallel execution.
96    pub fn with_parallel(mut self, parallel: bool) -> Self {
97        self.parallel = parallel;
98        self
99    }
100}
101
102// =============================================================================
103// Results
104// =============================================================================
105
106/// Results from multi-run evaluation.
107///
108/// All metrics are reported with mean ± std and confidence intervals.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct MultiRunResults {
111    /// Precision with variance
112    pub precision: MetricWithVariance,
113    /// Recall with variance
114    pub recall: MetricWithVariance,
115    /// F1 with variance
116    pub f1: MetricWithVariance,
117    /// Macro F1 with variance (if available)
118    pub macro_f1: Option<MetricWithVariance>,
119    /// Per-type F1 with variance
120    pub per_type_f1: HashMap<String, MetricWithVariance>,
121    /// Tokens per second with variance
122    pub throughput: MetricWithVariance,
123    /// Individual run results (for detailed analysis)
124    pub individual_runs: Vec<NEREvaluationResults>,
125    /// Configuration used
126    pub config: MultiRunConfig,
127    /// Seeds used for each run
128    pub seeds: Vec<u64>,
129}
130
131impl MultiRunResults {
132    /// Format as a human-readable summary table.
133    pub fn format_summary(&self) -> String {
134        let mut s = String::new();
135
136        s.push_str(&format!("Multi-Run Evaluation (n={})\n", self.f1.n));
137        s.push_str(&format!("{:=<50}\n", ""));
138        s.push_str(&format!(
139            "{:<12} {:<20} {:<15}\n",
140            "Metric", "Mean ± CI95", "Range"
141        ));
142        s.push_str(&format!("{:-<50}\n", ""));
143
144        s.push_str(&format!(
145            "{:<12} {:<20} {:<15}\n",
146            "Precision",
147            self.precision.format_with_ci(),
148            self.precision.format_with_range()
149        ));
150        s.push_str(&format!(
151            "{:<12} {:<20} {:<15}\n",
152            "Recall",
153            self.recall.format_with_ci(),
154            self.recall.format_with_range()
155        ));
156        s.push_str(&format!(
157            "{:<12} {:<20} {:<15}\n",
158            "F1",
159            self.f1.format_with_ci(),
160            self.f1.format_with_range()
161        ));
162
163        if let Some(ref macro_f1) = self.macro_f1 {
164            s.push_str(&format!(
165                "{:<12} {:<20} {:<15}\n",
166                "Macro F1",
167                macro_f1.format_with_ci(),
168                macro_f1.format_with_range()
169            ));
170        }
171
172        s.push_str(&format!("{:-<50}\n", ""));
173
174        // Per-type breakdown
175        if !self.per_type_f1.is_empty() {
176            s.push_str("\nPer-Type F1:\n");
177            let mut types: Vec<_> = self.per_type_f1.keys().collect();
178            types.sort();
179            for entity_type in types {
180                if let Some(metric) = self.per_type_f1.get(entity_type) {
181                    s.push_str(&format!(
182                        "  {:<10} {}\n",
183                        entity_type,
184                        metric.format_with_ci()
185                    ));
186                }
187            }
188        }
189
190        // Stability analysis
191        let cv = self.f1.coefficient_of_variation();
192        s.push_str(&format!("\nStability: CV = {:.2}% ", cv * 100.0));
193        if cv < 0.02 {
194            s.push_str("(excellent)");
195        } else if cv < 0.05 {
196            s.push_str("(good)");
197        } else if cv < 0.10 {
198            s.push_str("(moderate)");
199        } else {
200            s.push_str("(high variance - investigate)");
201        }
202        s.push('\n');
203
204        s
205    }
206
207    /// Check if results are statistically stable.
208    ///
209    /// Returns true if coefficient of variation < threshold.
210    pub fn is_stable(&self, threshold: f64) -> bool {
211        self.f1.coefficient_of_variation() < threshold
212    }
213}
214
215// =============================================================================
216// Evaluator
217// =============================================================================
218
219/// Multi-run evaluator for NER models.
220///
221/// Runs evaluation multiple times and aggregates results with statistics.
222#[derive(Debug, Clone)]
223pub struct MultiRunEvaluator {
224    config: MultiRunConfig,
225}
226
227impl MultiRunEvaluator {
228    /// Create a new evaluator with the given config.
229    pub fn new(config: MultiRunConfig) -> Self {
230        Self { config }
231    }
232
233    /// Create with default config.
234    pub fn default_config() -> Self {
235        Self::new(MultiRunConfig::default())
236    }
237
238    /// Evaluate a model on test cases with multiple runs.
239    pub fn evaluate(
240        &self,
241        model: &dyn Model,
242        test_cases: &[(String, Vec<GoldEntity>)],
243    ) -> Result<MultiRunResults> {
244        if test_cases.is_empty() {
245            return Err(Error::InvalidInput("Empty test cases".to_string()));
246        }
247
248        let mut all_results = Vec::with_capacity(self.config.num_runs);
249        let mut seeds = Vec::with_capacity(self.config.num_runs);
250
251        for run in 0..self.config.num_runs {
252            let seed = self.config.seed_base + run as u64;
253            seeds.push(seed);
254
255            // Optionally shuffle data
256            let data = if self.config.shuffle {
257                shuffle_with_seed(test_cases, seed)
258            } else {
259                test_cases.to_vec()
260            };
261
262            // Run evaluation
263            let result = evaluate_ner_model(model, &data)?;
264            all_results.push(result);
265        }
266
267        // Aggregate metrics
268        let precision_samples: Vec<f64> = all_results.iter().map(|r| r.precision).collect();
269        let recall_samples: Vec<f64> = all_results.iter().map(|r| r.recall).collect();
270        let f1_samples: Vec<f64> = all_results.iter().map(|r| r.f1).collect();
271        let throughput_samples: Vec<f64> =
272            all_results.iter().map(|r| r.tokens_per_second).collect();
273
274        // Macro F1
275        let macro_f1_samples: Vec<f64> = all_results.iter().filter_map(|r| r.macro_f1).collect();
276        let macro_f1 = if macro_f1_samples.len() >= self.config.min_runs_for_ci {
277            Some(MetricWithVariance::from_samples(&macro_f1_samples))
278        } else {
279            None
280        };
281
282        // Per-type F1
283        let mut per_type_f1 = HashMap::new();
284        if let Some(first) = all_results.first() {
285            for entity_type in first.per_type.keys() {
286                let type_f1s: Vec<f64> = all_results
287                    .iter()
288                    .filter_map(|r| r.per_type.get(entity_type).map(|m| m.f1))
289                    .collect();
290                if type_f1s.len() >= self.config.min_runs_for_ci {
291                    per_type_f1.insert(
292                        entity_type.clone(),
293                        MetricWithVariance::from_samples(&type_f1s),
294                    );
295                }
296            }
297        }
298
299        Ok(MultiRunResults {
300            precision: MetricWithVariance::from_samples(&precision_samples),
301            recall: MetricWithVariance::from_samples(&recall_samples),
302            f1: MetricWithVariance::from_samples(&f1_samples),
303            macro_f1,
304            per_type_f1,
305            throughput: MetricWithVariance::from_samples(&throughput_samples),
306            individual_runs: all_results,
307            config: self.config.clone(),
308            seeds,
309        })
310    }
311
312    /// Quick evaluation with 3 runs (suitable for development).
313    pub fn quick_eval(
314        model: &dyn Model,
315        test_cases: &[(String, Vec<GoldEntity>)],
316    ) -> Result<MultiRunResults> {
317        let evaluator = Self::new(MultiRunConfig::new().with_runs(3));
318        evaluator.evaluate(model, test_cases)
319    }
320
321    /// Thorough evaluation with 10 runs (suitable for publication).
322    pub fn thorough_eval(
323        model: &dyn Model,
324        test_cases: &[(String, Vec<GoldEntity>)],
325    ) -> Result<MultiRunResults> {
326        let evaluator = Self::new(MultiRunConfig::new().with_runs(10));
327        evaluator.evaluate(model, test_cases)
328    }
329}
330
331// =============================================================================
332// Helpers
333// =============================================================================
334
335/// Shuffle data with a deterministic seed.
336fn shuffle_with_seed<T: Clone>(data: &[T], seed: u64) -> Vec<T> {
337    let mut indices: Vec<usize> = (0..data.len()).collect();
338
339    // Fisher-Yates shuffle with seeded PRNG
340    let mut rng_state = seed;
341    for i in (1..indices.len()).rev() {
342        // Simple LCG for deterministic shuffling
343        rng_state = rng_state
344            .wrapping_mul(6364136223846793005)
345            .wrapping_add(1442695040888963407);
346        let j = (rng_state % (i as u64 + 1)) as usize;
347        indices.swap(i, j);
348    }
349
350    indices.into_iter().map(|i| data[i].clone()).collect()
351}
352
353/// Compare two models across multiple runs with statistical significance.
354pub fn compare_models_multi_run(
355    model_a: (&str, &dyn Model),
356    model_b: (&str, &dyn Model),
357    test_cases: &[(String, Vec<GoldEntity>)],
358    config: MultiRunConfig,
359) -> Result<ModelComparison> {
360    let evaluator = MultiRunEvaluator::new(config);
361
362    let results_a = evaluator.evaluate(model_a.1, test_cases)?;
363    let results_b = evaluator.evaluate(model_b.1, test_cases)?;
364
365    // Paired t-test for significance
366    let (t_stat, p_value) = paired_t_test(
367        &results_a
368            .individual_runs
369            .iter()
370            .map(|r| r.f1)
371            .collect::<Vec<_>>(),
372        &results_b
373            .individual_runs
374            .iter()
375            .map(|r| r.f1)
376            .collect::<Vec<_>>(),
377    );
378
379    let difference = results_a.f1.mean - results_b.f1.mean;
380    let significant = p_value < 0.05;
381
382    Ok(ModelComparison {
383        model_a_name: model_a.0.to_string(),
384        model_b_name: model_b.0.to_string(),
385        model_a_f1: results_a.f1,
386        model_b_f1: results_b.f1,
387        f1_difference: difference,
388        t_statistic: t_stat,
389        p_value,
390        significant_at_05: significant,
391        winner: if significant {
392            if difference > 0.0 {
393                Some(model_a.0.to_string())
394            } else {
395                Some(model_b.0.to_string())
396            }
397        } else {
398            None
399        },
400    })
401}
402
403/// Result of comparing two models.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct ModelComparison {
406    /// Name of first model
407    pub model_a_name: String,
408    /// Name of second model
409    pub model_b_name: String,
410    /// F1 of first model with variance
411    pub model_a_f1: MetricWithVariance,
412    /// F1 of second model with variance
413    pub model_b_f1: MetricWithVariance,
414    /// Difference in mean F1 (A - B)
415    pub f1_difference: f64,
416    /// T-statistic from paired t-test
417    pub t_statistic: f64,
418    /// P-value
419    pub p_value: f64,
420    /// Whether difference is significant at p < 0.05
421    pub significant_at_05: bool,
422    /// Winner (if significant)
423    pub winner: Option<String>,
424}
425
426impl std::fmt::Display for ModelComparison {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        writeln!(
429            f,
430            "Model Comparison: {} vs {}",
431            self.model_a_name, self.model_b_name
432        )?;
433        writeln!(f, "{:-<50}", "")?;
434        writeln!(
435            f,
436            "{}: {}",
437            self.model_a_name,
438            self.model_a_f1.format_with_ci()
439        )?;
440        writeln!(
441            f,
442            "{}: {}",
443            self.model_b_name,
444            self.model_b_f1.format_with_ci()
445        )?;
446        writeln!(f, "Difference: {:+.2}%", self.f1_difference * 100.0)?;
447        writeln!(f, "p-value: {:.4}", self.p_value)?;
448        if self.significant_at_05 {
449            writeln!(
450                f,
451                "Result: {} significantly better (p < 0.05)",
452                self.winner.as_deref().unwrap_or("?")
453            )?;
454        } else {
455            writeln!(f, "Result: No significant difference")?;
456        }
457        Ok(())
458    }
459}
460
461/// Paired t-test for comparing two sets of measurements.
462///
463/// Returns (t-statistic, two-tailed p-value).
464fn paired_t_test(a: &[f64], b: &[f64]) -> (f64, f64) {
465    if a.len() != b.len() || a.is_empty() {
466        return (0.0, 1.0);
467    }
468
469    let n = a.len() as f64;
470    let diffs: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x - y).collect();
471
472    let mean_diff: f64 = diffs.iter().sum::<f64>() / n;
473    let var_diff: f64 = if a.len() > 1 {
474        diffs.iter().map(|d| (d - mean_diff).powi(2)).sum::<f64>() / (n - 1.0)
475    } else {
476        0.0
477    };
478
479    let std_err = (var_diff / n).sqrt();
480
481    let t_stat = if std_err > 1e-10 {
482        mean_diff / std_err
483    } else {
484        // If variance is zero, all differences are identical
485        // Large |mean_diff| = very significant, mean_diff == 0 = identical
486        if mean_diff.abs() > 1e-10 {
487            // Return large t-stat with same sign as mean_diff
488            mean_diff.signum() * 100.0
489        } else {
490            0.0
491        }
492    };
493
494    // Approximate two-tailed p-value using normal distribution
495    // For small samples this is an approximation (t-distribution would be more accurate)
496    // Normal CDF returns P(X < x), so P(|X| > |t|) = 2 * (1 - CDF(|t|))
497    let p_value = 2.0 * (1.0 - normal_cdf(t_stat.abs()));
498
499    (t_stat, p_value)
500}
501
502/// Approximate CDF of standard normal distribution.
503fn normal_cdf(x: f64) -> f64 {
504    // Abramowitz and Stegun approximation (7.1.26)
505    let a1 = 0.254829592;
506    let a2 = -0.284496736;
507    let a3 = 1.421413741;
508    let a4 = -1.453152027;
509    let a5 = 1.061405429;
510    let p = 0.3275911;
511
512    let sign = if x < 0.0 { -1.0 } else { 1.0 };
513    let x = x.abs() / std::f64::consts::SQRT_2;
514
515    let t = 1.0 / (1.0 + p * x);
516    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
517
518    0.5 * (1.0 + sign * y)
519}
520
521// =============================================================================
522// Tests
523// =============================================================================
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_shuffle_deterministic() {
531        let data: Vec<i32> = (0..10).collect();
532
533        let shuffled1 = shuffle_with_seed(&data, 42);
534        let shuffled2 = shuffle_with_seed(&data, 42);
535        let shuffled3 = shuffle_with_seed(&data, 43);
536
537        assert_eq!(
538            shuffled1, shuffled2,
539            "Same seed should produce same shuffle"
540        );
541        assert_ne!(
542            shuffled1, shuffled3,
543            "Different seeds should produce different shuffles"
544        );
545        assert_ne!(shuffled1, data, "Shuffle should change order");
546    }
547
548    #[test]
549    fn test_shuffle_preserves_elements() {
550        let data: Vec<i32> = (0..20).collect();
551        let shuffled = shuffle_with_seed(&data, 12345);
552
553        let mut sorted = shuffled.clone();
554        sorted.sort();
555        assert_eq!(sorted, data, "Shuffle should preserve all elements");
556    }
557
558    #[test]
559    fn test_metric_with_variance_from_samples() {
560        let samples = vec![0.80, 0.82, 0.85, 0.83, 0.80];
561        let metric = MetricWithVariance::from_samples(&samples);
562
563        assert!((metric.mean - 0.82).abs() < 0.01);
564        assert!(metric.std_dev > 0.0);
565        assert!(metric.ci_95 > 0.0);
566        assert_eq!(metric.n, 5);
567        assert!((metric.min - 0.80).abs() < 0.01);
568        assert!((metric.max - 0.85).abs() < 0.01);
569    }
570
571    #[test]
572    fn test_metric_with_variance_empty() {
573        let samples: Vec<f64> = vec![];
574        let metric = MetricWithVariance::from_samples(&samples);
575
576        assert_eq!(metric.n, 0);
577        assert!((metric.mean - 0.0).abs() < 0.001);
578    }
579
580    #[test]
581    fn test_metric_with_variance_single() {
582        let samples = vec![0.85];
583        let metric = MetricWithVariance::from_samples(&samples);
584
585        assert_eq!(metric.n, 1);
586        assert!((metric.mean - 0.85).abs() < 0.001);
587        assert!((metric.std_dev - 0.0).abs() < 0.001);
588    }
589
590    #[test]
591    fn test_coefficient_of_variation() {
592        let stable = MetricWithVariance::from_samples(&[0.85, 0.85, 0.85, 0.85, 0.85]);
593        let variable = MetricWithVariance::from_samples(&[0.60, 0.70, 0.80, 0.90, 1.00]);
594
595        assert!(stable.coefficient_of_variation() < 0.01);
596        assert!(variable.coefficient_of_variation() > 0.1);
597    }
598
599    #[test]
600    fn test_paired_t_test_identical() {
601        let a = vec![0.80, 0.82, 0.85, 0.83, 0.80];
602        let b = vec![0.80, 0.82, 0.85, 0.83, 0.80];
603
604        let (t_stat, p_value) = paired_t_test(&a, &b);
605
606        assert!((t_stat - 0.0).abs() < 0.001);
607        assert!((p_value - 1.0).abs() < 0.001);
608    }
609
610    #[test]
611    fn test_paired_t_test_different() {
612        // Need varying differences (not constant) for meaningful t-test
613        let a = vec![0.90, 0.92, 0.88, 0.91, 0.94];
614        let b = vec![0.80, 0.78, 0.79, 0.81, 0.82];
615
616        let (t_stat, p_value) = paired_t_test(&a, &b);
617
618        // A is consistently better, so t_stat should be positive
619        assert!(t_stat > 0.0, "t_stat should be positive: {}", t_stat);
620        // The difference is clear, so p should be small
621        assert!(
622            p_value < 0.05,
623            "p-value should indicate significance: {}",
624            p_value
625        );
626    }
627
628    #[test]
629    fn test_multi_run_config_builder() {
630        let config = MultiRunConfig::new()
631            .with_runs(10)
632            .with_shuffle(false)
633            .with_seed_base(123);
634
635        assert_eq!(config.num_runs, 10);
636        assert!(!config.shuffle);
637        assert_eq!(config.seed_base, 123);
638    }
639
640    #[test]
641    fn test_normal_cdf() {
642        // Standard normal: P(X < 0) = 0.5
643        assert!((normal_cdf(0.0) - 0.5).abs() < 0.01);
644
645        // P(X < 2) ≈ 0.977
646        assert!((normal_cdf(2.0) - 0.977).abs() < 0.01);
647
648        // P(X < -2) ≈ 0.023
649        assert!((normal_cdf(-2.0) - 0.023).abs() < 0.01);
650    }
651}
652
653// =============================================================================
654// Property Tests
655// =============================================================================
656
657#[cfg(test)]
658mod proptests {
659    use super::*;
660    use proptest::prelude::*;
661
662    // -------------------------------------------------------------------------
663    // Shuffle Properties
664    // -------------------------------------------------------------------------
665
666    proptest! {
667        /// Shuffle is deterministic given seed
668        #[test]
669        fn prop_shuffle_deterministic(seed in any::<u64>(), len in 0usize..100) {
670            let data: Vec<usize> = (0..len).collect();
671            let s1 = shuffle_with_seed(&data, seed);
672            let s2 = shuffle_with_seed(&data, seed);
673            prop_assert_eq!(s1, s2, "Same seed should produce same shuffle");
674        }
675
676        /// Shuffle preserves all elements
677        #[test]
678        fn prop_shuffle_preserves_elements(seed in any::<u64>(), len in 0usize..50) {
679            let data: Vec<usize> = (0..len).collect();
680            let mut shuffled = shuffle_with_seed(&data, seed);
681            shuffled.sort();
682            prop_assert_eq!(shuffled, data, "Shuffle should preserve all elements");
683        }
684
685        /// Different seeds produce different results (statistically)
686        #[test]
687        fn prop_different_seeds_differ(seed1 in any::<u64>(), seed2 in any::<u64>()) {
688            // Only test with reasonably sized data where collision is unlikely
689            let data: Vec<usize> = (0..20).collect();
690            let s1 = shuffle_with_seed(&data, seed1);
691            let s2 = shuffle_with_seed(&data, seed2);
692
693            // If seeds are different, shuffles should likely differ
694            // (but not guaranteed for small data - allow some collisions)
695            if seed1 != seed2 {
696                // Just ensure they're valid permutations
697                let mut sorted1 = s1.clone();
698                let mut sorted2 = s2.clone();
699                sorted1.sort();
700                sorted2.sort();
701                prop_assert_eq!(sorted1, data.clone());
702                prop_assert_eq!(sorted2, data);
703            }
704        }
705    }
706
707    // -------------------------------------------------------------------------
708    // MetricWithVariance Properties
709    // -------------------------------------------------------------------------
710
711    proptest! {
712        /// Mean is within [min, max]
713        #[test]
714        fn prop_mean_within_range(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
715            let metric = MetricWithVariance::from_samples(&samples);
716            prop_assert!(metric.mean >= metric.min - 1e-10);
717            prop_assert!(metric.mean <= metric.max + 1e-10);
718        }
719
720        /// Std dev is non-negative
721        #[test]
722        fn prop_std_dev_non_negative(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
723            let metric = MetricWithVariance::from_samples(&samples);
724            prop_assert!(metric.std_dev >= 0.0);
725        }
726
727        /// CI95 is non-negative
728        #[test]
729        fn prop_ci95_non_negative(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
730            let metric = MetricWithVariance::from_samples(&samples);
731            prop_assert!(metric.ci_95 >= 0.0);
732        }
733
734        /// n matches input length
735        #[test]
736        fn prop_n_matches_length(samples in prop::collection::vec(0.0f64..1.0, 0..20)) {
737            let metric = MetricWithVariance::from_samples(&samples);
738            prop_assert_eq!(metric.n, samples.len());
739        }
740
741        /// Coefficient of variation is non-negative
742        #[test]
743        fn prop_cv_non_negative(samples in prop::collection::vec(0.0f64..1.0, 1..20)) {
744            let metric = MetricWithVariance::from_samples(&samples);
745            let cv = metric.coefficient_of_variation();
746            prop_assert!(cv >= 0.0 || cv.is_nan(), "CV should be non-negative: {}", cv);
747        }
748
749        /// Identical samples have zero variance
750        #[test]
751        fn prop_identical_zero_variance(value in 0.0f64..1.0, n in 2usize..10) {
752            let samples: Vec<f64> = vec![value; n];
753            let metric = MetricWithVariance::from_samples(&samples);
754            prop_assert!((metric.std_dev - 0.0).abs() < 1e-10, "Identical samples should have 0 std dev");
755            prop_assert!((metric.mean - value).abs() < 1e-10);
756        }
757    }
758
759    // -------------------------------------------------------------------------
760    // Paired T-Test Properties
761    // -------------------------------------------------------------------------
762
763    proptest! {
764        /// T-test with identical samples gives t=0, p=1
765        #[test]
766        fn prop_ttest_identical_no_difference(samples in prop::collection::vec(0.0f64..1.0, 2..10)) {
767            let (t_stat, p_value) = paired_t_test(&samples, &samples);
768            prop_assert!((t_stat - 0.0).abs() < 1e-10, "t-stat should be 0 for identical samples");
769            prop_assert!((p_value - 1.0).abs() < 0.01, "p-value should be ~1 for identical samples");
770        }
771
772        /// T-test returns p in [0, 1]
773        #[test]
774        fn prop_ttest_p_value_bounds(
775            a in prop::collection::vec(0.0f64..1.0, 2..10),
776            b in prop::collection::vec(0.0f64..1.0, 2..10)
777        ) {
778            // Need same length
779            let min_len = a.len().min(b.len());
780            let a = &a[..min_len];
781            let b = &b[..min_len];
782
783            let (_, p_value) = paired_t_test(a, b);
784            prop_assert!((0.0..=1.0).contains(&p_value), "p-value {} out of [0,1]", p_value);
785        }
786    }
787
788    // -------------------------------------------------------------------------
789    // Normal CDF Properties
790    // -------------------------------------------------------------------------
791
792    proptest! {
793        /// CDF is monotonically increasing
794        #[test]
795        fn prop_cdf_monotonic(x1 in -5.0f64..5.0, x2 in -5.0f64..5.0) {
796            if x1 < x2 {
797                prop_assert!(normal_cdf(x1) <= normal_cdf(x2) + 1e-10);
798            }
799        }
800
801        /// CDF is in [0, 1]
802        #[test]
803        fn prop_cdf_bounds(x in -10.0f64..10.0) {
804            let cdf = normal_cdf(x);
805            prop_assert!((0.0..=1.0).contains(&cdf), "CDF {} out of bounds for x={}", cdf, x);
806        }
807
808        /// CDF(0) ≈ 0.5 for standard normal
809        #[test]
810        fn prop_cdf_symmetric_at_zero(_unused in Just(())) {
811            prop_assert!((normal_cdf(0.0) - 0.5).abs() < 0.01);
812        }
813    }
814
815    // -------------------------------------------------------------------------
816    // MultiRunConfig Properties
817    // -------------------------------------------------------------------------
818
819    proptest! {
820        /// Config builder sets values correctly
821        #[test]
822        fn prop_config_builder(runs in 1usize..100, seed in any::<u64>(), shuffle in any::<bool>()) {
823            let config = MultiRunConfig::new()
824                .with_runs(runs)
825                .with_seed_base(seed)
826                .with_shuffle(shuffle);
827
828            prop_assert_eq!(config.num_runs, runs);
829            prop_assert_eq!(config.seed_base, seed);
830            prop_assert_eq!(config.shuffle, shuffle);
831        }
832
833        /// Config enforces minimum 1 run
834        #[test]
835        fn prop_config_min_runs(_unused in Just(())) {
836            let config = MultiRunConfig::new().with_runs(0);
837            prop_assert!(config.num_runs >= 1);
838        }
839    }
840}