Skip to main content

ftui_runtime/
alpha_investing.rs

1//! Alpha-Investing: sequential FDR control for multiple simultaneous alerts.
2//!
3//! When many monitors fire simultaneously (budget alerts, degradation triggers,
4//! capability detection decisions), testing each at a fixed alpha level inflates
5//! the family-wise false discovery rate. Alpha-Investing controls FDR by
6//! treating significance level as a spendable resource.
7//!
8//! # Mathematical Model
9//!
10//! The investor maintains a **wealth** W that starts at an initial budget W₀:
11//!
12//! ```text
13//! W₀ = initial_wealth   (e.g. 0.5)
14//! ```
15//!
16//! For each new hypothesis H_i:
17//! 1. **Invest**: spend α_i ≤ W from the wealth (the test level for H_i)
18//! 2. **Test**: evaluate H_i at level α_i
19//! 3. **Update wealth**:
20//!    - If H_i **rejected** (discovery): W += reward  (typically ψ * α_i)
21//!    - If H_i **not rejected**: W unchanged (already spent α_i)
22//!
23//! The investment can never exceed the current wealth, so the procedure
24//! self-limits when too many tests fail to reject.
25//!
26//! # FDR Guarantee
27//!
28//! Under independence or positive dependence of the test statistics,
29//! Alpha-Investing controls the modified FDR (mFDR) at level ≤ W₀:
30//!
31//! ```text
32//! mFDR = E[V] / E[R] ≤ W₀
33//! ```
34//!
35//! where V = false discoveries, R = total discoveries.
36//!
37//! # Integration
38//!
39//! Pair with `conformal_alert` for individual alert calibration:
40//!
41//! ```text
42//! conformal_alert → p-value → alpha_investing → gated decision
43//! ```
44//!
45//! Each alert produces a p-value; the investor decides whether to "spend"
46//! enough alpha to declare the alert significant.
47//!
48//! # Failure Modes
49//!
50//! | Condition | Behavior | Rationale |
51//! |-----------|----------|-----------|
52//! | Wealth exhausted (W ≈ 0) | All tests skipped | FDR budget spent |
53//! | Negative p-value | Clamped to 0.0 | Invalid input guard |
54//! | p-value > 1.0 | Clamped to 1.0 | Invalid input guard |
55//! | Zero investment | Test skipped | No alpha allocated |
56//!
57//! # Reference
58//!
59//! Foster & Stine (2008), "α-investing: a procedure for sequential control
60//! of expected false discoveries", JRSS-B 70(2):429-444.
61
62/// Configuration for the Alpha-Investing procedure.
63#[derive(Debug, Clone)]
64pub struct AlphaInvestingConfig {
65    /// Initial wealth (significance budget). Controls the mFDR bound.
66    /// Typical values: 0.05 to 0.5.
67    pub initial_wealth: f64,
68    /// Fraction of alpha returned on discovery. Must be in (0, 1].
69    /// Higher values make the procedure more liberal after discoveries.
70    /// Foster & Stine recommend ψ = 0.5.
71    pub reward_fraction: f64,
72    /// Default fraction of current wealth to invest per test.
73    /// Each test spends `investment_fraction * current_wealth`.
74    /// Typical: 0.05 to 0.5.
75    pub investment_fraction: f64,
76    /// Minimum wealth below which all tests are skipped.
77    /// Prevents degenerate behavior near zero.
78    pub min_wealth: f64,
79}
80
81impl Default for AlphaInvestingConfig {
82    fn default() -> Self {
83        Self {
84            initial_wealth: 0.5,
85            reward_fraction: 0.5,
86            investment_fraction: 0.1,
87            min_wealth: 1e-10,
88        }
89    }
90}
91
92/// Outcome of testing a single hypothesis.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum TestOutcome {
95    /// Hypothesis rejected (discovery). The alert is significant.
96    Rejected,
97    /// Hypothesis not rejected. The alert is not significant at invested level.
98    NotRejected,
99    /// Test skipped because wealth was insufficient.
100    Skipped,
101}
102
103/// Record of a single hypothesis test within the Alpha-Investing sequence.
104#[derive(Debug, Clone)]
105pub struct TestRecord {
106    /// Hypothesis index (0-based).
107    pub index: usize,
108    /// The p-value for this hypothesis.
109    pub p_value: f64,
110    /// Alpha invested in this test.
111    pub alpha_invested: f64,
112    /// Outcome of the test.
113    pub outcome: TestOutcome,
114    /// Wealth after this test.
115    pub wealth_after: f64,
116}
117
118/// Alpha-Investing controller for sequential FDR control.
119///
120/// Maintains a wealth process that gates alert significance decisions.
121/// Each call to [`test`](AlphaInvestor::test) either rejects (discovery)
122/// or fails to reject, updating the wealth accordingly.
123#[derive(Debug, Clone)]
124pub struct AlphaInvestor {
125    config: AlphaInvestingConfig,
126    /// Current wealth (remaining significance budget).
127    wealth: f64,
128    /// Number of hypotheses tested so far.
129    tests_run: usize,
130    /// Number of discoveries (rejections).
131    discoveries: usize,
132    /// Full test history (bounded by practical use; callers can drain).
133    history: Vec<TestRecord>,
134}
135
136impl AlphaInvestor {
137    /// Create a new investor with the given configuration.
138    pub fn new(config: AlphaInvestingConfig) -> Self {
139        let wealth = config.initial_wealth;
140        Self {
141            config,
142            wealth,
143            tests_run: 0,
144            discoveries: 0,
145            history: Vec::new(),
146        }
147    }
148
149    /// Create an investor with default configuration.
150    pub fn with_defaults() -> Self {
151        Self::new(AlphaInvestingConfig::default())
152    }
153
154    /// Current wealth (remaining significance budget).
155    pub fn wealth(&self) -> f64 {
156        self.wealth
157    }
158
159    /// Number of hypotheses tested.
160    pub fn tests_run(&self) -> usize {
161        self.tests_run
162    }
163
164    /// Number of discoveries (rejected hypotheses).
165    pub fn discoveries(&self) -> usize {
166        self.discoveries
167    }
168
169    /// Empirical false discovery proportion: discoveries / tests_run.
170    /// Returns 0.0 if no tests have been run.
171    pub fn discovery_rate(&self) -> f64 {
172        if self.tests_run == 0 {
173            0.0
174        } else {
175            self.discoveries as f64 / self.tests_run as f64
176        }
177    }
178
179    /// Test a hypothesis with the given p-value.
180    ///
181    /// The procedure invests `investment_fraction * wealth` as the alpha
182    /// level for this test. If p_value ≤ alpha, the hypothesis is rejected
183    /// (discovery) and wealth is replenished by `reward_fraction * alpha`.
184    ///
185    /// Returns the outcome and updates internal state.
186    pub fn test(&mut self, p_value: f64) -> TestOutcome {
187        self.test_with_investment(p_value, None)
188    }
189
190    /// Test with a custom investment amount (overrides `investment_fraction`).
191    ///
192    /// `custom_alpha` is clamped to `[0, current_wealth]`.
193    pub fn test_with_investment(&mut self, p_value: f64, custom_alpha: Option<f64>) -> TestOutcome {
194        let p = p_value.clamp(0.0, 1.0);
195
196        // Check if we have enough wealth to test.
197        if self.wealth < self.config.min_wealth {
198            let record = TestRecord {
199                index: self.tests_run,
200                p_value: p,
201                alpha_invested: 0.0,
202                outcome: TestOutcome::Skipped,
203                wealth_after: self.wealth,
204            };
205            self.history.push(record);
206            self.tests_run += 1;
207            return TestOutcome::Skipped;
208        }
209
210        // Determine investment.
211        let alpha = match custom_alpha {
212            Some(a) => a.clamp(0.0, self.wealth),
213            None => (self.config.investment_fraction * self.wealth).min(self.wealth),
214        };
215
216        if alpha <= 0.0 {
217            let record = TestRecord {
218                index: self.tests_run,
219                p_value: p,
220                alpha_invested: 0.0,
221                outcome: TestOutcome::Skipped,
222                wealth_after: self.wealth,
223            };
224            self.history.push(record);
225            self.tests_run += 1;
226            return TestOutcome::Skipped;
227        }
228
229        // Spend alpha.
230        self.wealth -= alpha;
231
232        // Test: reject if p ≤ alpha.
233        let outcome = if p <= alpha {
234            // Discovery — replenish wealth.
235            let reward = self.config.reward_fraction * alpha;
236            self.wealth += reward;
237            self.discoveries += 1;
238            TestOutcome::Rejected
239        } else {
240            TestOutcome::NotRejected
241        };
242
243        let record = TestRecord {
244            index: self.tests_run,
245            p_value: p,
246            alpha_invested: alpha,
247            outcome,
248            wealth_after: self.wealth,
249        };
250        self.history.push(record);
251        self.tests_run += 1;
252
253        outcome
254    }
255
256    /// Batch-test multiple hypotheses, returning outcomes for each.
257    ///
258    /// P-values are tested in the order given. The wealth evolves
259    /// sequentially, so ordering matters.
260    pub fn test_batch(&mut self, p_values: &[f64]) -> Vec<TestOutcome> {
261        p_values.iter().map(|&p| self.test(p)).collect()
262    }
263
264    /// Reset the investor to its initial state.
265    pub fn reset(&mut self) {
266        self.wealth = self.config.initial_wealth;
267        self.tests_run = 0;
268        self.discoveries = 0;
269        self.history.clear();
270    }
271
272    /// Access the test history.
273    pub fn history(&self) -> &[TestRecord] {
274        &self.history
275    }
276
277    /// Drain the test history (returns ownership, clears internal log).
278    pub fn drain_history(&mut self) -> Vec<TestRecord> {
279        std::mem::take(&mut self.history)
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Bonferroni fallback (simple but conservative)
285// ---------------------------------------------------------------------------
286
287/// Simple Bonferroni correction: test each hypothesis at α/m.
288///
289/// Returns a vector of booleans (true = rejected).
290/// This is the conservative fallback mentioned in the bead spec.
291pub fn bonferroni_test(p_values: &[f64], alpha: f64) -> Vec<bool> {
292    if p_values.is_empty() {
293        return Vec::new();
294    }
295    let threshold = alpha / p_values.len() as f64;
296    p_values.iter().map(|&p| p <= threshold).collect()
297}
298
299/// Benjamini-Hochberg step-up procedure for FDR control.
300///
301/// Returns indices of rejected hypotheses (sorted ascending).
302/// Controls FDR at level α under independence.
303pub fn benjamini_hochberg(p_values: &[f64], alpha: f64) -> Vec<usize> {
304    if p_values.is_empty() {
305        return Vec::new();
306    }
307    let m = p_values.len();
308    // Sort p-values with original indices.
309    let mut indexed: Vec<(usize, f64)> = p_values
310        .iter()
311        .enumerate()
312        .map(|(i, &p)| (i, p.clamp(0.0, 1.0)))
313        .collect();
314    indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
315
316    // Find largest k where p_(k) ≤ k/m * α.
317    let mut max_k = 0;
318    for (rank, &(_, p)) in indexed.iter().enumerate() {
319        let threshold = (rank + 1) as f64 / m as f64 * alpha;
320        if p <= threshold {
321            max_k = rank + 1;
322        }
323    }
324
325    // Reject the first max_k sorted hypotheses.
326    indexed[..max_k].iter().map(|&(i, _)| i).collect()
327}
328
329// ---------------------------------------------------------------------------
330// Tests
331// ---------------------------------------------------------------------------
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn default_config() {
339        let cfg = AlphaInvestingConfig::default();
340        assert_eq!(cfg.initial_wealth, 0.5);
341        assert_eq!(cfg.reward_fraction, 0.5);
342        assert_eq!(cfg.investment_fraction, 0.1);
343    }
344
345    #[test]
346    fn investor_initial_state() {
347        let inv = AlphaInvestor::with_defaults();
348        assert_eq!(inv.wealth(), 0.5);
349        assert_eq!(inv.tests_run(), 0);
350        assert_eq!(inv.discoveries(), 0);
351        assert_eq!(inv.discovery_rate(), 0.0);
352    }
353
354    #[test]
355    fn single_rejection() {
356        let mut inv = AlphaInvestor::with_defaults();
357        // Invest 0.1 * 0.5 = 0.05; p=0.01 < 0.05 → reject
358        let outcome = inv.test(0.01);
359        assert_eq!(outcome, TestOutcome::Rejected);
360        assert_eq!(inv.discoveries(), 1);
361        // Wealth: 0.5 - 0.05 + 0.5*0.05 = 0.475
362        assert!((inv.wealth() - 0.475).abs() < 1e-10);
363    }
364
365    #[test]
366    fn single_non_rejection() {
367        let mut inv = AlphaInvestor::with_defaults();
368        // Invest 0.05; p=0.9 > 0.05 → not rejected
369        let outcome = inv.test(0.9);
370        assert_eq!(outcome, TestOutcome::NotRejected);
371        assert_eq!(inv.discoveries(), 0);
372        // Wealth: 0.5 - 0.05 = 0.45
373        assert!((inv.wealth() - 0.45).abs() < 1e-10);
374    }
375
376    #[test]
377    fn wealth_exhaustion() {
378        let cfg = AlphaInvestingConfig {
379            initial_wealth: 0.01,
380            investment_fraction: 1.0, // spend everything each time
381            min_wealth: 0.005,
382            ..Default::default()
383        };
384        let mut inv = AlphaInvestor::new(cfg);
385        // First test: invest 0.01, p=0.5 → not rejected → wealth=0
386        let o1 = inv.test(0.5);
387        assert_eq!(o1, TestOutcome::NotRejected);
388        // Second test: wealth < min_wealth → skipped
389        let o2 = inv.test(0.001);
390        assert_eq!(o2, TestOutcome::Skipped);
391    }
392
393    #[test]
394    fn batch_test() {
395        let mut inv = AlphaInvestor::with_defaults();
396        let outcomes = inv.test_batch(&[0.001, 0.001, 0.9, 0.9, 0.001]);
397        assert_eq!(outcomes.len(), 5);
398        // First two very small p-values should be rejected
399        assert_eq!(outcomes[0], TestOutcome::Rejected);
400        assert_eq!(outcomes[1], TestOutcome::Rejected);
401        // Large p-values should not be rejected
402        assert_eq!(outcomes[2], TestOutcome::NotRejected);
403        assert_eq!(outcomes[3], TestOutcome::NotRejected);
404    }
405
406    #[test]
407    fn custom_investment() {
408        let mut inv = AlphaInvestor::with_defaults();
409        // Invest exactly 0.2 (custom)
410        let outcome = inv.test_with_investment(0.1, Some(0.2));
411        assert_eq!(outcome, TestOutcome::Rejected);
412        // Wealth: 0.5 - 0.2 + 0.5*0.2 = 0.4
413        assert!((inv.wealth() - 0.4).abs() < 1e-10);
414    }
415
416    #[test]
417    fn custom_investment_clamped_to_wealth() {
418        let cfg = AlphaInvestingConfig {
419            initial_wealth: 0.1,
420            ..Default::default()
421        };
422        let mut inv = AlphaInvestor::new(cfg);
423        // Try to invest 1.0 but only have 0.1
424        let outcome = inv.test_with_investment(0.01, Some(1.0));
425        assert_eq!(outcome, TestOutcome::Rejected);
426        // Clamped to 0.1, so wealth: 0.1 - 0.1 + 0.5*0.1 = 0.05
427        assert!((inv.wealth() - 0.05).abs() < 1e-10);
428    }
429
430    #[test]
431    fn p_value_clamping() {
432        let mut inv = AlphaInvestor::with_defaults();
433        // Negative p-value clamped to 0
434        let o1 = inv.test(-0.5);
435        assert_eq!(o1, TestOutcome::Rejected);
436        // p > 1 clamped to 1
437        let o2 = inv.test(2.0);
438        assert_eq!(o2, TestOutcome::NotRejected);
439    }
440
441    #[test]
442    fn reset() {
443        let mut inv = AlphaInvestor::with_defaults();
444        inv.test(0.001);
445        inv.test(0.9);
446        assert!(inv.tests_run() > 0);
447        inv.reset();
448        assert_eq!(inv.tests_run(), 0);
449        assert_eq!(inv.discoveries(), 0);
450        assert_eq!(inv.wealth(), 0.5);
451        assert!(inv.history().is_empty());
452    }
453
454    #[test]
455    fn history_tracking() {
456        let mut inv = AlphaInvestor::with_defaults();
457        inv.test(0.01);
458        inv.test(0.9);
459        assert_eq!(inv.history().len(), 2);
460        let h = inv.drain_history();
461        assert_eq!(h.len(), 2);
462        assert!(inv.history().is_empty());
463    }
464
465    #[test]
466    fn bonferroni_basic() {
467        let p_values = [0.01, 0.03, 0.04, 0.05, 0.10];
468        let results = bonferroni_test(&p_values, 0.05);
469        // threshold = 0.05/5 = 0.01 → only first rejected
470        assert_eq!(results, vec![true, false, false, false, false]);
471    }
472
473    #[test]
474    fn bonferroni_empty() {
475        assert!(bonferroni_test(&[], 0.05).is_empty());
476    }
477
478    #[test]
479    fn benjamini_hochberg_basic() {
480        // Classic BH example
481        let p_values = [0.001, 0.008, 0.039, 0.041, 0.23, 0.35, 0.78, 0.90];
482        let rejected = benjamini_hochberg(&p_values, 0.05);
483        // BH at 0.05 with 8 tests:
484        // rank 1: 0.001 ≤ 1/8*0.05=0.00625 ✓
485        // rank 2: 0.008 ≤ 2/8*0.05=0.0125  ✓
486        // rank 3: 0.039 ≤ 3/8*0.05=0.01875 ✗
487        // rank 4: 0.041 ≤ 4/8*0.05=0.025   ✗
488        // max_k=2, reject first 2 sorted
489        assert_eq!(rejected.len(), 2);
490        assert!(rejected.contains(&0));
491        assert!(rejected.contains(&1));
492    }
493
494    #[test]
495    fn benjamini_hochberg_all_significant() {
496        let p_values = [0.001, 0.002, 0.003];
497        let rejected = benjamini_hochberg(&p_values, 0.05);
498        assert_eq!(rejected.len(), 3);
499    }
500
501    #[test]
502    fn benjamini_hochberg_none_significant() {
503        let p_values = [0.5, 0.6, 0.7];
504        let rejected = benjamini_hochberg(&p_values, 0.05);
505        assert!(rejected.is_empty());
506    }
507
508    #[test]
509    fn benjamini_hochberg_empty() {
510        assert!(benjamini_hochberg(&[], 0.05).is_empty());
511    }
512
513    #[test]
514    fn fdr_control_simulation() {
515        // Simulate: 100 hypotheses, 90 null (p ~ Uniform[0,1]), 10 real (p ~ 0.001)
516        // Verify that discovery rate is reasonable
517        let mut inv = AlphaInvestor::new(AlphaInvestingConfig {
518            initial_wealth: 0.5,
519            reward_fraction: 0.5,
520            investment_fraction: 0.1,
521            min_wealth: 1e-12,
522        });
523
524        // 10 real signals
525        let mut p_values = vec![0.001; 10];
526        // 90 null hypotheses with uniform-ish p-values
527        for i in 0..90 {
528            p_values.push(0.1 + (i as f64 * 0.01));
529        }
530
531        let outcomes = inv.test_batch(&p_values);
532        let rejections: usize = outcomes
533            .iter()
534            .filter(|&&o| o == TestOutcome::Rejected)
535            .count();
536
537        // Should reject at least some of the real signals
538        assert!(rejections >= 1, "Should reject at least 1 real signal");
539        // Should not reject most of the null hypotheses
540        assert!(
541            rejections <= 20,
542            "Should not reject too many (got {})",
543            rejections
544        );
545    }
546
547    #[test]
548    fn wealth_monotone_on_null() {
549        // Under pure null (all p > alpha), wealth strictly decreases
550        let mut inv = AlphaInvestor::with_defaults();
551        let mut prev_wealth = inv.wealth();
552        for _ in 0..20 {
553            inv.test(0.9);
554            assert!(
555                inv.wealth() <= prev_wealth,
556                "Wealth should not increase on non-rejection"
557            );
558            prev_wealth = inv.wealth();
559        }
560    }
561}