Skip to main content

fugue_evo/interactive/
uncertainty.rs

1//! Uncertainty quantification for fitness estimates
2//!
3//! This module provides types and utilities for representing fitness estimates
4//! with associated uncertainty (variance and confidence intervals).
5
6use serde::{Deserialize, Serialize};
7
8/// Full fitness estimate with uncertainty quantification
9///
10/// Represents a fitness value along with its statistical uncertainty,
11/// including variance and confidence intervals. This enables informed
12/// decision-making about which candidates need more evaluation.
13///
14/// # Example
15///
16/// ```rust
17/// use fugue_evo::interactive::uncertainty::FitnessEstimate;
18///
19/// let estimate = FitnessEstimate::new(7.5, 0.25, 10);
20/// println!("Fitness: {:.2} ± {:.2}", estimate.mean, estimate.std_error());
21/// println!("95% CI: [{:.2}, {:.2}]", estimate.ci_lower, estimate.ci_upper);
22/// ```
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24pub struct FitnessEstimate {
25    /// Point estimate (mean fitness)
26    pub mean: f64,
27    /// Variance of the estimate
28    pub variance: f64,
29    /// Lower bound of confidence interval (default 95%)
30    pub ci_lower: f64,
31    /// Upper bound of confidence interval
32    pub ci_upper: f64,
33    /// Number of observations contributing to this estimate
34    pub observation_count: usize,
35}
36
37impl FitnessEstimate {
38    /// Z-score for 95% confidence interval
39    const Z_95: f64 = 1.96;
40
41    /// Create a new fitness estimate from mean and variance
42    ///
43    /// Automatically computes 95% confidence intervals from the variance.
44    ///
45    /// # Arguments
46    ///
47    /// * `mean` - Point estimate of fitness
48    /// * `variance` - Variance of the estimate (not the population variance)
49    /// * `observation_count` - Number of observations used to compute the estimate
50    pub fn new(mean: f64, variance: f64, observation_count: usize) -> Self {
51        let std_err = variance.sqrt().max(0.0);
52        Self {
53            mean,
54            variance,
55            ci_lower: mean - Self::Z_95 * std_err,
56            ci_upper: mean + Self::Z_95 * std_err,
57            observation_count,
58        }
59    }
60
61    /// Create estimate with custom confidence level
62    ///
63    /// # Arguments
64    ///
65    /// * `mean` - Point estimate
66    /// * `variance` - Variance of the estimate
67    /// * `observation_count` - Number of observations
68    /// * `z_score` - Z-score for desired confidence level (e.g., 1.96 for 95%, 2.576 for 99%)
69    pub fn with_confidence(
70        mean: f64,
71        variance: f64,
72        observation_count: usize,
73        z_score: f64,
74    ) -> Self {
75        let std_err = variance.sqrt().max(0.0);
76        Self {
77            mean,
78            variance,
79            ci_lower: mean - z_score * std_err,
80            ci_upper: mean + z_score * std_err,
81            observation_count,
82        }
83    }
84
85    /// Create an uninformative estimate (infinite variance)
86    ///
87    /// Used for candidates with no evaluations.
88    pub fn uninformative(default_mean: f64) -> Self {
89        Self {
90            mean: default_mean,
91            variance: f64::INFINITY,
92            ci_lower: f64::NEG_INFINITY,
93            ci_upper: f64::INFINITY,
94            observation_count: 0,
95        }
96    }
97
98    /// Standard error (sqrt of variance)
99    pub fn std_error(&self) -> f64 {
100        self.variance.sqrt()
101    }
102
103    /// Width of the confidence interval
104    pub fn ci_width(&self) -> f64 {
105        self.ci_upper - self.ci_lower
106    }
107
108    /// Check if confidence interval contains a value
109    pub fn ci_contains(&self, value: f64) -> bool {
110        value >= self.ci_lower && value <= self.ci_upper
111    }
112
113    /// Check if this estimate overlaps with another's confidence interval
114    pub fn ci_overlaps(&self, other: &FitnessEstimate) -> bool {
115        self.ci_lower <= other.ci_upper && self.ci_upper >= other.ci_lower
116    }
117
118    /// Check if this estimate is significantly better than another
119    ///
120    /// Returns true if the lower bound of this estimate's CI is above
121    /// the upper bound of the other's CI.
122    pub fn significantly_better_than(&self, other: &FitnessEstimate) -> bool {
123        self.ci_lower > other.ci_upper
124    }
125
126    /// Check if this estimate has high uncertainty (needs more data)
127    ///
128    /// Returns true if variance is infinite or observation count is below threshold.
129    pub fn is_uncertain(&self, min_observations: usize) -> bool {
130        self.variance.is_infinite() || self.observation_count < min_observations
131    }
132
133    /// Coefficient of variation (relative uncertainty)
134    ///
135    /// Returns None if mean is zero to avoid division by zero.
136    pub fn coefficient_of_variation(&self) -> Option<f64> {
137        if self.mean.abs() < f64::EPSILON {
138            None
139        } else {
140            Some(self.std_error() / self.mean.abs())
141        }
142    }
143
144    /// Merge two independent estimates (weighted by inverse variance)
145    ///
146    /// Combines two estimates using inverse-variance weighting,
147    /// which is optimal for independent normal estimates.
148    pub fn merge(&self, other: &FitnessEstimate) -> FitnessEstimate {
149        // Handle infinite variance cases
150        if self.variance.is_infinite() {
151            return other.clone();
152        }
153        if other.variance.is_infinite() {
154            return self.clone();
155        }
156        if self.variance == 0.0 && other.variance == 0.0 {
157            // Both have zero variance - average
158            return FitnessEstimate::new(
159                (self.mean + other.mean) / 2.0,
160                0.0,
161                self.observation_count + other.observation_count,
162            );
163        }
164
165        // Inverse variance weighting
166        let w1 = 1.0 / self.variance;
167        let w2 = 1.0 / other.variance;
168        let w_total = w1 + w2;
169
170        let merged_mean = (w1 * self.mean + w2 * other.mean) / w_total;
171        let merged_variance = 1.0 / w_total;
172        let merged_count = self.observation_count + other.observation_count;
173
174        FitnessEstimate::new(merged_mean, merged_variance, merged_count)
175    }
176}
177
178impl Default for FitnessEstimate {
179    fn default() -> Self {
180        Self::uninformative(0.0)
181    }
182}
183
184/// Compute sample variance using Welford's online algorithm
185///
186/// This is numerically stable for computing variance incrementally.
187#[derive(Clone, Debug, Default, Serialize, Deserialize)]
188pub struct WelfordVariance {
189    count: usize,
190    mean: f64,
191    m2: f64, // Sum of squared differences from mean
192}
193
194impl WelfordVariance {
195    /// Create a new variance calculator
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Add a new observation
201    pub fn update(&mut self, value: f64) {
202        self.count += 1;
203        let delta = value - self.mean;
204        self.mean += delta / self.count as f64;
205        let delta2 = value - self.mean;
206        self.m2 += delta * delta2;
207    }
208
209    /// Get current count
210    pub fn count(&self) -> usize {
211        self.count
212    }
213
214    /// Get current mean
215    pub fn mean(&self) -> f64 {
216        self.mean
217    }
218
219    /// Get sample variance (unbiased, divided by n-1)
220    pub fn sample_variance(&self) -> f64 {
221        if self.count < 2 {
222            f64::INFINITY
223        } else {
224            self.m2 / (self.count - 1) as f64
225        }
226    }
227
228    /// Get population variance (divided by n)
229    pub fn population_variance(&self) -> f64 {
230        if self.count == 0 {
231            f64::INFINITY
232        } else {
233            self.m2 / self.count as f64
234        }
235    }
236
237    /// Get variance of the mean (standard error squared)
238    pub fn variance_of_mean(&self) -> f64 {
239        if self.count == 0 {
240            f64::INFINITY
241        } else {
242            self.sample_variance() / self.count as f64
243        }
244    }
245
246    /// Convert to FitnessEstimate
247    pub fn to_estimate(&self) -> FitnessEstimate {
248        FitnessEstimate::new(self.mean, self.variance_of_mean(), self.count)
249    }
250
251    /// Merge with another WelfordVariance (for parallel computation)
252    pub fn merge(&self, other: &WelfordVariance) -> WelfordVariance {
253        if self.count == 0 {
254            return other.clone();
255        }
256        if other.count == 0 {
257            return self.clone();
258        }
259
260        let count = self.count + other.count;
261        let delta = other.mean - self.mean;
262        let mean = self.mean + delta * other.count as f64 / count as f64;
263        let m2 = self.m2
264            + other.m2
265            + delta * delta * self.count as f64 * other.count as f64 / count as f64;
266
267        WelfordVariance { count, mean, m2 }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_fitness_estimate_creation() {
277        let est = FitnessEstimate::new(5.0, 0.25, 10);
278        assert_eq!(est.mean, 5.0);
279        assert_eq!(est.variance, 0.25);
280        assert_eq!(est.observation_count, 10);
281        assert!((est.std_error() - 0.5).abs() < 1e-9);
282    }
283
284    #[test]
285    fn test_confidence_interval() {
286        let est = FitnessEstimate::new(10.0, 1.0, 100);
287        // 95% CI with std_err = 1.0: [10 - 1.96, 10 + 1.96]
288        assert!((est.ci_lower - 8.04).abs() < 0.01);
289        assert!((est.ci_upper - 11.96).abs() < 0.01);
290        assert!((est.ci_width() - 3.92).abs() < 0.01);
291    }
292
293    #[test]
294    fn test_ci_contains() {
295        let est = FitnessEstimate::new(10.0, 1.0, 100);
296        assert!(est.ci_contains(10.0));
297        assert!(est.ci_contains(9.0));
298        assert!(est.ci_contains(11.0));
299        assert!(!est.ci_contains(5.0));
300        assert!(!est.ci_contains(15.0));
301    }
302
303    #[test]
304    fn test_ci_overlaps() {
305        let est1 = FitnessEstimate::new(10.0, 1.0, 100);
306        let est2 = FitnessEstimate::new(11.0, 1.0, 100);
307        let est3 = FitnessEstimate::new(20.0, 1.0, 100);
308
309        assert!(est1.ci_overlaps(&est2)); // Close estimates overlap
310        assert!(!est1.ci_overlaps(&est3)); // Far estimates don't overlap
311    }
312
313    #[test]
314    fn test_significantly_better_than() {
315        let good = FitnessEstimate::new(20.0, 0.1, 100);
316        let bad = FitnessEstimate::new(10.0, 0.1, 100);
317        let uncertain = FitnessEstimate::new(15.0, 100.0, 5);
318
319        assert!(good.significantly_better_than(&bad));
320        assert!(!bad.significantly_better_than(&good));
321        assert!(!good.significantly_better_than(&uncertain)); // Uncertain overlaps
322    }
323
324    #[test]
325    fn test_uninformative_estimate() {
326        let est = FitnessEstimate::uninformative(5.0);
327        assert_eq!(est.mean, 5.0);
328        assert!(est.variance.is_infinite());
329        assert!(est.is_uncertain(1));
330    }
331
332    #[test]
333    fn test_merge_estimates() {
334        let est1 = FitnessEstimate::new(10.0, 1.0, 10);
335        let est2 = FitnessEstimate::new(12.0, 1.0, 10);
336
337        let merged = est1.merge(&est2);
338        assert_eq!(merged.mean, 11.0); // Average when equal weights
339        assert!(merged.variance < est1.variance); // Variance decreases
340        assert_eq!(merged.observation_count, 20);
341    }
342
343    #[test]
344    fn test_merge_with_uninformative() {
345        let est = FitnessEstimate::new(10.0, 1.0, 10);
346        let uninf = FitnessEstimate::uninformative(5.0);
347
348        let merged = est.merge(&uninf);
349        assert_eq!(merged.mean, est.mean);
350        assert_eq!(merged.variance, est.variance);
351    }
352
353    #[test]
354    fn test_welford_variance() {
355        let mut welford = WelfordVariance::new();
356        let values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
357
358        for v in values {
359            welford.update(v);
360        }
361
362        assert_eq!(welford.count(), 8);
363        assert!((welford.mean() - 5.0).abs() < 1e-9);
364        // Sample variance should be 4.571... (32/7)
365        assert!((welford.sample_variance() - 32.0 / 7.0).abs() < 1e-9);
366    }
367
368    #[test]
369    fn test_welford_merge() {
370        let mut w1 = WelfordVariance::new();
371        let mut w2 = WelfordVariance::new();
372
373        for v in [1.0, 2.0, 3.0] {
374            w1.update(v);
375        }
376        for v in [4.0, 5.0, 6.0] {
377            w2.update(v);
378        }
379
380        let merged = w1.merge(&w2);
381        assert_eq!(merged.count(), 6);
382        assert!((merged.mean() - 3.5).abs() < 1e-9);
383    }
384
385    #[test]
386    fn test_welford_to_estimate() {
387        let mut welford = WelfordVariance::new();
388        for v in [10.0, 11.0, 9.0, 10.0, 10.0] {
389            welford.update(v);
390        }
391
392        let est = welford.to_estimate();
393        assert_eq!(est.mean, welford.mean());
394        assert_eq!(est.observation_count, 5);
395        assert_eq!(est.variance, welford.variance_of_mean());
396    }
397
398    #[test]
399    fn test_coefficient_of_variation() {
400        let est = FitnessEstimate::new(10.0, 1.0, 100);
401        let cv = est.coefficient_of_variation().unwrap();
402        assert!((cv - 0.1).abs() < 1e-9); // std_err / mean = 1.0 / 10.0
403
404        let zero_mean = FitnessEstimate::new(0.0, 1.0, 100);
405        assert!(zero_mean.coefficient_of_variation().is_none());
406    }
407}