blr-active 0.1.0

Active learning orchestration for Bayesian Linear Regression with Automatic Relevance Determination
Documentation
//! User-facing calibration session for noise-aware precision targeting.
//!
//! `NoiseCalibrationSession` represents the top-level user interaction lifecycle:
//!
//! ```text
//! 1. Fit an initial BLR+ARD model on ≥10 samples.
//! 2. Extract noise estimate via noise_estimation::estimate_noise_with_confidence.
//! 3. Create a NoiseCalibrationSession with that estimate.
//! 4. Present the precision tier table to the user.
//! 5. Call set_goal(PrecisionLevel::Moderate) to lock in a target.
//! 6. Call to_active_learning_config() to get parameters for the AL loop.
//! ```
//!
//! ## Naming
//!
//! This struct is called `NoiseCalibrationSession` to distinguish it from
//! `active_learning::orchestration::CalibrationSession`, which manages the
//! lower-level iteration loop. The noise session wraps the *user-facing* phase
//! that precedes active learning.
//!
//! # Example
//!
//! ```rust
//! use blr_active::calibration_session::NoiseCalibrationSession;
//! use blr_core::noise_estimation::{NoiseEstimate, SensorType};
//! use blr_active::precision_tiers::PrecisionLevel;
//!
//! let noise = NoiseEstimate {
//!     point_estimate: 0.008,
//!     lower_bound: 0.0072,
//!     upper_bound: 0.0088,
//!     confidence: "stable".to_string(),
//! };
//!
//! let mut session = NoiseCalibrationSession::new(SensorType::Hall, noise);
//! session.set_goal(PrecisionLevel::Moderate).unwrap();
//!
//! let config = session.to_active_learning_config().unwrap();
//! assert!((config.target_std  - 0.024).abs() < 1e-10);
//! assert!((config.noise_floor - 0.008).abs() < 1e-10);
//! ```

use serde::{Deserialize, Serialize};

use crate::goal_assessment::{assess_goal_feasibility, CalibrationGoal, GoalFeasibility};
use crate::precision_tiers::{compute_precision_tiers, PrecisionLevel, PrecisionTierInfo};
use blr_core::noise_estimation::{NoiseEstimate, SensorType};

// ─── ActiveLearningConfig ────────────────────────────────────────────────────

/// Parameters produced by `NoiseCalibrationSession` for use in the active
/// learning iteration loop.
///
/// Pass this to the orchestration layer to configure termination criteria.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveLearningConfig {
    /// Target posterior std the model must reach (σ_noise × factor).
    pub target_std: f64,
    /// Sensor noise floor σ_noise; used to detect noise-floor plateaus.
    pub noise_floor: f64,
    /// Maximum number of active learning iterations before forced stop.
    pub max_iterations: usize,
    /// Fractional margin above noise_floor before calling "at noise floor".
    /// Default 0.05 means "within 5% of the noise floor counts as at-floor".
    pub noise_floor_tolerance_margin: f64,
}

impl Default for ActiveLearningConfig {
    fn default() -> Self {
        Self {
            target_std: f64::INFINITY,
            noise_floor: 0.0,
            max_iterations: 100,
            noise_floor_tolerance_margin: 0.05,
        }
    }
}

// ─── NoiseCalibrationSession ─────────────────────────────────────────────────

/// User-facing session for noise-aware precision targeting.
///
/// Lifecycle:
/// 1. Construct with [`new`](Self::new).
/// 2. Display `precision_tiers` to the user.
/// 3. Call [`set_goal`](Self::set_goal) with the user's chosen tier.
/// 4. Call [`to_active_learning_config`](Self::to_active_learning_config)
///    to retrieve parameters for the active learning loop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoiseCalibrationSession {
    /// Sensor type for labelling / metadata.
    pub sensor_type: SensorType,
    /// Noise estimate extracted from an initial BLR+ARD fit.
    pub estimated_noise: NoiseEstimate,
    /// All four precision tiers computed from the noise estimate.
    pub precision_tiers: [PrecisionTierInfo; 4],
    /// The goal selected by the user (None until `set_goal` is called).
    pub selected_goal: Option<CalibrationGoal>,
    /// Absolute target std in signal units (None until goal is set).
    pub target_std: Option<f64>,
}

impl NoiseCalibrationSession {
    /// Create a new session from a sensor type and a noise estimate.
    ///
    /// Precision tiers are computed immediately from the noise point estimate.
    pub fn new(sensor_type: SensorType, estimated_noise: NoiseEstimate) -> Self {
        let precision_tiers = compute_precision_tiers(estimated_noise.point_estimate);
        Self {
            sensor_type,
            estimated_noise,
            precision_tiers,
            selected_goal: None,
            target_std: None,
        }
    }

    /// Set the calibration precision goal.
    ///
    /// Assesses feasibility of `level` against the noise estimate. Accepts
    /// `Achievable` and `Marginal` goals (with a warning). Rejects
    /// `Unachievable` goals with an error.
    ///
    /// Can be called multiple times to change the goal before starting
    /// active learning.
    ///
    /// # Errors
    ///
    /// Returns `Err(message)` if the chosen goal is classified as
    /// [`GoalFeasibility::Unachievable`] (i.e., target is below the noise floor).
    pub fn set_goal(&mut self, level: PrecisionLevel) -> Result<(), String> {
        let goal = assess_goal_feasibility(level, self.estimated_noise.point_estimate);

        if goal.feasibility == GoalFeasibility::Unachievable {
            return Err(format!("Cannot set goal: {}", goal.message));
        }

        self.target_std = Some(goal.target_std);
        self.selected_goal = Some(goal);
        Ok(())
    }

    /// Produce an `ActiveLearningConfig` from the locked-in goal.
    ///
    /// # Errors
    ///
    /// Returns `Err` if `set_goal` has not been called yet.
    pub fn to_active_learning_config(&self) -> Result<ActiveLearningConfig, String> {
        let target_std = self.target_std.ok_or_else(|| {
            "No goal selected — call set_goal() before to_active_learning_config()".to_string()
        })?;

        Ok(ActiveLearningConfig {
            target_std,
            noise_floor: self.estimated_noise.point_estimate,
            max_iterations: 100,
            noise_floor_tolerance_margin: 0.05,
        })
    }

    /// Return the selected goal's feasibility classification, if a goal is set.
    pub fn goal_feasibility(&self) -> Option<&GoalFeasibility> {
        self.selected_goal.as_ref().map(|g| &g.feasibility)
    }

    /// True if a goal has been set and active learning can begin.
    pub fn is_ready(&self) -> bool {
        self.selected_goal.is_some()
    }
}

// ─── Tests ────────────────────────────────────────────────────────────────────

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

    fn make_noise() -> NoiseEstimate {
        NoiseEstimate {
            point_estimate: 0.008,
            lower_bound: 0.0072,
            upper_bound: 0.0088,
            confidence: "stable".to_string(),
        }
    }

    #[test]
    fn test_session_new_initialises_correctly() {
        let session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        assert_eq!(session.sensor_type, SensorType::Hall);
        assert!(session.selected_goal.is_none(), "goal should start as None");
        assert!(
            session.target_std.is_none(),
            "target_std should start as None"
        );
        assert!(
            !session.is_ready(),
            "session should not be ready before set_goal"
        );
    }

    #[test]
    fn test_session_tiers_computed_from_noise() {
        let session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        assert!(
            (session.precision_tiers[1].absolute_tolerance - 0.024).abs() < 1e-10,
            "Moderate tier = 3 × 0.008 = 0.024"
        );
    }

    #[test]
    fn test_set_goal_moderate_accepted() {
        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        assert!(
            session.set_goal(PrecisionLevel::Moderate).is_ok(),
            "Moderate (3.0×) must be accepted"
        );
        assert!(session.is_ready());
    }

    #[test]
    fn test_set_goal_max_accepted_with_marginal() {
        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        // Max is Marginal — accepted with warning, but not rejected
        assert!(
            session.set_goal(PrecisionLevel::Max).is_ok(),
            "Max (1.0×, Marginal) must be accepted (with warning in message)"
        );
    }

    #[test]
    fn test_set_goal_stores_target_std() {
        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        session.set_goal(PrecisionLevel::Moderate).unwrap();
        let target = session.target_std.unwrap();
        assert!(
            (target - 0.024).abs() < 1e-10,
            "target_std = 3.0 × 0.008 = 0.024"
        );
    }

    #[test]
    fn test_to_active_learning_config_requires_goal() {
        let session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        let result = session.to_active_learning_config();
        assert!(result.is_err(), "must fail before set_goal() is called");
    }

    #[test]
    fn test_to_active_learning_config_correct_values() {
        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        session.set_goal(PrecisionLevel::Moderate).unwrap();
        let config = session.to_active_learning_config().unwrap();
        assert!((config.target_std - 0.024).abs() < 1e-10, "target_std");
        assert!((config.noise_floor - 0.008).abs() < 1e-10, "noise_floor");
        assert_eq!(config.max_iterations, 100);
    }

    #[test]
    fn test_set_goal_can_be_changed() {
        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
        session.set_goal(PrecisionLevel::Low).unwrap();
        session.set_goal(PrecisionLevel::Moderate).unwrap();
        let target = session.target_std.unwrap();
        assert!(
            (target - 0.024).abs() < 1e-10,
            "goal update: target should reflect latest selection"
        );
    }
}