prop/test/microwaveprop/propagation/calibration_test.exs
Graham McIntire d5da0cec2b
feat(ml): add prop.compare task for algorithm-vs-ML-vs-reality calibration
`mix prop.compare` runs both scorers against a recent contact sample
and measures both against achieved contact distance. Each run appends
to priv/calibration/history.jsonl, so trends in alg-ML divergence and
algorithm-distance correlation surface over weeks of running.

When drift exceeds threshold (alg-ML RMSE > 8 score points, or current
correlation > 0.10 below the history median), the task writes a
recommendation pointing at the right remediation:

    mix recalibrate_scorer    # algorithm drift → refit weights
    mix propagation_train     # ML drift → retrain on the new algorithm

That is the feedback loop: measure → recommend → recalibrate/retrain →
loop. Operational rather than automatic, so a bad data window can't
silently corrupt the model.

Pure analysis lives in Microwaveprop.Propagation.Calibration with its
own unit tests; the Mix task only handles data loading, file I/O, and
console formatting.
2026-04-28 14:38:02 -05:00

141 lines
4.6 KiB
Elixir

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