blr-active 0.1.0

Active learning orchestration for Bayesian Linear Regression with Automatic Relevance Determination
Documentation
/// Integration tests for the calibration orchestration loop (Algorithm 5).
use blr_active::active_learning::orchestration::{
    CalibrationSession, IterationOutcome, SessionConfig,
};

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

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

fn make_session(target: f64, max_iter: usize) -> CalibrationSession {
    let config = SessionConfig {
        target_precision: target,
        max_iterations: max_iter,
        top_k: 3,
        grid_resolution: 50,
        input_range: (0.0, 10.0),
        ..Default::default()
    };
    CalibrationSession::new(config, linear_feature, 2)
}

// ─── State machine transitions ────────────────────────────────────────────────

#[test]
fn test_no_samples_returns_fit_error() {
    let mut session = make_session(0.01, 10);
    let outcome = session.next_iteration();
    assert!(matches!(outcome, IterationOutcome::FitError(_)));
}

#[test]
fn test_max_iterations_terminates() {
    let mut session = make_session(0.0001, 2); // very tight target, won't converge
                                               // Add 3 samples
    session.add_measurement(0.0, 0.0);
    session.add_measurement(5.0, 10.0);
    session.add_measurement(10.0, 20.0);

    // Exhaust max_iterations=2
    session.next_iteration();
    session.add_measurement(2.5, 5.0);
    session.next_iteration();
    session.add_measurement(7.5, 15.0);

    let outcome = session.next_iteration();
    assert!(matches!(outcome, IterationOutcome::MaxIterationsReached));
}

#[test]
fn test_recommend_next_contains_valid_locations() {
    let mut session = make_session(0.001, 20); // tight target → RecommendNext
                                               // Sparse initial data
    session.add_measurement(0.0, 1.0);
    session.add_measurement(10.0, 21.0);

    let outcome = session.next_iteration();
    match outcome {
        IterationOutcome::RecommendNext(recs) => {
            assert!(!recs.is_empty(), "should have recommendations");
            for r in &recs {
                assert!(r.input_value.is_finite());
                assert!(r.expected_std >= 0.0);
                assert!(r.rank >= 1);
            }
        }
        IterationOutcome::PrecisionMet => {
            // Also acceptable — clean linear data with sufficient precision
        }
        other => panic!("unexpected outcome: {:?}", other),
    }
}

#[test]
fn test_iteration_count_increments() {
    let mut session = make_session(0.001, 20);
    session.add_measurement(0.0, 0.0);
    session.add_measurement(5.0, 10.0);
    session.add_measurement(10.0, 20.0);
    assert_eq!(session.iteration, 0);
    session.next_iteration();
    assert_eq!(session.iteration, 1);
    session.add_measurement(2.5, 5.0);
    session.next_iteration();
    assert_eq!(session.iteration, 2);
}

#[test]
fn test_precision_history_recorded() {
    let mut session = make_session(0.5, 20); // generous target
    session.add_measurement(0.0, 1.0);
    session.add_measurement(5.0, 11.0);
    session.add_measurement(10.0, 21.0);
    session.next_iteration();
    assert_eq!(session.precision_history.len(), 1);
    let record = &session.precision_history[0];
    assert_eq!(record.iteration, 0);
    assert_eq!(record.sample_count, 3);
    assert!(record.max_posterior_std.is_finite());
    assert!(record.mean_posterior_std.is_finite());
    assert!(record.percentile_95_std.is_finite());
}

// ─── End-to-end calibration loop ─────────────────────────────────────────────

#[test]
fn test_calibration_loop_can_reach_precision_goal() {
    // Iteratively add enough clean data until precision goal is met
    // y = 2x + 1, very low noise → linear model should converge quickly
    let mut session = make_session(0.5, 30);

    // Seed with initial samples
    let seed_xs = [0.0_f64, 5.0, 10.0];
    for x in &seed_xs {
        session.add_measurement(*x, 2.0 * x + 1.0);
    }

    let mut reached = false;
    for _ in 0..30 {
        let outcome = session.next_iteration();
        match outcome {
            IterationOutcome::PrecisionMet => {
                reached = true;
                break;
            }
            IterationOutcome::RecommendNext(recs) => {
                // Simulate user collecting at recommended points
                for r in &recs {
                    session.add_measurement(r.input_value, 2.0 * r.input_value + 1.0);
                }
            }
            IterationOutcome::NoiseFloorHit(_) => break,
            IterationOutcome::MaxIterationsReached => break,
            IterationOutcome::FitError(_) => break,
        }
    }
    // With clean linear data and generous target 0.5, goal should be reached
    assert!(reached, "calibration loop should reach precision goal");
}

// ─── JSON history export ──────────────────────────────────────────────────────

#[test]
fn test_history_export_json_structure() {
    let mut session = make_session(0.01, 10);
    session.add_measurement(0.0, 1.0);
    session.add_measurement(5.0, 11.0);
    session.add_measurement(8.0, 17.0);
    let _ = session.next_iteration();

    let json = session.export_history_json();

    // Must contain required top-level keys
    assert!(json.contains("\"iteration_count\""));
    assert!(json.contains("\"sample_count\""));
    assert!(json.contains("\"target_precision\""));
    assert!(json.contains("\"precision_history\""));
    assert!(json.contains("\"samples\""));

    // precision_history must have one entry (one iteration ran)
    assert!(json.contains("\"p95_std\""));
    assert!(json.contains("\"goal_met\""));

    // samples section must have entries
    assert!(json.contains("\"raw_input\""));
    assert!(json.contains("\"measured_output\""));
}

#[test]
fn test_history_export_after_multiple_iterations() {
    let mut session = make_session(0.5, 10);
    session.add_measurements(&[0.0, 5.0, 10.0], &[1.0, 11.0, 21.0]);
    session.next_iteration();
    session.add_measurements(&[2.5, 7.5], &[6.0, 16.0]);
    session.next_iteration();

    let json = session.export_history_json();
    // "iteration_count":2 should appear
    assert!(json.contains("\"iteration_count\":2"));
}

// ─── add_measurements batch convenience ──────────────────────────────────────

#[test]
fn test_add_measurements_batch() {
    let mut session = make_session(0.01, 5);
    session.add_measurements(&[1.0, 2.0, 3.0], &[3.0, 5.0, 7.0]);
    assert_eq!(session.sample_count(), 3);
}

// ─── Noise floor integration ──────────────────────────────────────────────────

#[test]
fn test_noise_floor_hit_when_variance_plateaus() {
    // Use polynomial features and increasingly dense data to saturate variance
    let config = SessionConfig {
        target_precision: 0.0001, // extremely tight — won't be met
        max_iterations: 30,
        top_k: 3,
        grid_resolution: 30,
        input_range: (0.0, 1.0),
        noise_floor_config: blr_active::active_learning::noise_floor::NoiseFloorConfig {
            improvement_threshold: 0.5, // very aggressive: 50% threshold
            window_size: 2,
        },
        ..Default::default()
    };
    let mut session = CalibrationSession::new(config, poly2_feature, 3);

    // Dense initial data — should saturate quickly
    for i in 0..20 {
        let x = i as f64 / 20.0;
        session.add_measurement(x, x * x + 0.01 * ((i as f64 * 1.23).sin()));
    }

    let mut floor_hit = false;
    for _ in 0..30 {
        let outcome = session.next_iteration();
        match outcome {
            IterationOutcome::NoiseFloorHit(_) => {
                floor_hit = true;
                break;
            }
            IterationOutcome::RecommendNext(recs) => {
                // Add at recommended points (more dense data)
                for r in &recs {
                    session.add_measurement(r.input_value, r.input_value * r.input_value);
                }
            }
            IterationOutcome::PrecisionMet => break,
            IterationOutcome::MaxIterationsReached => break,
            IterationOutcome::FitError(_) => break,
        }
    }
    assert!(
        floor_hit,
        "aggressive noise floor config should trigger floor hit"
    );
}