Skip to content

Commit 7d19fe4

Browse files
gzileniclaude
andcommitted
feat(ml): B1 drift monitoring live — apples-to-apples on canonical features
- MLScoringEngine feeds the model its rain features at inference (bundle rainfall + api_30 → same aggregates as training; they were silently 0.0 before) and distance_to_iffi_m; feature_row() exposes the named vector - ShadowChallengerExecutor persists that vector in breakdown.features so the drift job compares training vs live on identical keys/scales - drift job: monitored feature configurable (MONITORING__DRIFT_FEATURE, default rain.rain_72h_mm), require_features filter in the repo, enabled by default - cache_cleanup purges model_runs older than SCORING__MODEL_RUNS_RETENTION_DAYS (national shadow ≈ 1 GB/day) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a71aefa commit 7d19fe4

8 files changed

Lines changed: 116 additions & 15 deletions

File tree

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,22 @@ LOG_JSON=false
177177
#REPORT__ENABLED=true
178178
#REPORT__HOUR_UTC=6
179179

180+
# -----------------------------------------------------------------------------
181+
# ML drift monitoring (B1) — daily PSI/KS on one canonical model feature,
182+
# training vs the live vectors the shadow persists in model_runs
183+
# (breakdown.features — identical keys and scales by construction).
184+
# Informational: the retrain trigger only logs; promotion stays manual.
185+
# Note: with a case-control training set vs full-grid scoring, PSI on a
186+
# weather feature alarms whenever live weather differs from event-time
187+
# weather — read it as regime info, not as an automatic mandate.
188+
# -----------------------------------------------------------------------------
189+
#MONITORING__ENABLE_DRIFT_MONITORING=true
190+
#MONITORING__DRIFT_FEATURE=rain.rain_72h_mm
191+
#MONITORING__DRIFT_CHECK_HOURS=24
192+
# Shadow writes ~1M rows/day nationally (~1 GB/day): the cleanup job
193+
# purges model_runs older than this. 0 = keep everything.
194+
#SCORING__MODEL_RUNS_RETENTION_DAYS=30
195+
180196
# --- Dispatch rules ---
181197
ALERT__MIN_LEVEL=Moderate # Low | Moderate | High | VeryHigh
182198
ALERT__MIN_STATIC_S=0.5 # below-High cells alert only if S >= this (susceptible slope)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ La stessa interfaccia `CellFeatureBundle` accetta anche il motore ML V2.
8181
| **Workflow MAF (V1)** | AreaResolver → StaticFactors → MeteoFetch → SeismicCheck → FireCheck → \[SensorFetch?\] → RiskScoring → EscalationGate → RiskAnalyst → Briefing → PersistResult → AlertDispatch | one-shot CLI | `agents/` + `limen monitor-once` |
8282
| **Provider LLM** | precedenza `LLM__PROVIDER` > Anthropic > OpenAI > Foundry > Ollama; il resolver salta i provider cloud senza SDK e cade su Ollama (solo httpx). Briefing in italiano; RiskAnalyst restituisce JSON tipizzato. | risolto all'avvio | `agents/llm_factory/resolve_llm_factory` |
8383
| **API HTTP** | `/health` + `/ready`, `POST /api/monitor/{aoi}`, `GET /api/aoi/{id}/risk/latest`, `GET /api/cell/{id}/breakdown`, `GET /api/aoi`, `GET /api/alerts`, `/api/tiles/...`, OpenAPI su `/docs` e `/redoc` | FastAPI / uvicorn | `api/` + `limen serve` |
84-
| **Job periodici** | workflow MAF orario (con shadow ML), **sweep previsionale** ogni 6 h, **nowcast radar DPC** ogni 15 min, report nazionale giornaliero, sync ISPRA settimanale, drift monitor, cache cleanup | APScheduler in-process | `api/jobs/` |
84+
| **Job periodici** | workflow MAF orario (con shadow ML), **sweep previsionale** ogni 6 h, **nowcast radar DPC** ogni 15 min, report nazionale giornaliero, sync ISPRA settimanale, **drift monitor ML** (PSI/KS training-vs-live sulle feature canoniche che lo shadow persiste), cache cleanup + retention di `model_runs` (default 30 gg) | APScheduler in-process | `api/jobs/` |
8585
| **Radar DPC (nowcast)** | SRI nazionale 1 km / 5 min (piattaforma radar DPC, CC-BY-SA): pioggia ≥ `NOWCAST__MIN_INTENSITY_MMH` su una regione ⇒ il workflow di quella AOI parte subito invece di aspettare il tick orario (cooldown 45 min; alert dal percorso operativo normale) | poll ogni `NOWCAST__INTERVAL_MINUTES` | `integrations/dpc/` + `api/jobs/nowcast_monitoring.py` |
8686
| **Forecast previsionale** | scoring a `now+H` ore con pioggia prevista Open-Meteo (osservata+prevista nella stessa finestra); champion + challenger ML sulle stesse celle; report on-demand o alert "PREVISIONE" schedulato con dedup (AOI, orizzonte) | `limen forecast` / job ogni `FORECAST__INTERVAL_HOURS` | `agents/workflows/forecast.py` + `api/jobs/forecast_monitoring.py` |
8787
| **MCP `limen-ops`** | `tool_risk_summary`, `tool_top_risk_cells`, `tool_cell_breakdown`, `tool_recent_alerts`, `tool_national_report`, `tool_run_monitor` (admin, fail-closed su `MCP_ADMIN_TOKEN`) per gateway agentici (OpenClaw, Claude Desktop) | servizio compose `mcp`, HTTP `127.0.0.1:8766/mcp` | `mcp/` + `limen mcp-serve` |

src/limen/agents/executors/shadow_challenger.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ async def run(self, ctx: MonitoringContext) -> MonitoringContext:
4040
model_version = getattr(self._challenger, "model_version", "v1-deterministic")
4141

4242
bundles = assemble_bundles(ctx)
43+
feature_row_fn = getattr(self._challenger, "feature_row", None)
4344
rows: list[ModelRunRow] = []
4445
for bundle in bundles:
4546
scored = self._challenger.score(bundle)
47+
breakdown = scored.breakdown.model_dump(mode="json")
48+
if feature_row_fn is not None:
49+
# Canonical model inputs → drift monitoring compares
50+
# training vs live on identical keys and scales.
51+
breakdown["features"] = feature_row_fn(bundle)
4652
rows.append(
4753
ModelRunRow(
4854
cell_id=bundle.cell_id,
@@ -53,7 +59,7 @@ async def run(self, ctx: MonitoringContext) -> MonitoringContext:
5359
role="challenger",
5460
probability=scored.score,
5561
risk_class=scored.level.value,
56-
breakdown=scored.breakdown.model_dump(mode="json"),
62+
breakdown=breakdown,
5763
)
5864
)
5965

src/limen/api/jobs/cache_cleanup.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,33 @@
1010

1111
from limen.api.dependencies import AppDependencies
1212
from limen.core.logging import get_logger
13+
from limen.data.db import acquire
1314

1415
log = get_logger(__name__)
1516

17+
# Per-tick cap: the job runs every few minutes, so retention deletes in
18+
# small chronological batches (id is bigserial) instead of one huge sweep.
19+
_RETENTION_BATCH = 50_000
20+
21+
22+
async def _purge_old_model_runs(retention_days: int) -> int:
23+
if retention_days <= 0:
24+
return 0
25+
async with acquire() as conn:
26+
tag = await conn.execute(
27+
"""
28+
DELETE FROM model_runs WHERE id IN (
29+
SELECT id FROM model_runs
30+
WHERE computed_at < now() - make_interval(days => $1)
31+
ORDER BY id
32+
LIMIT $2
33+
)
34+
""",
35+
retention_days,
36+
_RETENTION_BATCH,
37+
)
38+
return int(tag.split()[-1])
39+
1640

1741
async def run_cache_cleanup_job(deps: AppDependencies) -> int:
1842
"""Delete expired ``app_cache`` rows; returns the number removed."""
@@ -29,6 +53,15 @@ async def run_cache_cleanup_job(deps: AppDependencies) -> int:
2953
error_type=type(exc).__name__,
3054
)
3155
return 0
32-
if removed:
33-
log.info("job.cache_cleanup.done", removed=removed)
56+
try:
57+
purged = await _purge_old_model_runs(deps.settings.scoring.model_runs_retention_days)
58+
except Exception as exc:
59+
log.error(
60+
"job.cache_cleanup.model_runs_error",
61+
error=str(exc),
62+
error_type=type(exc).__name__,
63+
)
64+
purged = 0
65+
if removed or purged:
66+
log.info("job.cache_cleanup.done", removed=removed, model_runs_purged=purged)
3467
return int(removed)

src/limen/api/jobs/drift_monitor.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,31 @@ async def run_drift_monitor_job(deps: AppDependencies) -> int:
3131
log.info("job.drift.skip_no_training_samples")
3232
return 0
3333

34-
# Use the static.susc_ispra column as the reference univariate
35-
# distribution — cheap to compute and a good proxy for distributional
36-
# change. Production deployments will rotate which feature is
37-
# monitored each cycle.
38-
reference = [
39-
float(s.features.get("static", {}).get("susc_ispra") or 0.0) for s in training_samples
40-
]
34+
# Compare one canonical model feature training-vs-live. The shadow
35+
# persists the exact vector the model consumed under
36+
# breakdown["features"] (same keys and scales as training_samples),
37+
# so the comparison is apples-to-apples by construction.
38+
feature = deps.settings.monitoring.drift_feature
39+
top, _, sub = feature.partition(".")
40+
reference = [float((s.features.get(top) or {}).get(sub) or 0.0) for s in training_samples]
4141
reference_labels = [float(s.label) for s in training_samples]
4242

4343
window_start = datetime.now(UTC) - timedelta(days=7)
44-
recent = await recent_for_role("challenger", since=window_start, limit=10_000)
44+
recent = await recent_for_role(
45+
"challenger", since=window_start, limit=10_000, require_features=True
46+
)
4547
if not recent:
4648
log.info("job.drift.no_recent_challenger_runs")
4749
return 0
4850

4951
candidate = [
50-
float(r.breakdown.get("static_terms", {}).get("susc_ispra") or 0.0) for r in recent
52+
float(r.breakdown["features"][feature])
53+
for r in recent
54+
if feature in (r.breakdown.get("features") or {})
5155
]
56+
if not candidate:
57+
log.info("job.drift.no_feature_rows", feature=feature)
58+
return 0
5259
candidate_probs = [r.probability for r in recent]
5360
report = make_report(
5461
reference=reference,
@@ -63,6 +70,7 @@ async def run_drift_monitor_job(deps: AppDependencies) -> int:
6370
trigger = RetrainingTrigger.from_inputs(drift=report, new_iffi_since_last_train=0)
6471
log.info(
6572
"job.drift.done",
73+
feature=feature,
6674
psi=report.psi,
6775
ks=report.ks,
6876
pred_drift=report.pred_drift,

src/limen/config/settings.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,9 @@ class ScoringSettings(BaseSettings):
315315
mlflow_tracking_uri: str = "sqlite:///mlflow.db"
316316
mlflow_experiment: str = "limen-landslide"
317317
mlflow_registered_model: str = "limen-landslide-ml"
318+
# Shadow rows older than this are purged by the cleanup job — the
319+
# national hourly sweep writes ~1M rows/day (~1 GB/day). 0 = keep all.
320+
model_runs_retention_days: int = Field(default=30, ge=0)
318321
mlflow_model_stage: Literal["Staging", "Production", "Archived"] = "Production"
319322
# Promotion gate — the ML model is blocked from champion until it
320323
# clears these floors on the same backtest the V1 baseline ran on.
@@ -368,7 +371,11 @@ class MonitoringSettings(BaseSettings):
368371
prediction_drift_alert: float = Field(default=0.15, ge=0.0)
369372
# APScheduler cadence — coarse, drift checks aren't a hot path.
370373
drift_check_hours: int = Field(default=24, ge=1)
371-
enable_drift_monitoring: bool = False
374+
enable_drift_monitoring: bool = True
375+
# Canonical feature whose distribution is compared training-vs-live.
376+
# Must exist both in training_samples.features and in the shadow's
377+
# persisted breakdown["features"] (same keys, same scales).
378+
drift_feature: str = "rain.rain_72h_mm"
372379

373380

374381
class GeodataSettings(BaseSettings):

src/limen/core/scoring/ml_engine.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@ def from_registry(
147147
# ------------------------------------------------------------------
148148
# ScoringEngine interface
149149
# ------------------------------------------------------------------
150+
def feature_row(self, bundle: CellFeatureBundle) -> dict[str, float]:
151+
"""Named canonical feature vector — persisted by the shadow so the
152+
drift monitor compares training vs live on identical keys/scales."""
153+
names = self._artefacts.feature_names
154+
if not names:
155+
return {}
156+
row = _bundle_to_feature_row(bundle, names=names)
157+
return dict(zip(names, row, strict=True))
158+
150159
def score(self, bundle: CellFeatureBundle) -> RiskScore:
151160
"""Predict the cell's calibrated probability + component breakdown.
152161
@@ -216,14 +225,28 @@ def _clamp01_scaled(x: float | None, cap: float) -> float:
216225

217226
def _bundle_to_feature_row(bundle: CellFeatureBundle, *, names: list[str]) -> list[float]:
218227
"""Project a bundle onto the ordered feature vector the model expects."""
228+
from limen.ml.rain_features import compute_rain_aggregates
229+
230+
rain = compute_rain_aggregates(
231+
[(s.timestamp, s.precipitation_mm) for s in bundle.dynamic.rainfall.samples],
232+
as_of=bundle.dynamic.valuation_time,
233+
)
219234
flat: dict[str, float] = {
220235
"static.susc_ispra": _clamp01(bundle.static.susc_ispra),
221236
"static.iffi_density_500": float(bundle.static.iffi_density_500 or 0.0),
237+
"static.distance_to_iffi_m": float(bundle.static.distance_to_iffi_m or 0.0),
222238
"static.slope_deg": float(bundle.static.slope_deg or 0.0),
223239
"static.pai_class_norm": _clamp01(bundle.static.pai_class_norm),
224240
"static.litho_weight": _clamp01(bundle.static.litho_weight),
225241
"static.twi": float(bundle.static.twi or 0.0),
226242
"static.curvature": float(bundle.static.curvature or 0.0),
243+
# Same antecedent-rain aggregates the model trained on (CERRA
244+
# replay). The monitoring window is 48 h, so rain_72h is a lower
245+
# bound; the 30-day total comes from the API_30 archive lookup.
246+
"rain.rain_24h_mm": rain["rain_24h_mm"],
247+
"rain.rain_72h_mm": rain["rain_72h_mm"],
248+
"rain.max_i_24h_mmh": rain["max_i_24h_mmh"],
249+
"rain.rain_30d_mm": float(bundle.dynamic.api_30_mm or rain["rain_30d_mm"]),
227250
}
228251
if not names:
229252
# Resolver fallback path: no feature schema → uniform vector,

src/limen/data/repos/model_runs_repo.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,29 @@ async def insert_many(rows: Iterable[ModelRunRow]) -> int:
6565

6666

6767
async def recent_for_role(
68-
role: ModelRole, *, since: datetime, limit: int = 10_000
68+
role: ModelRole,
69+
*,
70+
since: datetime,
71+
limit: int = 10_000,
72+
require_features: bool = False,
6973
) -> list[ModelRunRow]:
74+
"""Newest runs for a role. ``require_features`` keeps only rows whose
75+
breakdown carries the canonical feature vector (drift monitoring)."""
7076
async with acquire() as conn:
7177
rows = await conn.fetch(
7278
"""
7379
SELECT cell_id, valuation_time, aoi_id, model_uri, model_version,
7480
role, probability, risk_class, breakdown
7581
FROM model_runs
7682
WHERE role = $1 AND computed_at >= $2
83+
AND (NOT $4 OR breakdown ? 'features')
7784
ORDER BY computed_at DESC
7885
LIMIT $3
7986
""",
8087
role,
8188
since,
8289
limit,
90+
require_features,
8391
)
8492
return [_to_row(r) for r in rows]
8593

0 commit comments

Comments
 (0)