blr-active 0.1.0

Active learning orchestration for Bayesian Linear Regression with Automatic Relevance Determination
Documentation
//! Algorithm 4: Noise Floor Detection
//!
//! Detects when the posterior variance has stopped decreasing despite
//! additional measurements — indicating that hardware sensor noise is
//! the limiting factor, not insufficient coverage of the input space.
//!
//! **Heuristic (per design):**
//! Declare floor reached when the relative improvement in max posterior std
//! is < 1% for `window_size` consecutive iterations.
//!
//! **Tuning parameters:**
//! - `improvement_threshold`: default 0.01 (1% relative change)
//! - `window_size`: default 3 iterations (need 3 consecutive near-flat steps)
//!
//! These are configurable to allow per-sensor-type tuning.

/// Result of noise floor detection analysis.
#[derive(Debug, Clone)]
pub struct NoiseFloorDiagnostic {
    /// Sample counts from all recorded iterations.
    pub sample_counts: Vec<usize>,
    /// Max posterior std observed at each iteration.
    pub max_stds: Vec<f64>,
    /// Estimated noise floor value (last observed max_std when at floor).
    pub predicted_noise_floor: f64,
    /// Confidence in the floor estimate (0–1).
    /// 0 when insufficient history; rises with number of consistent plateau steps.
    pub confidence: f64,
    /// Whether the heuristic declares we're at the noise floor.
    pub likely_at_floor: bool,
}

/// Configuration parameters for noise floor detection.
#[derive(Debug, Clone)]
pub struct NoiseFloorConfig {
    /// Minimum relative improvement between iterations to *not* declare floor.
    /// Default: 0.01 (1%).
    pub improvement_threshold: f64,
    /// Number of consecutive near-flat iterations required to declare floor.
    /// Default: 3.
    pub window_size: usize,
}

impl Default for NoiseFloorConfig {
    fn default() -> Self {
        Self {
            improvement_threshold: 0.01,
            window_size: 3,
        }
    }
}

/// Detect whether the calibration has hit the sensor noise floor.
///
/// # Arguments
/// - `iteration_history`: `(n_samples, max_posterior_std)` pairs from each
///   iteration, ordered oldest-first.
/// - `config`: tuning parameters (default: 1% threshold, window=3).
///
/// # Returns
/// A [`NoiseFloorDiagnostic`] describing the current state.
///
/// # Behaviour on edge cases
/// - `history.len() < window_size + 1`: returns `likely_at_floor=false`, `confidence=0`.
/// - All zeros in history: declared at floor with full confidence.
/// - Monotonic decrease: not at floor (still improving).
pub fn detect_noise_floor(
    iteration_history: &[(usize, f64)],
    config: &NoiseFloorConfig,
) -> NoiseFloorDiagnostic {
    let sample_counts: Vec<usize> = iteration_history.iter().map(|&(n, _)| n).collect();
    let max_stds: Vec<f64> = iteration_history.iter().map(|&(_, s)| s).collect();

    // Not enough history to make a judgement
    if iteration_history.len() < config.window_size + 1 {
        return NoiseFloorDiagnostic {
            sample_counts,
            max_stds,
            predicted_noise_floor: iteration_history.last().map(|&(_, s)| s).unwrap_or(0.0),
            confidence: 0.0,
            likely_at_floor: false,
        };
    }

    // Examine the most recent `window_size` consecutive pairs
    let recent = &iteration_history[iteration_history.len() - config.window_size..];

    // Compute relative improvement between consecutive pairs: (prev - curr) / prev
    // Guard: if prev == 0.0, treat as no improvement (clamped at threshold - 1)
    let relative_changes: Vec<f64> = recent
        .windows(2)
        .map(|w| {
            let prev = w[0].1;
            let curr = w[1].1;
            if prev.abs() < 1e-15 {
                0.0 // already at zero; treat as no improvement
            } else {
                (prev - curr).abs() / prev.abs()
            }
        })
        .collect();

    let n_plateau_steps = relative_changes
        .iter()
        .filter(|&&c| c < config.improvement_threshold)
        .count();

    let likely_at_floor = n_plateau_steps == relative_changes.len();

    // Confidence: fraction of required window steps that show plateau behaviour.
    // Use window_size - 1 because we need window_size pairs from window_size+1 points.
    let required = config.window_size.saturating_sub(1).max(1);
    let confidence = (n_plateau_steps as f64 / required as f64).min(1.0);

    let predicted_noise_floor = iteration_history.last().map(|&(_, s)| s).unwrap_or(0.0);

    NoiseFloorDiagnostic {
        sample_counts,
        max_stds,
        predicted_noise_floor,
        confidence,
        likely_at_floor,
    }
}

/// Convenience wrapper using default configuration.
pub fn detect_noise_floor_default(iteration_history: &[(usize, f64)]) -> NoiseFloorDiagnostic {
    detect_noise_floor(iteration_history, &NoiseFloorConfig::default())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg() -> NoiseFloorConfig {
        NoiseFloorConfig::default()
    }

    /// Synthetic exponential-decay convergence: last 3 steps < 1% → floor detected
    #[test]
    fn test_noise_floor_synthetic_plateau() {
        // Simulate: std drops quickly then plateaus at ~0.02
        let history: Vec<(usize, f64)> = vec![
            (5, 0.100),
            (10, 0.060),
            (15, 0.040),
            (20, 0.025),
            (25, 0.0201),
            (30, 0.0200),
            (35, 0.0200),  // < 1% from previous
            (40, 0.01999), // < 1% from previous
        ];
        let diag = detect_noise_floor(&history, &cfg());
        assert!(diag.likely_at_floor, "should detect plateau");
        assert!((diag.predicted_noise_floor - 0.01999).abs() < 1e-4);
    }

    /// Too few data points → no floor declared, confidence=0
    #[test]
    fn test_noise_floor_early_data() {
        let history = vec![(5, 0.1), (10, 0.08)];
        let diag = detect_noise_floor(&history, &cfg());
        assert!(!diag.likely_at_floor);
        assert_eq!(diag.confidence, 0.0);
    }

    /// 1% threshold with 3-iteration window: only last 3 must be near-flat
    #[test]
    fn test_noise_floor_threshold_tuning() {
        // Exactly 3 consecutive steps below 1% improvement
        let history = vec![
            (5, 0.2),
            (10, 0.1),
            (15, 0.0502),  // 49.8% improvement — not plateau
            (20, 0.0501),  // 0.2% improvement — plateau
            (25, 0.05009), // 0.02% improvement — plateau
            (30, 0.05008), // 0.02% improvement — plateau
        ];
        let diag = detect_noise_floor(&history, &cfg());
        assert!(
            diag.likely_at_floor,
            "3 consecutive plateau steps should trigger floor"
        );
    }

    /// During slow but steady convergence, should NOT declare floor
    #[test]
    fn test_false_positive_prevention() {
        // 2% improvement each step — above 1% threshold → not at floor
        let history: Vec<(usize, f64)> = (0..10_usize)
            .map(|i| {
                let n = (i + 1) * 5;
                let s = 0.1 * 0.98_f64.powi(i as i32);
                (n, s)
            })
            .collect();
        let diag = detect_noise_floor(&history, &cfg());
        assert!(
            !diag.likely_at_floor,
            "steady 2% improvement should not be floor"
        );
    }

    /// All-zeros history: at floor with full confidence
    #[test]
    fn test_all_zeros_history() {
        let history = vec![(5, 0.0), (10, 0.0), (15, 0.0), (20, 0.0)];
        let diag = detect_noise_floor(&history, &cfg());
        assert!(diag.likely_at_floor);
    }

    /// Monotonically decreasing by more than threshold → not at floor
    #[test]
    fn test_monotonic_decrease_not_floor() {
        let history = vec![(10, 0.1), (20, 0.05), (30, 0.025), (40, 0.0125)];
        let diag = detect_noise_floor(&history, &cfg());
        assert!(
            !diag.likely_at_floor,
            "50% improvement steps should not trigger floor"
        );
    }

    /// Custom config: 5% threshold, window=2
    #[test]
    fn test_custom_config() {
        let custom_cfg = NoiseFloorConfig {
            improvement_threshold: 0.05,
            window_size: 2,
        };
        let history = vec![
            (10, 0.1),
            (20, 0.096), // 4% improvement → below 5% threshold
            (30, 0.094), // ~2% improvement → below 5% threshold
        ];
        let diag = detect_noise_floor(&history, &custom_cfg);
        assert!(
            diag.likely_at_floor,
            "both steps < 5% → floor with custom config"
        );
    }
}