feat(simple_metrics): add adaptive-state hysteresis and recovery-run rules (SIENTIAPDE-2191)

This commit is contained in:
PedroHMCosme
2026-10-07 15:38:12 -03:00
parent f503fd0610
commit cb97f7344f
2 changed files with 102 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ with workflow.unsafe.imports_passed_through():
import time
import traceback
import warnings
from math import ceil
from typing import Any
import pandas as pd
@@ -48,6 +49,48 @@ def threshold_breaches(
return breaches
_MAX_RECOVERY_RUNS = 12
_FREQUENCY_UNIT_SECONDS = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
def frequency_to_seconds(frequency: str) -> int:
"""`'5m'` -> 300. Same `'<int><s|m|h|d>'` format the orchestrator schedules with."""
# ponytail: copy of orchestrator/utils/converters.py:parse_frequency (no shared helper in
# sientia_do), same int() parsing so both repos accept the same strings, plus a > 0 guard;
# move it to sientia_do if a third repo needs it.
unit = _FREQUENCY_UNIT_SECONDS.get(frequency[-1:])
if unit is None:
raise ValueError(f'Invalid frequency: {frequency!r}')
value = int(frequency[:-1])
if value <= 0:
raise ValueError(f'Invalid frequency: {frequency!r}')
return value * unit
def recovery_runs(interval_minutes: int, frequency_alert: str) -> int:
"""
N of the hysteresis rule: healthy runs needed to renew the whole window without a breach,
capped at 12 (SIENTIAPDE-1995 decision 3).
"""
runs = ceil(interval_minutes * 60 / frequency_to_seconds(frequency_alert))
return min(_MAX_RECOVERY_RUNS, max(1, runs))
def next_adaptive_state(state: dict[str, Any], breached: bool, runs: int) -> dict[str, Any]:
"""
Hysteresis rule of the adaptive frequency (SIENTIAPDE-1995): escalate on the first breach,
recover after `runs` consecutive non-breach runs.
"""
if breached:
return {'escalated': True, 'healthy_streak': 0}
if not state.get('escalated'):
return {'escalated': False, 'healthy_streak': 0}
streak = int(state.get('healthy_streak', 0)) + 1
if streak >= runs:
return {'escalated': False, 'healthy_streak': 0}
return {'escalated': True, 'healthy_streak': streak}
class ModelMetrics(SientiaMonitoring):
"""
Metrics and statistical analysis activities for the Laborious pipeline.

View File

@@ -7,7 +7,13 @@ from sientia_do.observability.metrics_controller import MetricsController
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
from sientia_model.analytics.drift_analysis import DriftInsufficientDataError
from laborious.activities.model_metrics import ModelMetrics, threshold_breaches
from laborious.activities.model_metrics import (
ModelMetrics,
frequency_to_seconds,
next_adaptive_state,
recovery_runs,
threshold_breaches,
)
@fixture
@@ -1230,6 +1236,8 @@ def test_to_naive_utc_keeps_naive_timestamps():
([{'metric': 'rmse', 'value': 0.5}], {'rmse_max': 1.0}, 0),
([{'metric': 'r2', 'value': 0.5}], {'r2_min': 0.9}, 1),
([{'metric': 'r2', 'value': 0.95}], {'r2_min': 0.9}, 0),
([{'metric': 'rmse', 'value': 1.0}], {'rmse_max': 1.0}, 0), # equal is not a breach
([{'metric': 'r2', 'value': 0.9}], {'r2_min': 0.9}, 0),
([{'metric': 'mae', 'value': 99.0}], {'rmse_max': 1.0}, 0),
([{'metric': 'rmse', 'value': 99.0}], None, 0),
([], {'rmse_max': 1.0}, 0),
@@ -1242,3 +1250,53 @@ def test_to_naive_utc_keeps_naive_timestamps():
)
def test_threshold_breaches(rows, thresholds, expected_count):
assert len(threshold_breaches(rows, thresholds)) == expected_count
def _state(escalated: bool, streak: int) -> dict:
return {'escalated': escalated, 'healthy_streak': streak}
@mark.parametrize(
'state, breached, runs, expected',
[
({}, True, 3, _state(True, 0)), # first breach escalates
({}, False, 3, _state(False, 0)), # missing state = healthy
(_state(False, 0), False, 3, _state(False, 0)), # healthy stays healthy
(_state(True, 0), False, 3, _state(True, 1)), # healthy run while escalated
(_state(True, 1), False, 3, _state(True, 2)),
(_state(True, 2), False, 3, _state(False, 0)), # N-th healthy run recovers
(_state(True, 2), True, 3, _state(True, 0)), # breach resets the streak
(_state(True, 0), False, 1, _state(False, 0)), # N=1 recovers on first healthy run
],
)
def test_next_adaptive_state(state, breached, runs, expected):
assert next_adaptive_state(state, breached, runs) == expected
@mark.parametrize(
'frequency, seconds',
[('30s', 30), ('5m', 300), ('2h', 7200), ('1d', 86400)],
)
def test_frequency_to_seconds(frequency, seconds):
assert frequency_to_seconds(frequency) == seconds
@mark.parametrize('frequency', ['', '5', 'm', '5x', 'abc', '0m'])
def test_frequency_to_seconds_rejects_invalid(frequency):
with raises(ValueError):
frequency_to_seconds(frequency)
@mark.parametrize(
'interval_minutes, frequency_alert, expected',
[
(60, '5m', 12), # 60/5 = 12, at the cap
(60, '15m', 4),
(60, '7m', 9), # ceil(8.57)
(60, '30s', 12), # 120 capped at 12
(1440, '1h', 12), # 24 capped at 12
(5, '1h', 1), # window shorter than one run: at least 1
],
)
def test_recovery_runs(interval_minutes, frequency_alert, expected):
assert recovery_runs(interval_minutes, frequency_alert) == expected