blr-active 0.1.0

Active learning orchestration for Bayesian Linear Regression with Automatic Relevance Determination
Documentation
//! Multi-sensor orchestration support.
//!
//! Manages independent calibration sessions for multiple sensor types
//! simultaneously. Each sensor maintains its own BLR+ARD model and
//! precision history, enabling unified reporting.

use crate::active_learning::acquisition::RecommendedSample;
use crate::active_learning::orchestration::{CalibrationSession, IterationOutcome, SessionConfig};

/// Aggregated recommendations from multiple sensors.
#[derive(Debug, Clone)]
pub struct MultiSensorRecommendation {
    /// Sensor identifier label (e.g., "hall", "gas", "thermistor").
    pub sensor_id: String,
    /// Recommendations from this sensor's calibration session.
    pub recommendations: Vec<RecommendedSample>,
    /// Current 95th-percentile posterior std for this sensor.
    pub current_p95_std: f64,
    /// Whether this sensor has met its precision goal.
    pub goal_met: bool,
}

/// Multi-sensor calibration coordinator.
///
/// Maintains a map of named sensor sessions. Each sensor has its own
/// independent BLR+ARD model and calibration config (different input ranges,
/// feature functions, and precision targets are all supported).
pub struct MultiSensorSession {
    sessions: Vec<(String, CalibrationSession)>,
}

impl MultiSensorSession {
    /// Create an empty multi-sensor session.
    pub fn new() -> Self {
        Self {
            sessions: Vec::new(),
        }
    }

    /// Register a new sensor session.
    ///
    /// # Arguments
    /// - `sensor_id`: unique identifier for this sensor
    /// - `config`: session configuration for this sensor
    /// - `feature_fn`: feature engineering function for this sensor
    /// - `feature_dim`: number of features D
    pub fn add_sensor(
        &mut self,
        sensor_id: impl Into<String>,
        config: SessionConfig,
        feature_fn: impl Fn(f64) -> Vec<f64> + 'static,
        feature_dim: usize,
    ) {
        let session = CalibrationSession::new(config, feature_fn, feature_dim);
        self.sessions.push((sensor_id.into(), session));
    }

    /// Add a measurement to the specified sensor session.
    pub fn add_measurement(&mut self, sensor_id: &str, raw_input: f64, measured_output: f64) {
        if let Some((_, session)) = self.sessions.iter_mut().find(|(id, _)| id == sensor_id) {
            session.add_measurement(raw_input, measured_output);
        }
    }

    /// Run one calibration iteration for all sensors.
    ///
    /// Returns recommendations aggregated across all sensors that still need samples.
    /// Sensors that have met their goal or hit the noise floor are excluded from
    /// recommendations but their status is still reported.
    pub fn next_iteration_all(&mut self) -> Vec<MultiSensorRecommendation> {
        let mut results = Vec::new();

        for (sensor_id, session) in &mut self.sessions {
            let outcome = session.next_iteration();

            let (recs, goal_met) = match &outcome {
                IterationOutcome::RecommendNext(r) => (r.clone(), false),
                IterationOutcome::PrecisionMet => (Vec::new(), true),
                IterationOutcome::NoiseFloorHit(_) => (Vec::new(), false),
                IterationOutcome::MaxIterationsReached | IterationOutcome::FitError(_) => {
                    (Vec::new(), false)
                }
            };

            let current_p95 = session
                .precision_history
                .last()
                .map(|r| r.percentile_95_std)
                .unwrap_or(f64::INFINITY);

            results.push(MultiSensorRecommendation {
                sensor_id: sensor_id.clone(),
                recommendations: recs,
                current_p95_std: current_p95,
                goal_met,
            });
        }

        results
    }

    /// Check whether all registered sensors have met their precision goals.
    pub fn all_goals_met(&self) -> bool {
        self.sessions.iter().all(|(_, session)| {
            session
                .precision_history
                .last()
                .map(|r| r.goal_met)
                .unwrap_or(false)
        })
    }

    /// Per-sensor precision summary (sensor_id → current p95 std).
    pub fn precision_summary(&self) -> Vec<(String, f64)> {
        self.sessions
            .iter()
            .map(|(id, session)| {
                let p95 = session
                    .precision_history
                    .last()
                    .map(|r| r.percentile_95_std)
                    .unwrap_or(f64::INFINITY);
                (id.clone(), p95)
            })
            .collect()
    }
}

impl Default for MultiSensorSession {
    fn default() -> Self {
        Self::new()
    }
}

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

    fn polynomial_feature(x: f64) -> Vec<f64> {
        vec![1.0, x, x * x]
    }

    fn make_config(input_max: f64, target: f64) -> SessionConfig {
        SessionConfig {
            target_precision: target,
            max_iterations: 10,
            top_k: 2,
            grid_resolution: 20,
            input_range: (0.0, input_max),
            ..Default::default()
        }
    }

    /// Two sensors should have independent precision tracks
    #[test]
    fn test_independent_precision_tracking() {
        let mut ms = MultiSensorSession::new();
        ms.add_sensor("hall", make_config(100.0, 0.5), polynomial_feature, 3);
        ms.add_sensor("gas", make_config(50.0, 0.3), polynomial_feature, 3);

        // Add measurements to hall sensor
        for i in 0..5 {
            ms.add_measurement("hall", i as f64 * 20.0, i as f64 * 2.0 + 0.01);
        }
        // Add measurements to gas sensor
        for i in 0..5 {
            ms.add_measurement("gas", i as f64 * 10.0, i as f64 * 0.5 + 0.005);
        }

        let results = ms.next_iteration_all();
        assert_eq!(results.len(), 2, "should have results for both sensors");

        // Precision tracks are independent
        let hall = results.iter().find(|r| r.sensor_id == "hall").unwrap();
        let gas = results.iter().find(|r| r.sensor_id == "gas").unwrap();
        // p95 values may differ (different data & target)
        // Just verify they are finite
        assert!(hall.current_p95_std.is_finite());
        assert!(gas.current_p95_std.is_finite());
    }

    /// all_goals_met returns false when sensors haven't converged
    #[test]
    fn test_all_goals_not_met_initially() {
        let mut ms = MultiSensorSession::new();
        ms.add_sensor("hall", make_config(100.0, 0.0001), polynomial_feature, 3); // very tight
        ms.add_measurement("hall", 0.0, 0.0);
        ms.add_measurement("hall", 50.0, 1.0);
        ms.next_iteration_all();
        assert!(!ms.all_goals_met());
    }
}