Skip to main content

blr_active/
calibration_session.rs

1//! User-facing calibration session for noise-aware precision targeting.
2//!
3//! `NoiseCalibrationSession` represents the top-level user interaction lifecycle:
4//!
5//! ```text
6//! 1. Fit an initial BLR+ARD model on ≥10 samples.
7//! 2. Extract noise estimate via noise_estimation::estimate_noise_with_confidence.
8//! 3. Create a NoiseCalibrationSession with that estimate.
9//! 4. Present the precision tier table to the user.
10//! 5. Call set_goal(PrecisionLevel::Moderate) to lock in a target.
11//! 6. Call to_active_learning_config() to get parameters for the AL loop.
12//! ```
13//!
14//! ## Naming
15//!
16//! This struct is called `NoiseCalibrationSession` to distinguish it from
17//! `active_learning::orchestration::CalibrationSession`, which manages the
18//! lower-level iteration loop. The noise session wraps the *user-facing* phase
19//! that precedes active learning.
20//!
21//! # Example
22//!
23//! ```rust
24//! use blr_active::calibration_session::NoiseCalibrationSession;
25//! use blr_core::noise_estimation::{NoiseEstimate, SensorType};
26//! use blr_active::precision_tiers::PrecisionLevel;
27//!
28//! let noise = NoiseEstimate {
29//!     point_estimate: 0.008,
30//!     lower_bound: 0.0072,
31//!     upper_bound: 0.0088,
32//!     confidence: "stable".to_string(),
33//! };
34//!
35//! let mut session = NoiseCalibrationSession::new(SensorType::Hall, noise);
36//! session.set_goal(PrecisionLevel::Moderate).unwrap();
37//!
38//! let config = session.to_active_learning_config().unwrap();
39//! assert!((config.target_std  - 0.024).abs() < 1e-10);
40//! assert!((config.noise_floor - 0.008).abs() < 1e-10);
41//! ```
42
43use serde::{Deserialize, Serialize};
44
45use crate::goal_assessment::{assess_goal_feasibility, CalibrationGoal, GoalFeasibility};
46use crate::precision_tiers::{compute_precision_tiers, PrecisionLevel, PrecisionTierInfo};
47use blr_core::noise_estimation::{NoiseEstimate, SensorType};
48
49// ─── ActiveLearningConfig ────────────────────────────────────────────────────
50
51/// Parameters produced by `NoiseCalibrationSession` for use in the active
52/// learning iteration loop.
53///
54/// Pass this to the orchestration layer to configure termination criteria.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ActiveLearningConfig {
57    /// Target posterior std the model must reach (σ_noise × factor).
58    pub target_std: f64,
59    /// Sensor noise floor σ_noise; used to detect noise-floor plateaus.
60    pub noise_floor: f64,
61    /// Maximum number of active learning iterations before forced stop.
62    pub max_iterations: usize,
63    /// Fractional margin above noise_floor before calling "at noise floor".
64    /// Default 0.05 means "within 5% of the noise floor counts as at-floor".
65    pub noise_floor_tolerance_margin: f64,
66}
67
68impl Default for ActiveLearningConfig {
69    fn default() -> Self {
70        Self {
71            target_std: f64::INFINITY,
72            noise_floor: 0.0,
73            max_iterations: 100,
74            noise_floor_tolerance_margin: 0.05,
75        }
76    }
77}
78
79// ─── NoiseCalibrationSession ─────────────────────────────────────────────────
80
81/// User-facing session for noise-aware precision targeting.
82///
83/// Lifecycle:
84/// 1. Construct with [`new`](Self::new).
85/// 2. Display `precision_tiers` to the user.
86/// 3. Call [`set_goal`](Self::set_goal) with the user's chosen tier.
87/// 4. Call [`to_active_learning_config`](Self::to_active_learning_config)
88///    to retrieve parameters for the active learning loop.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct NoiseCalibrationSession {
91    /// Sensor type for labelling / metadata.
92    pub sensor_type: SensorType,
93    /// Noise estimate extracted from an initial BLR+ARD fit.
94    pub estimated_noise: NoiseEstimate,
95    /// All four precision tiers computed from the noise estimate.
96    pub precision_tiers: [PrecisionTierInfo; 4],
97    /// The goal selected by the user (None until `set_goal` is called).
98    pub selected_goal: Option<CalibrationGoal>,
99    /// Absolute target std in signal units (None until goal is set).
100    pub target_std: Option<f64>,
101}
102
103impl NoiseCalibrationSession {
104    /// Create a new session from a sensor type and a noise estimate.
105    ///
106    /// Precision tiers are computed immediately from the noise point estimate.
107    pub fn new(sensor_type: SensorType, estimated_noise: NoiseEstimate) -> Self {
108        let precision_tiers = compute_precision_tiers(estimated_noise.point_estimate);
109        Self {
110            sensor_type,
111            estimated_noise,
112            precision_tiers,
113            selected_goal: None,
114            target_std: None,
115        }
116    }
117
118    /// Set the calibration precision goal.
119    ///
120    /// Assesses feasibility of `level` against the noise estimate. Accepts
121    /// `Achievable` and `Marginal` goals (with a warning). Rejects
122    /// `Unachievable` goals with an error.
123    ///
124    /// Can be called multiple times to change the goal before starting
125    /// active learning.
126    ///
127    /// # Errors
128    ///
129    /// Returns `Err(message)` if the chosen goal is classified as
130    /// [`GoalFeasibility::Unachievable`] (i.e., target is below the noise floor).
131    pub fn set_goal(&mut self, level: PrecisionLevel) -> Result<(), String> {
132        let goal = assess_goal_feasibility(level, self.estimated_noise.point_estimate);
133
134        if goal.feasibility == GoalFeasibility::Unachievable {
135            return Err(format!("Cannot set goal: {}", goal.message));
136        }
137
138        self.target_std = Some(goal.target_std);
139        self.selected_goal = Some(goal);
140        Ok(())
141    }
142
143    /// Produce an `ActiveLearningConfig` from the locked-in goal.
144    ///
145    /// # Errors
146    ///
147    /// Returns `Err` if `set_goal` has not been called yet.
148    pub fn to_active_learning_config(&self) -> Result<ActiveLearningConfig, String> {
149        let target_std = self.target_std.ok_or_else(|| {
150            "No goal selected — call set_goal() before to_active_learning_config()".to_string()
151        })?;
152
153        Ok(ActiveLearningConfig {
154            target_std,
155            noise_floor: self.estimated_noise.point_estimate,
156            max_iterations: 100,
157            noise_floor_tolerance_margin: 0.05,
158        })
159    }
160
161    /// Return the selected goal's feasibility classification, if a goal is set.
162    pub fn goal_feasibility(&self) -> Option<&GoalFeasibility> {
163        self.selected_goal.as_ref().map(|g| &g.feasibility)
164    }
165
166    /// True if a goal has been set and active learning can begin.
167    pub fn is_ready(&self) -> bool {
168        self.selected_goal.is_some()
169    }
170}
171
172// ─── Tests ────────────────────────────────────────────────────────────────────
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn make_noise() -> NoiseEstimate {
179        NoiseEstimate {
180            point_estimate: 0.008,
181            lower_bound: 0.0072,
182            upper_bound: 0.0088,
183            confidence: "stable".to_string(),
184        }
185    }
186
187    #[test]
188    fn test_session_new_initialises_correctly() {
189        let session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
190        assert_eq!(session.sensor_type, SensorType::Hall);
191        assert!(session.selected_goal.is_none(), "goal should start as None");
192        assert!(
193            session.target_std.is_none(),
194            "target_std should start as None"
195        );
196        assert!(
197            !session.is_ready(),
198            "session should not be ready before set_goal"
199        );
200    }
201
202    #[test]
203    fn test_session_tiers_computed_from_noise() {
204        let session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
205        assert!(
206            (session.precision_tiers[1].absolute_tolerance - 0.024).abs() < 1e-10,
207            "Moderate tier = 3 × 0.008 = 0.024"
208        );
209    }
210
211    #[test]
212    fn test_set_goal_moderate_accepted() {
213        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
214        assert!(
215            session.set_goal(PrecisionLevel::Moderate).is_ok(),
216            "Moderate (3.0×) must be accepted"
217        );
218        assert!(session.is_ready());
219    }
220
221    #[test]
222    fn test_set_goal_max_accepted_with_marginal() {
223        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
224        // Max is Marginal — accepted with warning, but not rejected
225        assert!(
226            session.set_goal(PrecisionLevel::Max).is_ok(),
227            "Max (1.0×, Marginal) must be accepted (with warning in message)"
228        );
229    }
230
231    #[test]
232    fn test_set_goal_stores_target_std() {
233        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
234        session.set_goal(PrecisionLevel::Moderate).unwrap();
235        let target = session.target_std.unwrap();
236        assert!(
237            (target - 0.024).abs() < 1e-10,
238            "target_std = 3.0 × 0.008 = 0.024"
239        );
240    }
241
242    #[test]
243    fn test_to_active_learning_config_requires_goal() {
244        let session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
245        let result = session.to_active_learning_config();
246        assert!(result.is_err(), "must fail before set_goal() is called");
247    }
248
249    #[test]
250    fn test_to_active_learning_config_correct_values() {
251        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
252        session.set_goal(PrecisionLevel::Moderate).unwrap();
253        let config = session.to_active_learning_config().unwrap();
254        assert!((config.target_std - 0.024).abs() < 1e-10, "target_std");
255        assert!((config.noise_floor - 0.008).abs() < 1e-10, "noise_floor");
256        assert_eq!(config.max_iterations, 100);
257    }
258
259    #[test]
260    fn test_set_goal_can_be_changed() {
261        let mut session = NoiseCalibrationSession::new(SensorType::Hall, make_noise());
262        session.set_goal(PrecisionLevel::Low).unwrap();
263        session.set_goal(PrecisionLevel::Moderate).unwrap();
264        let target = session.target_std.unwrap();
265        assert!(
266            (target - 0.024).abs() < 1e-10,
267            "goal update: target should reflect latest selection"
268        );
269    }
270}