defmodule Microwaveprop.Propagation.CalibrationTest do use ExUnit.Case, async: true alias Microwaveprop.Propagation.Calibration describe "spearman/2" do test "perfect monotonic correlation is 1.0" do assert_in_delta Calibration.spearman([1, 2, 3, 4, 5], [10, 20, 30, 40, 50]), 1.0, 1.0e-9 end test "perfect inverse correlation is -1.0" do assert_in_delta Calibration.spearman([1, 2, 3, 4, 5], [50, 40, 30, 20, 10]), -1.0, 1.0e-9 end test "uncorrelated input lands near zero" do xs = Enum.to_list(1..100) # constant ys → undefined ranking; we expect 0 by convention. ys = List.duplicate(42, 100) result = Calibration.spearman(xs, ys) assert result == nil or abs(result) < 0.05 end test "returns nil for fewer than 3 pairs" do assert Calibration.spearman([1], [1]) == nil assert Calibration.spearman([1, 2], [3, 4]) == nil end end describe "rmse/2" do test "zero for identical lists" do assert Calibration.rmse([1, 2, 3], [1, 2, 3]) == 0.0 end test "matches the textbook formula" do # mean of [1, 4, 9] = 14/3 ; sqrt = 2.16 assert_in_delta Calibration.rmse([1, 2, 3], [2, 4, 6]), :math.sqrt(14 / 3), 1.0e-9 end test "returns nil for empty input" do assert Calibration.rmse([], []) == nil end end describe "bucket_by_score/2" do test "splits samples into 5 fixed score buckets and reports n + median distance" do samples = [ %{algorithm_score: 5, distance_km: 50}, %{algorithm_score: 15, distance_km: 60}, %{algorithm_score: 25, distance_km: 100}, %{algorithm_score: 45, distance_km: 200}, %{algorithm_score: 65, distance_km: 300}, %{algorithm_score: 85, distance_km: 500} ] buckets = Calibration.bucket_by_score(samples, :algorithm_score) assert length(buckets) == 5 # First bucket is 0-20 with two contacts assert hd(buckets) == %{ bucket: "0-20", n: 2, median_distance_km: 55.0 } # Last bucket is 80-100 with one contact last = List.last(buckets) assert last.bucket == "80-100" assert last.n == 1 assert last.median_distance_km == 500.0 end test "returns 5 buckets even when some are empty" do buckets = Calibration.bucket_by_score([%{algorithm_score: 50, distance_km: 100}], :algorithm_score) assert length(buckets) == 5 empty = Enum.filter(buckets, &(&1.n == 0)) assert length(empty) == 4 end end describe "band_summary/1" do test "computes n, mean scores, RMSE, and Spearman correlations" do samples = for i <- 1..10 do %{algorithm_score: i * 10, ml_score: i * 10 + 2, distance_km: i * 50} end summary = Calibration.band_summary(samples) assert summary.n == 10 assert summary.mean_algorithm_score == 55.0 assert summary.mean_ml_score == 57.0 assert_in_delta summary.alg_ml_rmse, 2.0, 1.0e-9 assert_in_delta summary.alg_distance_spearman, 1.0, 1.0e-9 assert_in_delta summary.ml_distance_spearman, 1.0, 1.0e-9 end test "returns a zero-row summary for empty input" do summary = Calibration.band_summary([]) assert summary.n == 0 assert summary.alg_ml_rmse == nil end end describe "disagreements/2" do test "returns top N samples by absolute alg-ml score difference, descending" do samples = [ %{id: 1, algorithm_score: 50, ml_score: 51}, %{id: 2, algorithm_score: 50, ml_score: 80}, %{id: 3, algorithm_score: 50, ml_score: 30}, %{id: 4, algorithm_score: 50, ml_score: 49}, %{id: 5, algorithm_score: 50, ml_score: 0} ] top = Calibration.disagreements(samples, 3) assert Enum.map(top, & &1.id) == [5, 2, 3] end end describe "detect_drift/3" do test "flags when current alg_ml_rmse exceeds the drift threshold" do assert {:drift, msg} = Calibration.detect_drift(:ml_drift, 12.0, threshold: 8.0) assert msg =~ "ML model has drifted" end test "no drift when within threshold" do assert :ok = Calibration.detect_drift(:ml_drift, 4.0, threshold: 8.0) end test "flags algorithm drift when current correlation falls below history median by margin" do history_corrs = [0.42, 0.45, 0.43, 0.44] assert {:drift, msg} = Calibration.detect_drift(:algorithm_drift, 0.25, history: history_corrs, margin: 0.10) assert msg =~ "Algorithm correlation dropped" end test "no algorithm drift if history empty" do assert :ok = Calibration.detect_drift(:algorithm_drift, 0.10, history: [], margin: 0.10) end end end