EC06 attendait une reponse a « comment savez-vous que le modele se degrade ? ». Elle n'existait nulle part : `docs/architecture/00-vue-ensemble.md` et `docs/ML-START.md` le disaient tous les deux. Le calcul vit dans le backend, et `ml/` ne gagne pas une ligne. Trois raisons : `prediction` n'est pas dans le perimetre de lecture que `ML_DATABASE_URL` vise (ADR 0003 et ML-START le bornent a `reading` et `site`) ; l'alignement prevu contre realise existe deja une fois ici, dans `AlertService._detect_anomaly`, et le dupliquer en SQL brut creerait une seconde source de verite, ce que l'ADR 0006 refuse ; et FastAPI continue de ne jamais faire tourner LightGBM. Ce qui est mesure : la jointure `prediction` x `reading` sur `(site_id, target_at)`, avec un `DISTINCT ON` des deux cotes. Les runs de scoring s'empilent volontairement, et `uq_reading_source` autorise deux lectures au meme instant quand la source differe : sans ce dedoublonnage, la meme heure pesait plusieurs fois dans la moyenne. La fenetre est fermee a droite par un delai de grace, sinon la derniere heure, dont le realise n'est pas encore ingere, ferait chuter la couverture a chaque execution. Le verdict a trois valeurs, pas deux : avec trois points on ne declare pas une derive, on dit qu'on ne sait pas. La comparaison se fait entre deux fenetres vives de meme duree, jamais contre la metrique loguee a l'entrainement : celle-ci mesure un backtest a meteo connue, le scoring prevoit une heure dont la meteo ne l'est pas. `drift_report` porte une ligne par site plus une ligne globale, que `site_id` a NULL designe. L'idempotence passe par un index a `coalesce` et non par une contrainte d'unicite, sans quoi deux lignes globales ne seraient jamais egales.
109 lines
3.2 KiB
Python
109 lines
3.2 KiB
Python
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.monitoring import drift as cli
|
|
from app.repositories.drift import NouveauRapportDerive
|
|
from app.services.drift import STATUT_DERIVE, STATUT_STABLE, Seuils
|
|
|
|
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
|
|
|
|
|
|
def rapport(*, site_id: str | None, status: str, reason: str | None = None) -> NouveauRapportDerive:
|
|
return NouveauRapportDerive(
|
|
site_id=site_id,
|
|
window_start=INSTANT - timedelta(hours=168),
|
|
window_end=INSTANT,
|
|
reference_start=None,
|
|
reference_end=None,
|
|
n_observations=48,
|
|
mae=1.5,
|
|
mape=12.0,
|
|
bias=0.3,
|
|
reference_mae=1.2,
|
|
coverage_ratio=1.0,
|
|
insufficient_data_ratio=0.0,
|
|
model_references=["lightgbm-aaa"],
|
|
status=status,
|
|
reason=reason,
|
|
)
|
|
|
|
|
|
def installe(monkeypatch: pytest.MonkeyPatch, rapports: list[NouveauRapportDerive]) -> None:
|
|
async def fausse_execution(
|
|
*, now: datetime | None, site_id: str | None, seuils: Seuils | None
|
|
) -> list[NouveauRapportDerive]:
|
|
return rapports
|
|
|
|
monkeypatch.setattr(cli, "run_drift", fausse_execution)
|
|
|
|
|
|
def test_parse_args_defaults_to_the_standard_window() -> None:
|
|
arguments = cli.parse_args([])
|
|
|
|
assert arguments.window_hours == 168
|
|
assert arguments.grace_hours == 2
|
|
assert arguments.fail_on_drift is False
|
|
|
|
|
|
def test_parse_args_reads_the_site_id() -> None:
|
|
assert cli.parse_args(["--site-id", "SITE001"]).site_id == "SITE001"
|
|
|
|
|
|
def test_parse_args_parses_the_instant_option() -> None:
|
|
arguments = cli.parse_args(["--now", "2026-09-22T12:00:00+00:00"])
|
|
|
|
assert arguments.now == INSTANT
|
|
|
|
|
|
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
|
|
assert cli._parse_instant("2026-09-22T12:00:00") == INSTANT
|
|
|
|
|
|
def test_seuils_depuis_translates_the_hour_options_into_durations() -> None:
|
|
seuils = cli.seuils_depuis(cli.parse_args(["--window-hours", "24", "--grace-hours", "1"]))
|
|
|
|
assert seuils.fenetre == timedelta(hours=24)
|
|
assert seuils.grace == timedelta(hours=1)
|
|
|
|
|
|
def test_main_prints_the_verdict_of_every_line(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
installe(
|
|
monkeypatch,
|
|
[
|
|
rapport(site_id="SITE001", status=STATUT_STABLE),
|
|
rapport(site_id=None, status=STATUT_STABLE),
|
|
],
|
|
)
|
|
|
|
code = cli.main([])
|
|
|
|
sortie = capsys.readouterr().out
|
|
assert code == 0
|
|
assert "SITE001" in sortie
|
|
assert "TOUS SITES" in sortie
|
|
|
|
|
|
def test_main_exits_non_zero_when_drift_is_detected_and_the_flag_is_set(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
|
|
|
|
code = cli.main(["--fail-on-drift"])
|
|
|
|
assert code == 1
|
|
assert "MAE doublée" in capsys.readouterr().out
|
|
|
|
|
|
def test_main_exits_zero_when_drift_is_detected_without_the_flag(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
|
|
|
|
code = cli.main([])
|
|
|
|
assert code == 0
|
|
assert capsys.readouterr().out != ""
|