diff --git a/orchestrator/utils/orchestrator_functions.py b/orchestrator/utils/orchestrator_functions.py index 291573c..c5df001 100644 --- a/orchestrator/utils/orchestrator_functions.py +++ b/orchestrator/utils/orchestrator_functions.py @@ -100,8 +100,16 @@ def simple_metrics(config: dict[str, Any]): # SIENTIAPDE-1995: laborious flips `adaptive_state.escalated` on a threshold breach (and back # after the window is renewed without one) and bumps `updated_at`, which makes this tick # rewrite the schedule. - if frequency_alert and (config.get('adaptive_state') or {}).get('escalated'): - base['frequency'] = frequency_alert + state = config.get('adaptive_state') + # Hand-written docs: a malformed state must not raise and kill the tick for every model. + if frequency_alert and isinstance(state, dict) and state.get('escalated') is True: + try: + # Temporal parses the schedule frequency on create/update; an unparseable value + # would fail it, so keep `frequency` (create_schedule_config still warns). + parse_frequency(str(frequency_alert)) + base['frequency'] = frequency_alert + except ValueError: + pass return { **base, 'schema': 'sientia_data', @@ -121,7 +129,8 @@ MIN_FREQUENCY_ALERT_SECONDS = 60 def frequency_alert_warning(frequency_alert: str | None) -> str | None: """ Warning message when `frequency_alert` is below the 1-minute floor or does not parse, - otherwise None. Warn only: the configured value is kept (SIENTIAPDE-1995 decision 7). + otherwise None. Warn only (SIENTIAPDE-1995 decision 7): a value below the floor is kept; + an unparseable one is ignored by `simple_metrics()`, which keeps `frequency`. """ if not frequency_alert: return None diff --git a/tests/orchestrator/activities/test_formatters.py b/tests/orchestrator/activities/test_formatters.py index 85e190f..4891eb2 100644 --- a/tests/orchestrator/activities/test_formatters.py +++ b/tests/orchestrator/activities/test_formatters.py @@ -1057,6 +1057,18 @@ def test_create_schedule_config_warns_on_frequency_alert_below_floor(formatters) assert kwargs['metadata']['schedule_name'] == 'm1-simple_metrics-60m' +def test_create_schedule_config_warns_on_frequency_alert_below_floor_on_update(formatters): + input_data = _schedule_config_with_frequency_alert('30s') + input_data['current_schedule_config'] = {'laborious': {'m1-simple_metrics-60m': '2026-10-06'}} + + result = formatters.create_schedule_config(input_data) + + assert 'm1-simple_metrics-60m' in result['to_update']['laborious'] + formatters.send_notification.assert_called_once() + kwargs = formatters.send_notification.call_args.kwargs + assert kwargs['notification_id'] == 'SIMPLE_METRICS_FREQUENCY_ALERT_BELOW_FLOOR' + + def test_create_schedule_config_no_warning_at_or_above_floor(formatters): formatters.create_schedule_config(_schedule_config_with_frequency_alert('5m')) diff --git a/tests/orchestrator/utils/test_orchestrator_functions.py b/tests/orchestrator/utils/test_orchestrator_functions.py index b7008e4..45209ee 100644 --- a/tests/orchestrator/utils/test_orchestrator_functions.py +++ b/tests/orchestrator/utils/test_orchestrator_functions.py @@ -158,6 +158,24 @@ def test_simple_metrics_frequency_alert_without_state_keeps_frequency(): assert simple_metrics(_simple_metrics_config(frequency_alert='5m'))['frequency'] == '1h' +def test_simple_metrics_escalated_unparseable_frequency_alert_keeps_frequency(): + # An unparseable frequency would make the Temporal schedule create/update fail. + config = _simple_metrics_config(frequency_alert='5min', adaptive_state={'escalated': True}) + assert simple_metrics(config)['frequency'] == '1h' + + +def test_simple_metrics_escalated_below_floor_frequency_alert_is_applied(): + # Decision 7: below the floor only warns, the value is kept. + config = _simple_metrics_config(frequency_alert='30s', adaptive_state={'escalated': True}) + assert simple_metrics(config)['frequency'] == '30s' + + +@mark.parametrize('adaptive_state', ['yes', {'escalated': 'false'}]) +def test_simple_metrics_malformed_adaptive_state_keeps_frequency(adaptive_state): + config = _simple_metrics_config(frequency_alert='5m', adaptive_state=adaptive_state) + assert simple_metrics(config)['frequency'] == '1h' + + def test_minimal_retrain(): config = { 'workflow_type': 'minimal_retrain',