Skip to main content

blr_active/active_learning/
precision.rs

1//! Algorithm 3: Precision Assessment
2//!
3//! Evaluates whether a fitted BLR+ARD model meets the user's precision goal,
4//! using **percentile-based** semantics (per CLARIFY-1, option C):
5//!
6//!   **Goal met** when: 95th-percentile posterior std ≤ target_std
7//!
8//! Rationale: 95th percentile is interpretable ("95% of measurements meet
9//! spec") while allowing a small tail of higher-uncertainty points (e.g., at
10//! input-space boundaries before dense sampling).
11//!
12//! Status thresholds:
13//!   - `MetGoal`:  percentile_95_std ≤ target_std
14//!   - `Near`:     percentile_95_std ≤ 1.1 × target_std (within 10% of goal)
15//!   - `Unmet`:    percentile_95_std > 1.1 × target_std
16//!   - `NoiseFloorHit`: set externally by Algorithm 4 integration
17
18/// Multi-level precision status.
19#[derive(Debug, Clone, PartialEq)]
20pub enum PrecisionStatus {
21    /// 95th-percentile posterior std is within the user's tolerance.
22    MetGoal,
23    /// Within 10% of the tolerance — almost there.
24    Near,
25    /// Not yet within tolerance; more samples are needed.
26    Unmet,
27    /// Posterior variance has plateaued; hardware noise is the bottleneck.
28    /// This is set externally when Algorithm 4 reports `likely_at_floor=true`.
29    NoiseFloorHit,
30}
31
32/// Summary of the current predictive precision.
33#[derive(Debug, Clone)]
34pub struct PrecisionAssessment {
35    /// Maximum posterior std over the test/grid points.
36    pub max_std: f64,
37    /// Mean posterior std over the test/grid points.
38    pub mean_std: f64,
39    /// 95th-percentile posterior std (primary decision metric, per CLARIFY-1).
40    pub percentile_95_std: f64,
41    /// User-provided tolerance (the precision goal).
42    pub target_std: f64,
43    /// Relative gap: (percentile_95_std − target_std) / target_std.
44    /// Negative when goal is met; positive when unmet.
45    pub gap: f64,
46    /// Status label (MetGoal / Near / Unmet / NoiseFloorHit).
47    pub status: PrecisionStatus,
48}
49
50/// Compute the p-th percentile (0.0–1.0) of a slice.
51///
52/// Uses nearest-rank method. Returns 0.0 on empty input.
53pub fn percentile(values: &[f64], p: f64) -> f64 {
54    if values.is_empty() {
55        return 0.0;
56    }
57    let mut sorted = values.to_vec();
58    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
59    let idx = ((p * (sorted.len() - 1) as f64).round() as usize).min(sorted.len() - 1);
60    sorted[idx]
61}
62
63/// Assess how close the current model is to the precision goal.
64///
65/// # Arguments
66/// - `posterior_std`: posterior std at each evaluation point (e.g., a uniform grid)
67/// - `target_std`: user precision requirement (e.g., 0.01 V)
68///
69/// # Edge cases
70/// - Empty `posterior_std` → returns `Unmet` with zeros everywhere.
71/// - `target_std = 0` → gap is always `f64::INFINITY` (never MetGoal).
72pub fn assess_precision(posterior_std: &[f64], target_std: f64) -> PrecisionAssessment {
73    if posterior_std.is_empty() {
74        return PrecisionAssessment {
75            max_std: 0.0,
76            mean_std: 0.0,
77            percentile_95_std: 0.0,
78            target_std,
79            gap: if target_std > 0.0 {
80                -1.0
81            } else {
82                f64::INFINITY
83            },
84            status: PrecisionStatus::Unmet,
85        };
86    }
87
88    let max_std = posterior_std
89        .iter()
90        .cloned()
91        .fold(f64::NEG_INFINITY, f64::max);
92    let mean_std = posterior_std.iter().sum::<f64>() / posterior_std.len() as f64;
93    let percentile_95_std = percentile(posterior_std, 0.95);
94
95    let gap = if target_std > 0.0 {
96        (percentile_95_std - target_std) / target_std
97    } else {
98        f64::INFINITY
99    };
100
101    // Primary decision metric: percentile_95_std (per CLARIFY-1)
102    let status = if target_std > 0.0 && percentile_95_std <= target_std {
103        PrecisionStatus::MetGoal
104    } else if target_std > 0.0 && percentile_95_std <= 1.1 * target_std {
105        PrecisionStatus::Near
106    } else {
107        PrecisionStatus::Unmet
108    };
109
110    PrecisionAssessment {
111        max_std,
112        mean_std,
113        percentile_95_std,
114        target_std,
115        gap,
116        status,
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    /// MetGoal when p95 <= target
125    #[test]
126    fn test_precision_goal_met() {
127        // All stds at 0.005, target = 0.01 → p95 = 0.005 ≤ 0.01
128        let stds = vec![0.005; 100];
129        let a = assess_precision(&stds, 0.01);
130        assert_eq!(a.status, PrecisionStatus::MetGoal);
131        assert!(a.gap < 0.0, "gap should be negative when goal met");
132    }
133
134    /// Near when p95 is within 10% above target
135    #[test]
136    fn test_precision_near() {
137        // p95 = 0.0105, target = 0.01 → 5% above → Near
138        let mut stds = vec![0.01; 100];
139        stds[99] = 0.012; // 99th percentile at 0.012 but 95th stays at 0.01
140                          // Construct: 95 values at 0.01, 5 values at 0.012
141        let stds: Vec<f64> = (0..95).map(|_| 0.01).chain((0..5).map(|_| 0.012)).collect();
142        let _a = assess_precision(&stds, 0.01);
143        // p95 of this set: index = round(0.95 * 99) = 94 → 0.01, which is MetGoal
144        // Let's pick a case that is clearly Near
145        let stds2: Vec<f64> = (0..80)
146            .map(|_| 0.01)
147            .chain((0..20).map(|_| 0.0108))
148            .collect();
149        let a2 = assess_precision(&stds2, 0.01);
150        // p95: index 94 → 0.0108 ≤ 1.1 * 0.01 = 0.011 → Near
151        assert!(
152            a2.status == PrecisionStatus::Near || a2.status == PrecisionStatus::MetGoal,
153            "expected Near or MetGoal, got {:?}",
154            a2.status
155        );
156    }
157
158    /// Unmet when p95 is significantly above target
159    #[test]
160    fn test_precision_unmet() {
161        let stds = vec![0.05; 100]; // way above target 0.01
162        let a = assess_precision(&stds, 0.01);
163        assert_eq!(a.status, PrecisionStatus::Unmet);
164        assert!(a.gap > 0.0, "gap should be positive when unmet");
165    }
166
167    /// Gap formula: (p95 - target) / target
168    #[test]
169    fn test_gap_calculation() {
170        let stds = vec![0.02; 100];
171        let a = assess_precision(&stds, 0.01);
172        let expected_gap = (0.02 - 0.01) / 0.01; // = 1.0
173        assert!(
174            (a.gap - expected_gap).abs() < 1e-10,
175            "gap={}, expected={}",
176            a.gap,
177            expected_gap
178        );
179    }
180
181    /// Percentile function is correct for known distribution
182    #[test]
183    fn test_percentile_computation() {
184        // 100 values: [0.0, 0.01, ..., 0.99] → p95 index = round(0.95*99)=94 → 0.94
185        let vals: Vec<f64> = (0..100).map(|i| i as f64 / 100.0).collect();
186        let p95 = percentile(&vals, 0.95);
187        // nearest-rank: round(0.95 * 99) = round(94.05) = 94 → vals[94] = 0.94
188        assert!((p95 - 0.94).abs() < 1e-10, "p95={}, expected 0.94", p95);
189    }
190
191    /// Empty array returns Unmet without panic
192    #[test]
193    fn test_empty_stds_no_panic() {
194        let a = assess_precision(&[], 0.01);
195        assert_eq!(a.status, PrecisionStatus::Unmet);
196    }
197
198    /// target_std = 0: never MetGoal, gap = infinity
199    #[test]
200    fn test_zero_target_std() {
201        let stds = vec![0.001; 10];
202        let a = assess_precision(&stds, 0.0);
203        assert_ne!(a.status, PrecisionStatus::MetGoal);
204        assert!(
205            a.gap.is_infinite(),
206            "gap should be infinite for target_std=0"
207        );
208    }
209}