Merge pull request #59 from Aignosi/feature/SIENTIAPDE-2191-adaptive-state
feat(simple_metrics): adaptive state on the pipelines doc (SIENTIAPDE-2191)
This commit is contained in:
@@ -489,14 +489,16 @@ The **SimpleMetrics** workflow calculates regression metrics (RMSE, MSE, MAE, R
|
|||||||
1. **Data Loading**: Loads prediction vs target data via a JOIN query
|
1. **Data Loading**: Loads prediction vs target data via a JOIN query
|
||||||
2. **Metrics Calculation**: Calculates configured regression metrics; alerts on `simple_metrics_thresholds` breaches (one alert per model, the message names the window) unless the window has fewer rows than `simple_metrics_min_samples`
|
2. **Metrics Calculation**: Calculates configured regression metrics; alerts on `simple_metrics_thresholds` breaches (one alert per model, the message names the window) unless the window has fewer rows than `simple_metrics_min_samples`
|
||||||
3. **Data Export**: Exports metrics to PostgreSQL
|
3. **Data Export**: Exports metrics to PostgreSQL
|
||||||
|
4. **Adaptive State**: when the schedule's `pipelines` doc has `frequency_alert`, escalates `adaptive_state` on a breach and recovers after the window is renewed without one (`min(12, ceil(interval_minutes / frequency_alert))` runs, at least 1; empty, under-sampled or threshold-less windows count as healthy). The orchestrator runs the schedule at `frequency_alert` while escalated. Runs on every schedule (it reads the `pipelines` doc to check for `frequency_alert`), so a MongoDB failure here fails the run; the metrics are already exported by then
|
||||||
|
|
||||||
#### Architecture Diagram
|
#### Architecture Diagram
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A[1. load_custom_query] --> B[2. calculate_simple_metrics] --> C[3. export_data_to_postgres]
|
A[1. load_custom_query] --> B[2. calculate_simple_metrics] --> C[3. export_data_to_postgres] --> D[4. update_simple_metrics_adaptive_state]
|
||||||
|
|
||||||
A -.-> Database[(Database)]
|
A -.-> Database[(Database)]
|
||||||
C -.-> PostgreSQL[(PostgreSQL)]
|
C -.-> PostgreSQL[(PostgreSQL)]
|
||||||
|
D -.-> MongoDB[(MongoDB pipelines)]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Input Parameters
|
#### Input Parameters
|
||||||
@@ -527,7 +529,7 @@ flowchart LR
|
|||||||
- PostgreSQL database
|
- PostgreSQL database
|
||||||
- MLFlow server
|
- MLFlow server
|
||||||
- MinIO object storage (for MLFlow artifacts)
|
- MinIO object storage (for MLFlow artifacts)
|
||||||
- MongoDB server (for notifications)
|
- MongoDB server (notifications, prediction metadata, and read/write on the orchestrator's `pipelines` collection for the simple-metrics adaptive state)
|
||||||
- OPC server(s) if using OPC export
|
- OPC server(s) if using OPC export
|
||||||
- PI Web API server if using PI Web API export
|
- PI Web API server if using PI Web API export
|
||||||
|
|
||||||
@@ -903,7 +905,7 @@ See [OPC UA Communication](#opc-ua-communication) for semantics, concurrency, an
|
|||||||
| `MONGODB_URL` | MongoDB connection URI | `localhost:27018` | Yes |
|
| `MONGODB_URL` | MongoDB connection URI | `localhost:27018` | Yes |
|
||||||
| `MONGODB_USERNAME` | MongoDB username | `root` | Yes |
|
| `MONGODB_USERNAME` | MongoDB username | `root` | Yes |
|
||||||
| `MONGODB_PASSWORD` | MongoDB password | `wKZDbMNU1c` | Yes |
|
| `MONGODB_PASSWORD` | MongoDB password | `wKZDbMNU1c` | Yes |
|
||||||
| `MONGODB_DATABASE_NAME` | MongoDB database name | `sientia` | Yes |
|
| `MONGODB_DATABASE_NAME` | MongoDB database name; must be the same database as the orchestrator's `pipelines` | `sientia` | Yes |
|
||||||
| `MONGODB_TTL_INDEX_HOURS` | MongoDB TTL index hours | `1` | No |
|
| `MONGODB_TTL_INDEX_HOURS` | MongoDB TTL index hours | `1` | No |
|
||||||
| `LOG_LEVEL` | Application log level | `INFO` | No |
|
| `LOG_LEVEL` | Application log level | `INFO` | No |
|
||||||
| `PROJECT_NAME` | Project name for metrics | `laborious` | No |
|
| `PROJECT_NAME` | Project name for metrics | `laborious` | No |
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ def _simple_metrics_worker_activity_list(test_activities: Activities):
|
|||||||
test_activities.load_custom_query,
|
test_activities.load_custom_query,
|
||||||
test_activities.calculate_simple_metrics,
|
test_activities.calculate_simple_metrics,
|
||||||
test_activities.export_data_to_postgres,
|
test_activities.export_data_to_postgres,
|
||||||
|
test_activities.update_simple_metrics_adaptive_state,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -413,6 +413,15 @@ four metrics with closed-form expected values.
|
|||||||
#### S.1.2 Subset metrics
|
#### S.1.2 Subset metrics
|
||||||
**Summary**: Requesting `metrics=['rmse']` writes only the rmse row.
|
**Summary**: Requesting `metrics=['rmse']` writes only the rmse row.
|
||||||
|
|
||||||
|
#### S.1.3 Threshold breach escalates the adaptive state
|
||||||
|
**Summary**: With `simple_metrics_thresholds` breached and a `pipelines` doc
|
||||||
|
(same `schedule_name`) carrying `frequency_alert`, the workflow sets
|
||||||
|
`adaptive_state={escalated: true, healthy_streak: 0}` and stamps `updated_at`.
|
||||||
|
|
||||||
|
**Expected Outcome**:
|
||||||
|
- `adaptive_state` written on the `pipelines` doc.
|
||||||
|
- `updated_at` is a BSON datetime (the orchestrator calls `.replace(tzinfo=UTC)` on it).
|
||||||
|
|
||||||
### 6.2 Edge cases
|
### 6.2 Edge cases
|
||||||
|
|
||||||
#### S.2.1 Zero-variance target returns r2=0
|
#### S.2.1 Zero-variance target returns r2=0
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
import pytest
|
import pytest
|
||||||
|
from pymongo import MongoClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from temporalio.testing import WorkflowEnvironment
|
from temporalio.testing import WorkflowEnvironment
|
||||||
from temporalio.worker import Worker
|
from temporalio.worker import Worker
|
||||||
@@ -332,3 +333,54 @@ async def test_simple_metrics_no_overlapping_data_short_circuits(
|
|||||||
{'m': str(model_id)},
|
{'m': str(model_id)},
|
||||||
).scalar()
|
).scalar()
|
||||||
assert count == 0, 'Empty target data must short-circuit and skip persistence'
|
assert count == 0, 'Empty target data must short-circuit and skip persistence'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_simple_metrics_breach_escalates_adaptive_state(
|
||||||
|
temporal_test_env: WorkflowEnvironment,
|
||||||
|
temporal_worker_simple_metrics: Worker,
|
||||||
|
test_activities: Activities,
|
||||||
|
postgres_engine,
|
||||||
|
mongo_container,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Scenario S.1.3: a threshold breach on a schedule whose pipelines doc has
|
||||||
|
``frequency_alert`` escalates ``adaptive_state`` and stamps ``updated_at``
|
||||||
|
as a BSON datetime (the type the orchestrator reads).
|
||||||
|
"""
|
||||||
|
model_id = 541
|
||||||
|
schedule_name = 'test-simple-metrics-adaptive'
|
||||||
|
_seed_predictions_and_targets(
|
||||||
|
postgres_engine, model_id=model_id, pairs=[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
mongo = MongoClient(f'mongodb://localhost:{mongo_container.get_exposed_port(27017)}')
|
||||||
|
pipelines = mongo['test_db']['pipelines']
|
||||||
|
try:
|
||||||
|
pipelines.delete_many({'schedule_name': schedule_name})
|
||||||
|
pipelines.insert_one(
|
||||||
|
{'schedule_name': schedule_name, 'frequency': '1h', 'frequency_alert': '5m'}
|
||||||
|
)
|
||||||
|
|
||||||
|
input_data = _simple_metrics_input(
|
||||||
|
model_id,
|
||||||
|
schedule_name=schedule_name,
|
||||||
|
model_config={
|
||||||
|
'target': 'sensor_target',
|
||||||
|
'simple_metrics_thresholds': {'rmse_max': 0.01},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_test_env.client,
|
||||||
|
SimpleMetrics.run,
|
||||||
|
input_data,
|
||||||
|
make_workflow_id('test-simple-metrics-adaptive'),
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = pipelines.find_one({'schedule_name': schedule_name})
|
||||||
|
finally:
|
||||||
|
mongo.close()
|
||||||
|
|
||||||
|
assert doc['adaptive_state'] == {'escalated': True, 'healthy_streak': 0}
|
||||||
|
assert isinstance(doc['updated_at'], datetime)
|
||||||
|
|||||||
@@ -16,22 +16,22 @@ with workflow.unsafe.imports_passed_through():
|
|||||||
from laborious.activities.gates import Gates
|
from laborious.activities.gates import Gates
|
||||||
from laborious.activities.mlflow import MLFlow
|
from laborious.activities.mlflow import MLFlow
|
||||||
from laborious.activities.model_metrics import ModelMetrics
|
from laborious.activities.model_metrics import ModelMetrics
|
||||||
|
from laborious.activities.mongo import MongoActivities
|
||||||
from laborious.activities.opc import OPC
|
from laborious.activities.opc import OPC
|
||||||
from laborious.activities.prediction_metadata import PredictionMetadata
|
|
||||||
from laborious.activities.storage import Storage
|
from laborious.activities.storage import Storage
|
||||||
from laborious.utils.connectors_config import build_mlflow_config
|
from laborious.utils.connectors_config import build_mlflow_config
|
||||||
|
|
||||||
|
|
||||||
class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API, PredictionMetadata):
|
class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API, MongoActivities):
|
||||||
"""
|
"""
|
||||||
Central orchestrator for all Temporal activities used by Laborious workflows.
|
Central orchestrator for all Temporal activities used by Laborious workflows.
|
||||||
|
|
||||||
Composes Storage (Postgres + MinIO offload), MLFlow (wrapper-based inference and retrain
|
Composes Storage (Postgres + MinIO offload), MLFlow (wrapper-based inference and retrain
|
||||||
via ``SientiaMLflowRepository``), Gates (data quality and ML response filters), OPC exports,
|
via ``SientiaMLflowRepository``), Gates (data quality and ML response filters), OPC exports,
|
||||||
drift/simple metrics, PI Web API writes, and PredictionMetadata (the single write point for
|
drift/simple metrics, PI Web API writes, and MongoActivities (the single write point for
|
||||||
gate metadata — see `laborious.activities.prediction_metadata`). The worker constructs one
|
gate metadata and the simple metrics adaptive state — see `laborious.activities.mongo`).
|
||||||
``Activities`` instance per process and registers its callables on multiple workers bound to
|
The worker constructs one ``Activities`` instance per process and registers its callables on
|
||||||
different task queues.
|
multiple workers bound to different task queues.
|
||||||
|
|
||||||
MLflow connectivity: unless ``mlflow_repository`` is injected (tests only), this class builds
|
MLflow connectivity: unless ``mlflow_repository`` is injected (tests only), this class builds
|
||||||
``SientiaMLflowRepository`` from ``build_mlflow_config()`` so tracking credentials and URL
|
``SientiaMLflowRepository`` from ``build_mlflow_config()`` so tracking credentials and URL
|
||||||
@@ -73,7 +73,7 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API, PredictionMetad
|
|||||||
- metrics_controller: Optional shared controller; if ``None``, a new one is created.
|
- metrics_controller: Optional shared controller; if ``None``, a new one is created.
|
||||||
- mlflow_repository: Optional ``SientiaMLflowRepository`` for unit/e2e tests; in production
|
- mlflow_repository: Optional ``SientiaMLflowRepository`` for unit/e2e tests; in production
|
||||||
leave unset so the repository is built from environment via ``build_mlflow_config()``.
|
leave unset so the repository is built from environment via ``build_mlflow_config()``.
|
||||||
- mongo_repository: Optional ``MongoDBRepository`` (prediction metadata, best-effort) for
|
- mongo_repository: Optional ``MongoDBRepository`` (prediction metadata, adaptive state) for
|
||||||
unit/e2e tests; in production leave unset so the repository is built from environment
|
unit/e2e tests; in production leave unset so the repository is built from environment
|
||||||
via ``build_mongodb_config()``, same pattern as ``mlflow_repository`` above.
|
via ``build_mongodb_config()``, same pattern as ``mlflow_repository`` above.
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API, PredictionMetad
|
|||||||
metrics_controller=mc,
|
metrics_controller=mc,
|
||||||
)
|
)
|
||||||
|
|
||||||
PredictionMetadata.__init__(
|
MongoActivities.__init__(
|
||||||
self,
|
self,
|
||||||
mongo_repository=mongo_repository,
|
mongo_repository=mongo_repository,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
@@ -202,4 +202,4 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API, PredictionMetad
|
|||||||
OPC.close(self)
|
OPC.close(self)
|
||||||
ModelMetrics.close(self)
|
ModelMetrics.close(self)
|
||||||
API.close(self)
|
API.close(self)
|
||||||
PredictionMetadata.close(self)
|
MongoActivities.close(self)
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class Gates(SientiaMonitoring):
|
|||||||
5. Handles errors gracefully with notification integration
|
5. Handles errors gracefully with notification integration
|
||||||
6. Builds a metadata document describing which filter(s) triggered, with their policy
|
6. Builds a metadata document describing which filter(s) triggered, with their policy
|
||||||
and a counter, whenever one does — this activity only computes it, it never writes it
|
and a counter, whenever one does — this activity only computes it, it never writes it
|
||||||
(see `PredictionMetadata.write_prediction_metadata` for the actual write)
|
(see `MongoActivities.write_prediction_metadata` for the actual write)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data: Configuration and data for input validation
|
input_data: Configuration and data for input validation
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ with workflow.unsafe.imports_passed_through():
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
import warnings
|
||||||
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -26,6 +27,73 @@ warnings.filterwarnings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def threshold_breaches(
|
||||||
|
rows: list[dict[str, Any]], thresholds: dict[str, float] | None
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Breach messages for metric rows outside their configured thresholds.
|
||||||
|
|
||||||
|
Convention: `<metric>_max` breaches when value > threshold, `<metric>_min` when value <
|
||||||
|
threshold. Shared by the breach alert and the adaptive-state rule so both agree on what a
|
||||||
|
breach is.
|
||||||
|
"""
|
||||||
|
thresholds = thresholds or {}
|
||||||
|
breaches = []
|
||||||
|
for row in rows:
|
||||||
|
metric_name, value = row['metric'], row['value']
|
||||||
|
max_key, min_key = f'{metric_name}_max', f'{metric_name}_min'
|
||||||
|
if max_key in thresholds and value > thresholds[max_key]:
|
||||||
|
breaches.append(f'{metric_name}={value} exceeds {max_key}={thresholds[max_key]}')
|
||||||
|
elif min_key in thresholds and value < thresholds[min_key]:
|
||||||
|
breaches.append(f'{metric_name}={value} below {min_key}={thresholds[min_key]}')
|
||||||
|
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}
|
||||||
|
try:
|
||||||
|
streak = int(state.get('healthy_streak', 0)) + 1
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
streak = 1 # hand-edited streak (null, text, inf): restart the count, stay escalated
|
||||||
|
if streak >= runs:
|
||||||
|
return {'escalated': False, 'healthy_streak': 0}
|
||||||
|
return {'escalated': True, 'healthy_streak': streak}
|
||||||
|
|
||||||
|
|
||||||
class ModelMetrics(SientiaMonitoring):
|
class ModelMetrics(SientiaMonitoring):
|
||||||
"""
|
"""
|
||||||
Metrics and statistical analysis activities for the Laborious pipeline.
|
Metrics and statistical analysis activities for the Laborious pipeline.
|
||||||
@@ -430,37 +498,21 @@ class ModelMetrics(SientiaMonitoring):
|
|||||||
self._debug_dataframe(f'Simple metrics dataframe: Size {data.shape}', data, metadata)
|
self._debug_dataframe(f'Simple metrics dataframe: Size {data.shape}', data, metadata)
|
||||||
|
|
||||||
# Threshold alerting (optional — no crash when absent)
|
# Threshold alerting (optional — no crash when absent)
|
||||||
thresholds = input_data.get('thresholds')
|
for breach_msg in threshold_breaches(output_data, input_data.get('thresholds')):
|
||||||
if thresholds:
|
message = (
|
||||||
# Convention: _max thresholds breach when value > threshold,
|
f'Threshold breach for model {model_id} '
|
||||||
# _min thresholds breach when value < threshold.
|
f'(window {interval_minutes} min): {breach_msg}'
|
||||||
for row in output_data:
|
)
|
||||||
metric_name = row['metric']
|
self.warning(message, metadata)
|
||||||
value = row['value']
|
self.send_notification(
|
||||||
max_key = f'{metric_name}_max'
|
# Model-level trigger: the orchestrator dedups alerts on
|
||||||
min_key = f'{metric_name}_min'
|
# `<trigger>:<notification_id>`, so one breach seen by several windows
|
||||||
|
# (one schedule each) becomes one alert (SIENTIAPDE-1995).
|
||||||
breach_msg = None
|
metadata={**metadata, 'schedule_name': f'{model_id}-simple_metrics'},
|
||||||
if max_key in thresholds and value > thresholds[max_key]:
|
notification_id='SIMPLE_METRICS_THRESHOLD_BREACH',
|
||||||
breach_msg = f'{metric_name}={value} exceeds {max_key}={thresholds[max_key]}'
|
message=message,
|
||||||
elif min_key in thresholds and value < thresholds[min_key]:
|
block='model_metrics',
|
||||||
breach_msg = f'{metric_name}={value} below {min_key}={thresholds[min_key]}'
|
level=NotificationLevel.WARNING,
|
||||||
|
)
|
||||||
if breach_msg:
|
|
||||||
message = (
|
|
||||||
f'Threshold breach for model {model_id} '
|
|
||||||
f'(window {interval_minutes} min): {breach_msg}'
|
|
||||||
)
|
|
||||||
self.warning(message, metadata)
|
|
||||||
self.send_notification(
|
|
||||||
# Model-level trigger: the orchestrator dedups alerts on
|
|
||||||
# `<trigger>:<notification_id>`, so one breach seen by several windows
|
|
||||||
# (one schedule each) becomes one alert (SIENTIAPDE-1995).
|
|
||||||
metadata={**metadata, 'schedule_name': f'{model_id}-simple_metrics'},
|
|
||||||
notification_id='SIMPLE_METRICS_THRESHOLD_BREACH',
|
|
||||||
message=message,
|
|
||||||
block='model_metrics',
|
|
||||||
level=NotificationLevel.WARNING,
|
|
||||||
)
|
|
||||||
|
|
||||||
return data.to_dict(orient='records')
|
return data.to_dict(orient='records')
|
||||||
|
|||||||
227
laborious/activities/mongo.py
Normal file
227
laborious/activities/mongo.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
from temporalio import activity, workflow
|
||||||
|
|
||||||
|
with workflow.unsafe.imports_passed_through():
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||||
|
from sientia_do.observability.logger import Logger
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
from sientia_do.repository.mongodb_repository_sync import MongoDBRepository
|
||||||
|
from sientia_do.temporal.constants import now
|
||||||
|
|
||||||
|
from laborious.activities.model_metrics import (
|
||||||
|
next_adaptive_state,
|
||||||
|
recovery_runs,
|
||||||
|
threshold_breaches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MongoActivities(SientiaMonitoring):
|
||||||
|
"""
|
||||||
|
Activities that read from or write to MongoDB, sharing one `MongoDBRepository`.
|
||||||
|
|
||||||
|
- `write_prediction_metadata`: centralized, best-effort writer for prediction gate metadata
|
||||||
|
(triggered filter, policy, counter). Gates only compute and return a metadata document;
|
||||||
|
they never write it themselves. Keeping the write here, instead of in each gate, puts the
|
||||||
|
one real write point in the subworkflow that's about to persist a `predictions` row — the
|
||||||
|
only place that reliably knows whether one is being written at all — and lets other
|
||||||
|
metadata sources reuse the same activity instead of duplicating write logic wherever they
|
||||||
|
originate.
|
||||||
|
- `update_simple_metrics_adaptive_state`: adaptive frequency state on the `pipelines` doc of
|
||||||
|
a simple metrics schedule (SIENTIAPDE-1995).
|
||||||
|
"""
|
||||||
|
|
||||||
|
mongo_repository: MongoDBRepository | None = None
|
||||||
|
_PREDICTION_METADATA_COLLECTION = 'prediction_metadata'
|
||||||
|
_PIPELINES_COLLECTION = 'pipelines'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mongo_repository: MongoDBRepository | None = None,
|
||||||
|
logger: Logger | None = None,
|
||||||
|
notification_handler: NotificationHandler | None = None,
|
||||||
|
metrics_controller: MetricsController | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the Mongo activities.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mongo_repository: MongoDB, where gate metadata (triggered filter, policy, counter) is
|
||||||
|
written best-effort and the simple metrics adaptive state is kept. `None` is a
|
||||||
|
valid state: both activities become no-ops.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If BaseActivity initialization fails
|
||||||
|
"""
|
||||||
|
self.mongo_repository = mongo_repository
|
||||||
|
SientiaMonitoring.__init__(
|
||||||
|
self,
|
||||||
|
logger=logger,
|
||||||
|
notification_handler=notification_handler,
|
||||||
|
metrics_controller=metrics_controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Close the Mongo repository and clean up resources.
|
||||||
|
"""
|
||||||
|
if self.mongo_repository is not None:
|
||||||
|
try:
|
||||||
|
self.mongo_repository.close()
|
||||||
|
finally:
|
||||||
|
self.mongo_repository = None
|
||||||
|
SientiaMonitoring.shutdown(self)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
@activity.defn(name='write_prediction_metadata')
|
||||||
|
def write_prediction_metadata(self, input_data: dict[str, Any]) -> str | None:
|
||||||
|
"""
|
||||||
|
Best-effort write of one or more gate metadata documents to Mongo, combined into a single
|
||||||
|
`prediction_metadata` document.
|
||||||
|
|
||||||
|
Implements both business rules for this feature in one place:
|
||||||
|
- The prediction write must never be blocked or failed by a metadata write. A failed
|
||||||
|
`insert(...)` is swallowed, never re-raised, logged here with call-site context (which
|
||||||
|
`mongo_repository.insert(...)` itself can't know) — matching how `storage.py`'s
|
||||||
|
`cleanup_minio_objects_expired` also logs again at the call site even though the
|
||||||
|
repository already logs internally and emits its own Prometheus metric
|
||||||
|
(`MONGODB_WRITE_ERROR_COUNT`); the generic library log is for the metric/trace, this
|
||||||
|
one is for whoever is reading logs for this specific call.
|
||||||
|
- Nothing is written when there's nothing relevant to report. An empty/missing
|
||||||
|
`documents` list (or one full of falsy/empty entries) skips Mongo entirely, so callers
|
||||||
|
can call this unconditionally and get `None` back for "no metadata this time" instead
|
||||||
|
of deciding upfront whether there's something worth saving.
|
||||||
|
|
||||||
|
Takes a *list* of documents, not a single one: each caller contributes its own document
|
||||||
|
(shaped like `{'filters': [...]}`), and this flattens every `filters` entry across all of
|
||||||
|
them into one `prediction_metadata` document before the single insert. Today only one
|
||||||
|
gate ever contributes a document per prediction cycle (the gate chain short-circuits on
|
||||||
|
the first non-`None` `path_flag`), but the list shape means this activity won't need to
|
||||||
|
change again once more than one source contributes metadata for the same cycle.
|
||||||
|
|
||||||
|
`insert(...)` doesn't hand back the inserted document's `_id`, so it is generated here,
|
||||||
|
client-side, before the insert, as a plain string (not a BSON ObjectId) — matching this
|
||||||
|
codebase's convention of string `_id`s (e.g. the `models` collection) and the 24-hex-char
|
||||||
|
size of the `predictions.metadata_id` Postgres column that will reference it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Dict with:
|
||||||
|
- documents (list[dict[str, Any]]): Metadata payloads to combine, each shaped
|
||||||
|
like `{'filters': [...]}`. Missing/empty, or entries with no `filters`, skip
|
||||||
|
the write entirely.
|
||||||
|
- metadata (dict): Workflow metadata for contextual logging.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
The generated id, or `None` when there's nothing to write, Mongo isn't configured, or
|
||||||
|
the insert failed.
|
||||||
|
"""
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
documents = input_data.get('documents') or []
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
entry for document in documents if document for entry in document.get('filters', [])
|
||||||
|
]
|
||||||
|
|
||||||
|
if not filters or self.mongo_repository is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
generated_id = str(ObjectId())
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.mongo_repository.insert(
|
||||||
|
self._PREDICTION_METADATA_COLLECTION,
|
||||||
|
{'filters': filters, '_id': generated_id},
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.error(f'Failed to write prediction metadata: {e}', metadata)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return generated_id
|
||||||
|
|
||||||
|
@activity.defn(name='update_simple_metrics_adaptive_state')
|
||||||
|
def update_simple_metrics_adaptive_state(self, input_data: dict[str, Any]) -> dict | None:
|
||||||
|
"""
|
||||||
|
Update the adaptive frequency state on the schedule's `pipelines` doc.
|
||||||
|
|
||||||
|
The orchestrator derives the schedule frequency from `adaptive_state.escalated` and
|
||||||
|
`frequency_alert`. State is read from Mongo, not from the workflow input, because the
|
||||||
|
input only refreshes on the orchestrator tick and would be stale while escalated.
|
||||||
|
`updated_at` is bumped only when `escalated` flips: that is what makes the orchestrator
|
||||||
|
rewrite the schedule, and a streak change alone does not change the frequency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: Dict with:
|
||||||
|
- metadata (dict): Workflow metadata; `schedule_name` identifies the doc.
|
||||||
|
- simple_metrics (list[dict]): Rows from `calculate_simple_metrics`; empty for an
|
||||||
|
empty or under-sampled window, which counts as a non-breach run.
|
||||||
|
- thresholds (dict): `simple_metrics_thresholds` from `model_config` (may be None: no breach).
|
||||||
|
- interval_minutes (int): Window size, used to compute the recovery runs.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
The new state, or `None` when adaptive mode is off, Mongo is not configured, the doc
|
||||||
|
is missing or `frequency_alert` is invalid.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Mongo read/write failures, so the workflow run fails explicitly.
|
||||||
|
"""
|
||||||
|
metadata = input_data['metadata']
|
||||||
|
if self.mongo_repository is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mongo errors propagate on purpose: the run must fail explicitly so the problem shows
|
||||||
|
# up in the Temporal metrics (PR #59 review). Metrics were already exported by then.
|
||||||
|
query = {'schedule_name': metadata['schedule_name']}
|
||||||
|
pipeline = self.mongo_repository.find_one(self._PIPELINES_COLLECTION, query, metadata)
|
||||||
|
if not pipeline:
|
||||||
|
# Likely misconfig: MONGODB_DATABASE_NAME differs from the orchestrator's, or the
|
||||||
|
# schedule_name does not match its doc.
|
||||||
|
self.warning(
|
||||||
|
f'No pipelines doc for schedule {metadata["schedule_name"]}: '
|
||||||
|
'adaptive state skipped',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
frequency_alert = pipeline.get('frequency_alert')
|
||||||
|
if not frequency_alert:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
runs = recovery_runs(input_data['interval_minutes'], str(frequency_alert))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Config error, not a failure: the orchestrator ignores an invalid `frequency_alert`
|
||||||
|
# too (keeps `frequency`) and already sends SIMPLE_METRICS_FREQUENCY_ALERT_BELOW_FLOOR
|
||||||
|
# for it. `interval_minutes` is untyped Mongo config as well (e.g. "60" or null).
|
||||||
|
self.warning(
|
||||||
|
f'Invalid frequency_alert={frequency_alert!r} or '
|
||||||
|
f'interval_minutes={input_data["interval_minutes"]!r}: adaptive state skipped',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
breached = bool(
|
||||||
|
threshold_breaches(input_data['simple_metrics'], input_data.get('thresholds'))
|
||||||
|
)
|
||||||
|
state = pipeline.get('adaptive_state')
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
# Missing or hand-edited garbage: not escalated, same as the orchestrator reads it.
|
||||||
|
state = {'escalated': False, 'healthy_streak': 0}
|
||||||
|
new_state = next_adaptive_state(state, breached, runs)
|
||||||
|
if new_state == state:
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
update: dict[str, Any] = {'adaptive_state': new_state}
|
||||||
|
if new_state['escalated'] != bool(state.get('escalated')):
|
||||||
|
# BSON datetime, never a string: the orchestrator normalizes `updated_at` with
|
||||||
|
# `.replace(tzinfo=UTC).strftime(...)` and a string would fail its whole tick.
|
||||||
|
update['updated_at'] = now()
|
||||||
|
self.mongo_repository.update_one(
|
||||||
|
self._PIPELINES_COLLECTION, query, {'$set': update}, metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info(f'Adaptive state of {query["schedule_name"]}: {state} -> {new_state}', metadata)
|
||||||
|
return new_state
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
from temporalio import activity, workflow
|
|
||||||
|
|
||||||
with workflow.unsafe.imports_passed_through():
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from bson import ObjectId
|
|
||||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
|
||||||
from sientia_do.observability.logger import Logger
|
|
||||||
from sientia_do.observability.metrics_controller import MetricsController
|
|
||||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
|
||||||
from sientia_do.repository.mongodb_repository_sync import MongoDBRepository
|
|
||||||
|
|
||||||
|
|
||||||
class PredictionMetadata(SientiaMonitoring):
|
|
||||||
"""
|
|
||||||
Centralized, best-effort writer for prediction gate metadata (triggered filter, policy,
|
|
||||||
counter) to MongoDB.
|
|
||||||
|
|
||||||
Gates only compute and return a metadata document; they never write it themselves. Keeping
|
|
||||||
the write here, instead of in each gate, puts the one real write point in the subworkflow
|
|
||||||
that's about to persist a `predictions` row — the only place that reliably knows whether one
|
|
||||||
is being written at all — and lets other metadata sources reuse the same activity instead of
|
|
||||||
duplicating write logic wherever they originate.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mongo_repository: MongoDBRepository | None = None
|
|
||||||
_PREDICTION_METADATA_COLLECTION = 'prediction_metadata'
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
mongo_repository: MongoDBRepository | None = None,
|
|
||||||
logger: Logger | None = None,
|
|
||||||
notification_handler: NotificationHandler | None = None,
|
|
||||||
metrics_controller: MetricsController | None = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize the prediction metadata writer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mongo_repository: MongoDB, where gate metadata (triggered filter, policy, counter) is
|
|
||||||
written best-effort. `None` is a valid state (see `write_prediction_metadata`).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If BaseActivity initialization fails
|
|
||||||
"""
|
|
||||||
self.mongo_repository = mongo_repository
|
|
||||||
SientiaMonitoring.__init__(
|
|
||||||
self,
|
|
||||||
logger=logger,
|
|
||||||
notification_handler=notification_handler,
|
|
||||||
metrics_controller=metrics_controller,
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""
|
|
||||||
Close the prediction metadata writer and clean up resources.
|
|
||||||
"""
|
|
||||||
if self.mongo_repository is not None:
|
|
||||||
try:
|
|
||||||
self.mongo_repository.close()
|
|
||||||
finally:
|
|
||||||
self.mongo_repository = None
|
|
||||||
SientiaMonitoring.shutdown(self)
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
@activity.defn(name='write_prediction_metadata')
|
|
||||||
def write_prediction_metadata(self, input_data: dict[str, Any]) -> str | None:
|
|
||||||
"""
|
|
||||||
Best-effort write of one or more gate metadata documents to Mongo, combined into a single
|
|
||||||
`prediction_metadata` document.
|
|
||||||
|
|
||||||
Implements both business rules for this feature in one place:
|
|
||||||
- The prediction write must never be blocked or failed by a metadata write. A failed
|
|
||||||
`insert(...)` is swallowed, never re-raised, logged here with call-site context (which
|
|
||||||
`mongo_repository.insert(...)` itself can't know) — matching how `storage.py`'s
|
|
||||||
`cleanup_minio_objects_expired` also logs again at the call site even though the
|
|
||||||
repository already logs internally and emits its own Prometheus metric
|
|
||||||
(`MONGODB_WRITE_ERROR_COUNT`); the generic library log is for the metric/trace, this
|
|
||||||
one is for whoever is reading logs for this specific call.
|
|
||||||
- Nothing is written when there's nothing relevant to report. An empty/missing
|
|
||||||
`documents` list (or one full of falsy/empty entries) skips Mongo entirely, so callers
|
|
||||||
can call this unconditionally and get `None` back for "no metadata this time" instead
|
|
||||||
of deciding upfront whether there's something worth saving.
|
|
||||||
|
|
||||||
Takes a *list* of documents, not a single one: each caller contributes its own document
|
|
||||||
(shaped like `{'filters': [...]}`), and this flattens every `filters` entry across all of
|
|
||||||
them into one `prediction_metadata` document before the single insert. Today only one
|
|
||||||
gate ever contributes a document per prediction cycle (the gate chain short-circuits on
|
|
||||||
the first non-`None` `path_flag`), but the list shape means this activity won't need to
|
|
||||||
change again once more than one source contributes metadata for the same cycle.
|
|
||||||
|
|
||||||
`insert(...)` doesn't hand back the inserted document's `_id`, so it is generated here,
|
|
||||||
client-side, before the insert, as a plain string (not a BSON ObjectId) — matching this
|
|
||||||
codebase's convention of string `_id`s (e.g. the `models` collection) and the 24-hex-char
|
|
||||||
size of the `predictions.metadata_id` Postgres column that will reference it.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_data: Dict with:
|
|
||||||
- documents (list[dict[str, Any]]): Metadata payloads to combine, each shaped
|
|
||||||
like `{'filters': [...]}`. Missing/empty, or entries with no `filters`, skip
|
|
||||||
the write entirely.
|
|
||||||
- metadata (dict): Workflow metadata for contextual logging.
|
|
||||||
|
|
||||||
Return:
|
|
||||||
The generated id, or `None` when there's nothing to write, Mongo isn't configured, or
|
|
||||||
the insert failed.
|
|
||||||
"""
|
|
||||||
metadata = input_data['metadata']
|
|
||||||
documents = input_data.get('documents') or []
|
|
||||||
|
|
||||||
filters = [
|
|
||||||
entry for document in documents if document for entry in document.get('filters', [])
|
|
||||||
]
|
|
||||||
|
|
||||||
if not filters or self.mongo_repository is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
generated_id = str(ObjectId())
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.mongo_repository.insert(
|
|
||||||
self._PREDICTION_METADATA_COLLECTION,
|
|
||||||
{'filters': filters, '_id': generated_id},
|
|
||||||
metadata,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.error(f'Failed to write prediction metadata: {e}', metadata)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return generated_id
|
|
||||||
@@ -184,7 +184,7 @@ class Storage(Postgres, SientiaMonitoring):
|
|||||||
REPEAT persists a new row (unlike STOP, which persists nothing), reusing the previous
|
REPEAT persists a new row (unlike STOP, which persists nothing), reusing the previous
|
||||||
row's `prediction`/`response_time`/`prediction_status`/`prediction_confidence` values with
|
row's `prediction`/`response_time`/`prediction_status`/`prediction_confidence` values with
|
||||||
a fresh `timestamp`/`created_at`. `metadata_id` references the `prediction_metadata`
|
a fresh `timestamp`/`created_at`. `metadata_id` references the `prediction_metadata`
|
||||||
document for this cycle, if any (see `PredictionMetadata.write_prediction_metadata`);
|
document for this cycle, if any (see `MongoActivities.write_prediction_metadata`);
|
||||||
`None` is a valid value — the column is nullable.
|
`None` is a valid value — the column is nullable.
|
||||||
|
|
||||||
Args (query_items):
|
Args (query_items):
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ async def main():
|
|||||||
activities.load_custom_query,
|
activities.load_custom_query,
|
||||||
activities.calculate_simple_metrics,
|
activities.calculate_simple_metrics,
|
||||||
activities.export_data_to_postgres,
|
activities.export_data_to_postgres,
|
||||||
|
activities.update_simple_metrics_adaptive_state,
|
||||||
],
|
],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
runtime='core',
|
runtime='core',
|
||||||
|
|||||||
@@ -71,48 +71,63 @@ class SimpleMetrics:
|
|||||||
start_to_close_timeout=timedelta(seconds=300),
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not target_data:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Too few rows for a reliable value: metrics are still persisted, but a noisy short
|
# Too few rows for a reliable value: metrics are still persisted, but a noisy short
|
||||||
# window must not alert (SIENTIAPDE-1995).
|
# window must neither alert nor escalate (SIENTIAPDE-1995).
|
||||||
enough_samples = not min_samples or len(target_data) >= min_samples
|
enough_samples = bool(target_data) and (not min_samples or len(target_data) >= min_samples)
|
||||||
if not enough_samples:
|
if target_data and not enough_samples:
|
||||||
workflow.logger.info(
|
workflow.logger.info(
|
||||||
f'{len(target_data)} rows < simple_metrics_min_samples={min_samples}: '
|
f'{len(target_data)} rows < simple_metrics_min_samples={min_samples}: '
|
||||||
'threshold alerting skipped'
|
'threshold alerting and escalation skipped'
|
||||||
)
|
)
|
||||||
|
|
||||||
simple_metrics = await workflow.execute_local_activity_method(
|
simple_metrics = None
|
||||||
Activities.calculate_simple_metrics,
|
if target_data:
|
||||||
{
|
simple_metrics = await workflow.execute_local_activity_method(
|
||||||
**metadata,
|
Activities.calculate_simple_metrics,
|
||||||
'model_id': model_id,
|
{
|
||||||
'target_data': target_data,
|
**metadata,
|
||||||
'metrics': input_data.get('metrics', ['rmse', 'mse', 'mae', 'r2']),
|
'model_id': model_id,
|
||||||
'interval_minutes': interval_minutes,
|
'target_data': target_data,
|
||||||
'model_type': model_type,
|
'metrics': input_data.get('metrics', ['rmse', 'mse', 'mae', 'r2']),
|
||||||
'thresholds': thresholds if enough_samples else None,
|
'interval_minutes': interval_minutes,
|
||||||
},
|
'model_type': model_type,
|
||||||
retry_policy=retry_policy,
|
'thresholds': thresholds if enough_samples else None,
|
||||||
start_to_close_timeout=timedelta(seconds=300),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not simple_metrics:
|
|
||||||
return
|
|
||||||
|
|
||||||
await workflow.execute_activity_method(
|
|
||||||
Activities.export_data_to_postgres,
|
|
||||||
{
|
|
||||||
**metadata,
|
|
||||||
'data': simple_metrics,
|
|
||||||
'schema': input_data['schema'],
|
|
||||||
'table_name': input_data['target_table_name'],
|
|
||||||
'timestamp_conversion': {
|
|
||||||
'column': 'timestamp',
|
|
||||||
'format': DATETIME_FORMAT_WITH_TZ,
|
|
||||||
},
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
if simple_metrics:
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.export_data_to_postgres,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'data': simple_metrics,
|
||||||
|
'schema': input_data['schema'],
|
||||||
|
'table_name': input_data['target_table_name'],
|
||||||
|
'timestamp_conversion': {
|
||||||
|
'column': 'timestamp',
|
||||||
|
'format': DATETIME_FORMAT_WITH_TZ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=300),
|
||||||
|
)
|
||||||
|
|
||||||
|
# No try/except: a failure here must fail the run so it shows up in the Temporal metrics
|
||||||
|
# (metrics are already persisted above).
|
||||||
|
await workflow.execute_activity_method(
|
||||||
|
Activities.update_simple_metrics_adaptive_state,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
# Only a valid breach escalates: an empty or under-sampled window is sent as no
|
||||||
|
# rows, which counts as a healthy run (spec decision 8). Always called, even
|
||||||
|
# without thresholds (no breach), so an escalated doc recovers after they are
|
||||||
|
# removed.
|
||||||
|
'simple_metrics': (simple_metrics or []) if enough_samples else [],
|
||||||
|
'thresholds': thresholds,
|
||||||
|
'interval_minutes': interval_minutes,
|
||||||
},
|
},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=300),
|
start_to_close_timeout=timedelta(minutes=1),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class PredictionProcess:
|
|||||||
|
|
||||||
Return:
|
Return:
|
||||||
The generated id, or `None` when there's nothing to write, including when the
|
The generated id, or `None` when there's nothing to write, including when the
|
||||||
activity call itself fails (see `PredictionMetadata.write_prediction_metadata`).
|
activity call itself fails (see `MongoActivities.write_prediction_metadata`).
|
||||||
"""
|
"""
|
||||||
if not document:
|
if not document:
|
||||||
# Skip the remote activity call entirely rather than dispatching it just to have
|
# Skip the remote activity call entirely rather than dispatching it just to have
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from laborious.activities.api import API
|
|||||||
from laborious.activities.gates import Gates
|
from laborious.activities.gates import Gates
|
||||||
from laborious.activities.mlflow import MLFlow
|
from laborious.activities.mlflow import MLFlow
|
||||||
from laborious.activities.model_metrics import ModelMetrics
|
from laborious.activities.model_metrics import ModelMetrics
|
||||||
|
from laborious.activities.mongo import MongoActivities
|
||||||
from laborious.activities.opc import OPC
|
from laborious.activities.opc import OPC
|
||||||
from laborious.activities.prediction_metadata import PredictionMetadata
|
|
||||||
from laborious.activities.storage import Storage
|
from laborious.activities.storage import Storage
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from laborious.activities.storage import Storage
|
|||||||
@patch('laborious.activities.activities.MLFlow.__init__')
|
@patch('laborious.activities.activities.MLFlow.__init__')
|
||||||
@patch('laborious.activities.activities.OPC.__init__')
|
@patch('laborious.activities.activities.OPC.__init__')
|
||||||
@patch('laborious.activities.activities.Gates.__init__')
|
@patch('laborious.activities.activities.Gates.__init__')
|
||||||
@patch('laborious.activities.activities.PredictionMetadata.__init__')
|
@patch('laborious.activities.activities.MongoActivities.__init__')
|
||||||
@patch('laborious.activities.activities.ModelMetrics.__init__')
|
@patch('laborious.activities.activities.ModelMetrics.__init__')
|
||||||
@patch('laborious.activities.activities.API.__init__')
|
@patch('laborious.activities.activities.API.__init__')
|
||||||
@patch('laborious.activities.activities.MinioRepository')
|
@patch('laborious.activities.activities.MinioRepository')
|
||||||
@@ -24,7 +24,7 @@ def test___init__(
|
|||||||
mock_minio_repository,
|
mock_minio_repository,
|
||||||
mock_api_init,
|
mock_api_init,
|
||||||
mock_model_metrics_init,
|
mock_model_metrics_init,
|
||||||
mock_prediction_metadata_init,
|
mock_mongo_activities_init,
|
||||||
mock_gates_init,
|
mock_gates_init,
|
||||||
mock_opc_init,
|
mock_opc_init,
|
||||||
mock_mlflow_init,
|
mock_mlflow_init,
|
||||||
@@ -87,7 +87,7 @@ def test___init__(
|
|||||||
assert isinstance(activities, Gates)
|
assert isinstance(activities, Gates)
|
||||||
assert isinstance(activities, ModelMetrics)
|
assert isinstance(activities, ModelMetrics)
|
||||||
assert isinstance(activities, API)
|
assert isinstance(activities, API)
|
||||||
assert isinstance(activities, PredictionMetadata)
|
assert isinstance(activities, MongoActivities)
|
||||||
|
|
||||||
mock_storage_init.assert_called_once_with(
|
mock_storage_init.assert_called_once_with(
|
||||||
ANY,
|
ANY,
|
||||||
@@ -131,7 +131,7 @@ def test___init__(
|
|||||||
metrics_controller=mock_metrics_controller.return_value,
|
metrics_controller=mock_metrics_controller.return_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_prediction_metadata_init.assert_called_once_with(
|
mock_mongo_activities_init.assert_called_once_with(
|
||||||
ANY,
|
ANY,
|
||||||
mongo_repository=mongo_repository,
|
mongo_repository=mongo_repository,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
@@ -172,7 +172,7 @@ def test___init__(
|
|||||||
@patch('laborious.activities.activities.MLFlow')
|
@patch('laborious.activities.activities.MLFlow')
|
||||||
@patch('laborious.activities.activities.OPC')
|
@patch('laborious.activities.activities.OPC')
|
||||||
@patch('laborious.activities.activities.Gates')
|
@patch('laborious.activities.activities.Gates')
|
||||||
@patch('laborious.activities.activities.PredictionMetadata')
|
@patch('laborious.activities.activities.MongoActivities')
|
||||||
@patch('laborious.activities.activities.ModelMetrics')
|
@patch('laborious.activities.activities.ModelMetrics')
|
||||||
@patch('laborious.activities.activities.API')
|
@patch('laborious.activities.activities.API')
|
||||||
@patch('laborious.activities.activities.MinioRepository')
|
@patch('laborious.activities.activities.MinioRepository')
|
||||||
@@ -180,7 +180,7 @@ def test_shutdown(
|
|||||||
_mock_minio_repository,
|
_mock_minio_repository,
|
||||||
mock_api_init,
|
mock_api_init,
|
||||||
mock_model_metrics_init,
|
mock_model_metrics_init,
|
||||||
mock_prediction_metadata_init,
|
mock_mongo_activities_init,
|
||||||
mock_gates_init,
|
mock_gates_init,
|
||||||
mock_opc_init,
|
mock_opc_init,
|
||||||
mock_mlflow_init,
|
mock_mlflow_init,
|
||||||
@@ -243,7 +243,7 @@ def test_shutdown(
|
|||||||
mock_gates_init.close.assert_called_once()
|
mock_gates_init.close.assert_called_once()
|
||||||
mock_model_metrics_init.close.assert_called_once()
|
mock_model_metrics_init.close.assert_called_once()
|
||||||
mock_api_init.close.assert_called_once()
|
mock_api_init.close.assert_called_once()
|
||||||
mock_prediction_metadata_init.close.assert_called_once()
|
mock_mongo_activities_init.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@patch('laborious.activities.activities.SientiaMLflowRepository')
|
@patch('laborious.activities.activities.SientiaMLflowRepository')
|
||||||
@@ -252,7 +252,7 @@ def test_shutdown(
|
|||||||
@patch('laborious.activities.activities.MLFlow.__init__')
|
@patch('laborious.activities.activities.MLFlow.__init__')
|
||||||
@patch('laborious.activities.activities.OPC.__init__')
|
@patch('laborious.activities.activities.OPC.__init__')
|
||||||
@patch('laborious.activities.activities.Gates.__init__')
|
@patch('laborious.activities.activities.Gates.__init__')
|
||||||
@patch('laborious.activities.activities.PredictionMetadata.__init__')
|
@patch('laborious.activities.activities.MongoActivities.__init__')
|
||||||
@patch('laborious.activities.activities.ModelMetrics.__init__')
|
@patch('laborious.activities.activities.ModelMetrics.__init__')
|
||||||
@patch('laborious.activities.activities.API.__init__')
|
@patch('laborious.activities.activities.API.__init__')
|
||||||
@patch('laborious.activities.activities.MinioRepository')
|
@patch('laborious.activities.activities.MinioRepository')
|
||||||
@@ -262,7 +262,7 @@ def test___init___builds_mlflow_repository_when_not_provided(
|
|||||||
mock_minio_repository,
|
mock_minio_repository,
|
||||||
_mock_api_init,
|
_mock_api_init,
|
||||||
_mock_model_metrics_init,
|
_mock_model_metrics_init,
|
||||||
_mock_prediction_metadata_init,
|
_mock_mongo_activities_init,
|
||||||
_mock_gates_init,
|
_mock_gates_init,
|
||||||
_mock_opc_init,
|
_mock_opc_init,
|
||||||
_mock_mlflow_init,
|
_mock_mlflow_init,
|
||||||
@@ -326,7 +326,7 @@ def test___init___builds_mlflow_repository_when_not_provided(
|
|||||||
@patch('laborious.activities.activities.MLFlow.__init__')
|
@patch('laborious.activities.activities.MLFlow.__init__')
|
||||||
@patch('laborious.activities.activities.OPC.__init__')
|
@patch('laborious.activities.activities.OPC.__init__')
|
||||||
@patch('laborious.activities.activities.Gates.__init__')
|
@patch('laborious.activities.activities.Gates.__init__')
|
||||||
@patch('laborious.activities.activities.PredictionMetadata.__init__')
|
@patch('laborious.activities.activities.MongoActivities.__init__')
|
||||||
@patch('laborious.activities.activities.ModelMetrics.__init__')
|
@patch('laborious.activities.activities.ModelMetrics.__init__')
|
||||||
@patch('laborious.activities.activities.API.__init__')
|
@patch('laborious.activities.activities.API.__init__')
|
||||||
@patch('laborious.activities.activities.MinioRepository')
|
@patch('laborious.activities.activities.MinioRepository')
|
||||||
@@ -336,7 +336,7 @@ def test___init___builds_mongo_repository_when_not_provided(
|
|||||||
mock_minio_repository,
|
mock_minio_repository,
|
||||||
_mock_api_init,
|
_mock_api_init,
|
||||||
_mock_model_metrics_init,
|
_mock_model_metrics_init,
|
||||||
mock_prediction_metadata_init,
|
mock_mongo_activities_init,
|
||||||
mock_gates_init,
|
mock_gates_init,
|
||||||
_mock_opc_init,
|
_mock_opc_init,
|
||||||
_mock_mlflow_init,
|
_mock_mlflow_init,
|
||||||
@@ -398,9 +398,9 @@ def test___init___builds_mongo_repository_when_not_provided(
|
|||||||
notification_handler=notification_handler,
|
notification_handler=notification_handler,
|
||||||
metrics_controller=mock_metrics_controller.return_value,
|
metrics_controller=mock_metrics_controller.return_value,
|
||||||
)
|
)
|
||||||
# The same repository built here is the one handed to PredictionMetadata, not a second
|
# The same repository built here is the one handed to MongoActivities, not a second
|
||||||
# instance.
|
# instance.
|
||||||
mock_prediction_metadata_init.assert_called_once_with(
|
mock_mongo_activities_init.assert_called_once_with(
|
||||||
ANY,
|
ANY,
|
||||||
mongo_repository=mock_mongo_repository_cls.return_value,
|
mongo_repository=mock_mongo_repository_cls.return_value,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ def test_input_gate_with_filter(gates_activity):
|
|||||||
|
|
||||||
# Assert: the gate only computes the document now — it never writes anywhere, and never
|
# Assert: the gate only computes the document now — it never writes anywhere, and never
|
||||||
# checks path_flag to decide whether to build one (that decision moved to the caller, see
|
# checks path_flag to decide whether to build one (that decision moved to the caller, see
|
||||||
# `PredictionMetadata.write_prediction_metadata` and `prediction_process.py`), so even a STOP
|
# `MongoActivities.write_prediction_metadata` and `prediction_process.py`), so even a
|
||||||
# resolution still gets its document built.
|
# STOP resolution still gets its document built.
|
||||||
assert (path_flag, confidence, comment) == ('STOP', -1, 'Input data with bad quality')
|
assert (path_flag, confidence, comment) == ('STOP', -1, 'Input data with bad quality')
|
||||||
assert document == {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 0}]}
|
assert document == {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 0}]}
|
||||||
gates_activity.debug.assert_called()
|
gates_activity.debug.assert_called()
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ from sientia_do.observability.metrics_controller import MetricsController
|
|||||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
from sientia_model.analytics.drift_analysis import DriftInsufficientDataError
|
from sientia_model.analytics.drift_analysis import DriftInsufficientDataError
|
||||||
|
|
||||||
from laborious.activities.model_metrics import ModelMetrics
|
from laborious.activities.model_metrics import (
|
||||||
|
ModelMetrics,
|
||||||
|
frequency_to_seconds,
|
||||||
|
next_adaptive_state,
|
||||||
|
recovery_runs,
|
||||||
|
threshold_breaches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
@@ -1221,3 +1227,79 @@ def test_to_naive_utc_keeps_naive_timestamps():
|
|||||||
|
|
||||||
assert result.dt.tz is None
|
assert result.dt.tz is None
|
||||||
assert result.tolist() == [Timestamp('2024-01-01 06:00:00')]
|
assert result.tolist() == [Timestamp('2024-01-01 06:00:00')]
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize(
|
||||||
|
'rows, thresholds, expected_count',
|
||||||
|
[
|
||||||
|
([{'metric': 'rmse', 'value': 2.0}], {'rmse_max': 1.0}, 1),
|
||||||
|
([{'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),
|
||||||
|
(
|
||||||
|
[{'metric': 'rmse', 'value': 2.0}, {'metric': 'r2', 'value': 0.1}],
|
||||||
|
{'rmse_max': 1.0, 'r2_min': 0.9},
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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
|
||||||
|
(_state(True, None), False, 3, _state(True, 1)), # hand-edited streak restarts
|
||||||
|
(_state(True, 'x'), False, 3, _state(True, 1)),
|
||||||
|
(_state(True, float('inf')), False, 3, _state(True, 1)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|||||||
357
tests/laborious/activities/test_mongo.py
Normal file
357
tests/laborious/activities/test_mongo.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
from pytest import fixture, mark, raises
|
||||||
|
from sientia_do.observability.metrics_controller import MetricsController
|
||||||
|
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
||||||
|
|
||||||
|
from laborious.activities.mongo import MongoActivities
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def mongo() -> MagicMock:
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def prediction_metadata(mongo: MagicMock):
|
||||||
|
activity = MongoActivities(
|
||||||
|
mongo_repository=mongo,
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(spec=MetricsController),
|
||||||
|
)
|
||||||
|
activity.error = MagicMock()
|
||||||
|
return activity
|
||||||
|
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'metadata': {
|
||||||
|
'model_id': 'test_model',
|
||||||
|
'model_name': 'test_model',
|
||||||
|
'workflow_name': 'test_workflow',
|
||||||
|
'schema_name': 'test_schedule',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_no_documents_skips_mongo(prediction_metadata, mongo: MagicMock):
|
||||||
|
# Act
|
||||||
|
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': []})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is None
|
||||||
|
mongo.insert.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_missing_documents_key_skips_mongo(
|
||||||
|
prediction_metadata, mongo: MagicMock
|
||||||
|
):
|
||||||
|
# Act
|
||||||
|
result = prediction_metadata.write_prediction_metadata({**metadata})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is None
|
||||||
|
mongo.insert.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_documents_with_no_filters_skips_mongo(
|
||||||
|
prediction_metadata, mongo: MagicMock
|
||||||
|
):
|
||||||
|
# Arrange: a falsy document (None) and one with an empty `filters` list — neither contributes
|
||||||
|
# anything to write.
|
||||||
|
documents = [None, {'filters': []}]
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': documents})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is None
|
||||||
|
mongo.insert.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_not_configured_returns_none():
|
||||||
|
# Arrange
|
||||||
|
activity = MongoActivities(
|
||||||
|
mongo_repository=None,
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(spec=MetricsController),
|
||||||
|
)
|
||||||
|
activity.error = MagicMock()
|
||||||
|
document = {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 0}]}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = activity.write_prediction_metadata({**metadata, 'documents': [document]})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is None
|
||||||
|
activity.error.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_success_generates_id_and_inserts(
|
||||||
|
prediction_metadata, mongo: MagicMock
|
||||||
|
):
|
||||||
|
# Arrange
|
||||||
|
document = {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 3}]}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': [document]})
|
||||||
|
|
||||||
|
# Assert: a plain 24-hex-char string id (matches predictions.metadata_id's varchar(24)),
|
||||||
|
# not a BSON ObjectId — `insert(...)` never hands one back, so it's generated client-side.
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 24
|
||||||
|
int(result, 16) # raises ValueError if not hex
|
||||||
|
|
||||||
|
collection, inserted_document, inserted_metadata = mongo.insert.call_args.args
|
||||||
|
assert collection == MongoActivities._PREDICTION_METADATA_COLLECTION
|
||||||
|
assert inserted_document == {'filters': document['filters'], '_id': result}
|
||||||
|
assert inserted_metadata == metadata['metadata']
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_combines_multiple_documents(
|
||||||
|
prediction_metadata, mongo: MagicMock
|
||||||
|
):
|
||||||
|
# Arrange: two documents (e.g. from two different sources contributing metadata for the same
|
||||||
|
# prediction cycle) — their `filters` entries are flattened into one combined document.
|
||||||
|
documents = [
|
||||||
|
{'filters': [{'name': 'EMPTY_DATA', 'policy': 'CONTINUE', 'counter': 0}]},
|
||||||
|
{
|
||||||
|
'filters': [
|
||||||
|
{'name': 'SPECIFIC_VARIABLES_NULL_VALUES', 'policy': 'CONTINUE', 'counter': 2}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': documents})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is not None
|
||||||
|
_, inserted_document, _ = mongo.insert.call_args.args
|
||||||
|
assert inserted_document['filters'] == [
|
||||||
|
{'name': 'EMPTY_DATA', 'policy': 'CONTINUE', 'counter': 0},
|
||||||
|
{'name': 'SPECIFIC_VARIABLES_NULL_VALUES', 'policy': 'CONTINUE', 'counter': 2},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_prediction_metadata_insert_failure_swallowed(prediction_metadata, mongo: MagicMock):
|
||||||
|
# Arrange
|
||||||
|
mongo.insert.side_effect = RuntimeError('connection reset')
|
||||||
|
document = {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 0}]}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': [document]})
|
||||||
|
|
||||||
|
# Assert: the prediction write must never be blocked or failed by this
|
||||||
|
assert result is None
|
||||||
|
prediction_metadata.error.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch.object(SientiaMonitoring, 'shutdown')
|
||||||
|
def test_close_disposes_mongo_repository(mock_shutdown):
|
||||||
|
"""``MongoActivities.close`` should close the Mongo client and clear the reference."""
|
||||||
|
mongo = MagicMock()
|
||||||
|
activity = MongoActivities(
|
||||||
|
mongo_repository=mongo,
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(spec=MetricsController),
|
||||||
|
)
|
||||||
|
activity.close()
|
||||||
|
mongo.close.assert_called_once()
|
||||||
|
assert activity.mongo_repository is None
|
||||||
|
# Other instances collected by GC during the test also hit the patched shutdown.
|
||||||
|
assert mock_shutdown.call_args_list.count(call(activity)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@patch.object(SientiaMonitoring, 'shutdown')
|
||||||
|
def test_close_without_mongo_repository(mock_shutdown):
|
||||||
|
"""When no Mongo repository is configured, ``close`` only shuts down monitoring."""
|
||||||
|
activity = MongoActivities(
|
||||||
|
mongo_repository=None,
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(spec=MetricsController),
|
||||||
|
)
|
||||||
|
activity.close()
|
||||||
|
# Other instances collected by GC during the test also hit the patched shutdown.
|
||||||
|
assert mock_shutdown.call_args_list.count(call(activity)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
_SM_METADATA = {**metadata['metadata'], 'schedule_name': 'm1-simple_metrics-60m'}
|
||||||
|
_BREACH_INPUT = {
|
||||||
|
'metadata': _SM_METADATA,
|
||||||
|
'simple_metrics': [{'metric': 'rmse', 'value': 2.0}],
|
||||||
|
'thresholds': {'rmse_max': 1.0},
|
||||||
|
'interval_minutes': 60,
|
||||||
|
}
|
||||||
|
_HEALTHY_INPUT = {**_BREACH_INPUT, 'simple_metrics': [{'metric': 'rmse', 'value': 0.5}]}
|
||||||
|
_EMPTY_INPUT = {**_BREACH_INPUT, 'simple_metrics': []}
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def mongo_activities(mongo: MagicMock):
|
||||||
|
activity = MongoActivities(
|
||||||
|
mongo_repository=mongo,
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(spec=MetricsController),
|
||||||
|
)
|
||||||
|
activity.info = MagicMock()
|
||||||
|
activity.warning = MagicMock()
|
||||||
|
return activity
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline(**fields) -> dict:
|
||||||
|
return {'schedule_name': 'm1-simple_metrics-60m', 'frequency': '1h', **fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _set_of(mongo: MagicMock) -> dict:
|
||||||
|
collection, query, update, _metadata = mongo.update_one.call_args.args
|
||||||
|
assert collection == 'pipelines'
|
||||||
|
assert query == {'schedule_name': 'm1-simple_metrics-60m'}
|
||||||
|
return update['$set']
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_no_mongo_is_noop():
|
||||||
|
activity = MongoActivities(
|
||||||
|
mongo_repository=None,
|
||||||
|
logger=MagicMock(),
|
||||||
|
notification_handler=MagicMock(),
|
||||||
|
metrics_controller=MagicMock(spec=MetricsController),
|
||||||
|
)
|
||||||
|
assert activity.update_simple_metrics_adaptive_state(_BREACH_INPUT) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_missing_pipeline_doc_is_noop(mongo_activities, mongo):
|
||||||
|
mongo.find_one.return_value = None
|
||||||
|
assert mongo_activities.update_simple_metrics_adaptive_state(_BREACH_INPUT) is None
|
||||||
|
mongo.update_one.assert_not_called()
|
||||||
|
# Likely misconfig (other MONGODB_DATABASE_NAME or schedule_name mismatch): not silent.
|
||||||
|
mongo_activities.warning.assert_called_once()
|
||||||
|
assert 'm1-simple_metrics-60m' in mongo_activities.warning.call_args.args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_without_frequency_alert_is_noop(mongo_activities, mongo):
|
||||||
|
mongo.find_one.return_value = _pipeline()
|
||||||
|
assert mongo_activities.update_simple_metrics_adaptive_state(_BREACH_INPUT) is None
|
||||||
|
mongo.update_one.assert_not_called()
|
||||||
|
mongo_activities.warning.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_breach_escalates_and_bumps_updated_at(mongo_activities, mongo):
|
||||||
|
mongo.find_one.return_value = _pipeline(frequency_alert='5m')
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_BREACH_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': True, 'healthy_streak': 0}
|
||||||
|
update = _set_of(mongo)
|
||||||
|
assert update['adaptive_state'] == {'escalated': True, 'healthy_streak': 0}
|
||||||
|
# BSON datetime, never a string: the orchestrator calls .replace(tzinfo=...) on it.
|
||||||
|
assert isinstance(update['updated_at'], datetime)
|
||||||
|
assert update['updated_at'].tzinfo is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_healthy_run_increments_streak_without_updated_at(mongo_activities, mongo):
|
||||||
|
# 60 min window / 5m = N 12, so one healthy run only moves the streak.
|
||||||
|
mongo.find_one.return_value = _pipeline(
|
||||||
|
frequency_alert='5m', adaptive_state={'escalated': True, 'healthy_streak': 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_HEALTHY_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': True, 'healthy_streak': 1}
|
||||||
|
assert _set_of(mongo) == {'adaptive_state': {'escalated': True, 'healthy_streak': 1}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_recovers_after_window_renewed(mongo_activities, mongo):
|
||||||
|
# 60 min window / 30m = N 2: second consecutive healthy run recovers.
|
||||||
|
mongo.find_one.return_value = _pipeline(
|
||||||
|
frequency_alert='30m', adaptive_state={'escalated': True, 'healthy_streak': 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_HEALTHY_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': False, 'healthy_streak': 0}
|
||||||
|
update = _set_of(mongo)
|
||||||
|
assert update['adaptive_state'] == {'escalated': False, 'healthy_streak': 0}
|
||||||
|
assert isinstance(update['updated_at'], datetime)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_empty_window_counts_as_healthy_run(mongo_activities, mongo):
|
||||||
|
# Spec decision 8: an empty/under-sampled window never escalates but counts toward recovery.
|
||||||
|
mongo.find_one.return_value = _pipeline(
|
||||||
|
frequency_alert='5m', adaptive_state={'escalated': True, 'healthy_streak': 3}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_EMPTY_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': True, 'healthy_streak': 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_empty_window_never_escalates(mongo_activities, mongo):
|
||||||
|
mongo.find_one.return_value = _pipeline(frequency_alert='5m')
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_EMPTY_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': False, 'healthy_streak': 0}
|
||||||
|
mongo.update_one.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_state_unchanged_does_not_write(mongo_activities, mongo):
|
||||||
|
mongo.find_one.return_value = _pipeline(
|
||||||
|
frequency_alert='5m', adaptive_state={'escalated': False, 'healthy_streak': 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_HEALTHY_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': False, 'healthy_streak': 0}
|
||||||
|
mongo.update_one.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize('frequency_alert', ['5min', '0m', 5])
|
||||||
|
def test_adaptive_state_invalid_frequency_alert_is_skipped(
|
||||||
|
mongo_activities, mongo, frequency_alert
|
||||||
|
):
|
||||||
|
mongo.find_one.return_value = _pipeline(frequency_alert=frequency_alert)
|
||||||
|
|
||||||
|
assert mongo_activities.update_simple_metrics_adaptive_state(_BREACH_INPUT) is None
|
||||||
|
mongo.update_one.assert_not_called()
|
||||||
|
mongo_activities.warning.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize('interval_minutes', ['60', None])
|
||||||
|
def test_adaptive_state_invalid_interval_minutes_is_skipped(
|
||||||
|
mongo_activities, mongo, interval_minutes
|
||||||
|
):
|
||||||
|
# Untyped Mongo config: "60" * 60 is a string, None * 60 a TypeError.
|
||||||
|
mongo.find_one.return_value = _pipeline(frequency_alert='5m')
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(
|
||||||
|
{**_BREACH_INPUT, 'interval_minutes': interval_minutes}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
mongo.update_one.assert_not_called()
|
||||||
|
mongo_activities.warning.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize('adaptive_state', ['garbage', ['escalated']])
|
||||||
|
def test_adaptive_state_malformed_state_is_not_escalated(mongo_activities, mongo, adaptive_state):
|
||||||
|
mongo.find_one.return_value = _pipeline(frequency_alert='5m', adaptive_state=adaptive_state)
|
||||||
|
|
||||||
|
result = mongo_activities.update_simple_metrics_adaptive_state(_BREACH_INPUT)
|
||||||
|
|
||||||
|
assert result == {'escalated': True, 'healthy_streak': 0}
|
||||||
|
mongo.update_one.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize('failing_call', ['find_one', 'update_one'])
|
||||||
|
def test_adaptive_state_mongo_error_propagates(mongo_activities, mongo, failing_call):
|
||||||
|
mongo.find_one.return_value = _pipeline(frequency_alert='5m')
|
||||||
|
getattr(mongo, failing_call).side_effect = RuntimeError('mongo down')
|
||||||
|
|
||||||
|
with raises(RuntimeError, match='mongo down'):
|
||||||
|
mongo_activities.update_simple_metrics_adaptive_state(_BREACH_INPUT)
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
from unittest.mock import MagicMock, call, patch
|
|
||||||
|
|
||||||
from pytest import fixture
|
|
||||||
from sientia_do.observability.metrics_controller import MetricsController
|
|
||||||
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
|
|
||||||
|
|
||||||
from laborious.activities.prediction_metadata import PredictionMetadata
|
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
|
||||||
def mongo() -> MagicMock:
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
|
||||||
def prediction_metadata(mongo: MagicMock):
|
|
||||||
activity = PredictionMetadata(
|
|
||||||
mongo_repository=mongo,
|
|
||||||
logger=MagicMock(),
|
|
||||||
notification_handler=MagicMock(),
|
|
||||||
metrics_controller=MagicMock(spec=MetricsController),
|
|
||||||
)
|
|
||||||
activity.error = MagicMock()
|
|
||||||
return activity
|
|
||||||
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
'metadata': {
|
|
||||||
'model_id': 'test_model',
|
|
||||||
'model_name': 'test_model',
|
|
||||||
'workflow_name': 'test_workflow',
|
|
||||||
'schema_name': 'test_schedule',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_no_documents_skips_mongo(prediction_metadata, mongo: MagicMock):
|
|
||||||
# Act
|
|
||||||
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': []})
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is None
|
|
||||||
mongo.insert.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_missing_documents_key_skips_mongo(
|
|
||||||
prediction_metadata, mongo: MagicMock
|
|
||||||
):
|
|
||||||
# Act
|
|
||||||
result = prediction_metadata.write_prediction_metadata({**metadata})
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is None
|
|
||||||
mongo.insert.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_documents_with_no_filters_skips_mongo(
|
|
||||||
prediction_metadata, mongo: MagicMock
|
|
||||||
):
|
|
||||||
# Arrange: a falsy document (None) and one with an empty `filters` list — neither contributes
|
|
||||||
# anything to write.
|
|
||||||
documents = [None, {'filters': []}]
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': documents})
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is None
|
|
||||||
mongo.insert.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_not_configured_returns_none():
|
|
||||||
# Arrange
|
|
||||||
activity = PredictionMetadata(
|
|
||||||
mongo_repository=None,
|
|
||||||
logger=MagicMock(),
|
|
||||||
notification_handler=MagicMock(),
|
|
||||||
metrics_controller=MagicMock(spec=MetricsController),
|
|
||||||
)
|
|
||||||
activity.error = MagicMock()
|
|
||||||
document = {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 0}]}
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = activity.write_prediction_metadata({**metadata, 'documents': [document]})
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is None
|
|
||||||
activity.error.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_success_generates_id_and_inserts(
|
|
||||||
prediction_metadata, mongo: MagicMock
|
|
||||||
):
|
|
||||||
# Arrange
|
|
||||||
document = {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 3}]}
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': [document]})
|
|
||||||
|
|
||||||
# Assert: a plain 24-hex-char string id (matches predictions.metadata_id's varchar(24)),
|
|
||||||
# not a BSON ObjectId — `insert(...)` never hands one back, so it's generated client-side.
|
|
||||||
assert result is not None
|
|
||||||
assert len(result) == 24
|
|
||||||
int(result, 16) # raises ValueError if not hex
|
|
||||||
|
|
||||||
collection, inserted_document, inserted_metadata = mongo.insert.call_args.args
|
|
||||||
assert collection == PredictionMetadata._PREDICTION_METADATA_COLLECTION
|
|
||||||
assert inserted_document == {'filters': document['filters'], '_id': result}
|
|
||||||
assert inserted_metadata == metadata['metadata']
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_combines_multiple_documents(
|
|
||||||
prediction_metadata, mongo: MagicMock
|
|
||||||
):
|
|
||||||
# Arrange: two documents (e.g. from two different sources contributing metadata for the same
|
|
||||||
# prediction cycle) — their `filters` entries are flattened into one combined document.
|
|
||||||
documents = [
|
|
||||||
{'filters': [{'name': 'EMPTY_DATA', 'policy': 'CONTINUE', 'counter': 0}]},
|
|
||||||
{
|
|
||||||
'filters': [
|
|
||||||
{'name': 'SPECIFIC_VARIABLES_NULL_VALUES', 'policy': 'CONTINUE', 'counter': 2}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': documents})
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert result is not None
|
|
||||||
_, inserted_document, _ = mongo.insert.call_args.args
|
|
||||||
assert inserted_document['filters'] == [
|
|
||||||
{'name': 'EMPTY_DATA', 'policy': 'CONTINUE', 'counter': 0},
|
|
||||||
{'name': 'SPECIFIC_VARIABLES_NULL_VALUES', 'policy': 'CONTINUE', 'counter': 2},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_prediction_metadata_insert_failure_swallowed(prediction_metadata, mongo: MagicMock):
|
|
||||||
# Arrange
|
|
||||||
mongo.insert.side_effect = RuntimeError('connection reset')
|
|
||||||
document = {'filters': [{'name': 'EMPTY_DATA', 'policy': 'STOP', 'counter': 0}]}
|
|
||||||
|
|
||||||
# Act
|
|
||||||
result = prediction_metadata.write_prediction_metadata({**metadata, 'documents': [document]})
|
|
||||||
|
|
||||||
# Assert: the prediction write must never be blocked or failed by this
|
|
||||||
assert result is None
|
|
||||||
prediction_metadata.error.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@patch.object(SientiaMonitoring, 'shutdown')
|
|
||||||
def test_close_disposes_mongo_repository(mock_shutdown):
|
|
||||||
"""``PredictionMetadata.close`` should close the Mongo client and clear the reference."""
|
|
||||||
mongo = MagicMock()
|
|
||||||
activity = PredictionMetadata(
|
|
||||||
mongo_repository=mongo,
|
|
||||||
logger=MagicMock(),
|
|
||||||
notification_handler=MagicMock(),
|
|
||||||
metrics_controller=MagicMock(spec=MetricsController),
|
|
||||||
)
|
|
||||||
activity.close()
|
|
||||||
mongo.close.assert_called_once()
|
|
||||||
assert activity.mongo_repository is None
|
|
||||||
# Other instances collected by GC during the test also hit the patched shutdown.
|
|
||||||
assert mock_shutdown.call_args_list.count(call(activity)) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@patch.object(SientiaMonitoring, 'shutdown')
|
|
||||||
def test_close_without_mongo_repository(mock_shutdown):
|
|
||||||
"""When no Mongo repository is configured, ``close`` only shuts down monitoring."""
|
|
||||||
activity = PredictionMetadata(
|
|
||||||
mongo_repository=None,
|
|
||||||
logger=MagicMock(),
|
|
||||||
notification_handler=MagicMock(),
|
|
||||||
metrics_controller=MagicMock(spec=MetricsController),
|
|
||||||
)
|
|
||||||
activity.close()
|
|
||||||
# Other instances collected by GC during the test also hit the patched shutdown.
|
|
||||||
assert mock_shutdown.call_args_list.count(call(activity)) == 1
|
|
||||||
@@ -16,6 +16,7 @@ def _build_fake_activities():
|
|||||||
inst.export_data_to_postgres = MagicMock()
|
inst.export_data_to_postgres = MagicMock()
|
||||||
inst.load_custom_query = MagicMock()
|
inst.load_custom_query = MagicMock()
|
||||||
inst.calculate_simple_metrics = MagicMock()
|
inst.calculate_simple_metrics = MagicMock()
|
||||||
|
inst.update_simple_metrics_adaptive_state = MagicMock()
|
||||||
inst.get_reference_data = MagicMock()
|
inst.get_reference_data = MagicMock()
|
||||||
inst.calculate_drift = MagicMock()
|
inst.calculate_drift = MagicMock()
|
||||||
inst.request_predict = MagicMock()
|
inst.request_predict = MagicMock()
|
||||||
@@ -188,6 +189,11 @@ async def test_main_success_exit_zero(monkeypatch):
|
|||||||
# Drift and SimpleMetrics load no model code, so every runtime shares the `core` queues.
|
# Drift and SimpleMetrics load no model code, so every runtime shares the `core` queues.
|
||||||
assert prepare_calls[1].kwargs['runtime'] == 'core'
|
assert prepare_calls[1].kwargs['runtime'] == 'core'
|
||||||
assert prepare_calls[2].kwargs['runtime'] == 'core'
|
assert prepare_calls[2].kwargs['runtime'] == 'core'
|
||||||
|
# prepare_calls[1] is the SimpleMetrics worker (order: MinimalRetrain, SimpleMetrics, Drift, PredictionsBatch).
|
||||||
|
assert (
|
||||||
|
fake_activities.update_simple_metrics_adaptive_state
|
||||||
|
in prepare_calls[1].kwargs['activities']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
from pytest import fixture, mark
|
from pytest import fixture, mark, raises
|
||||||
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ
|
||||||
|
|
||||||
from laborious.activities.activities import Activities
|
from laborious.activities.activities import Activities
|
||||||
@@ -42,12 +42,17 @@ async def test_run(workflow_mock: AsyncMock, simple_metrics: SimpleMetrics):
|
|||||||
target_data = {'data': 'test_target_data'}
|
target_data = {'data': 'test_target_data'}
|
||||||
simple_metrics_data = {'metrics': 'test_simple_metrics_data'}
|
simple_metrics_data = {'metrics': 'test_simple_metrics_data'}
|
||||||
|
|
||||||
workflow_mock.execute_activity_method = AsyncMock(side_effect=[target_data, None])
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[target_data, None, None])
|
||||||
workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data)
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
await simple_metrics.run(input_data)
|
await simple_metrics.run(input_data)
|
||||||
|
|
||||||
|
# Assert - load, export and adaptive state (no thresholds still updates it)
|
||||||
|
assert workflow_mock.execute_activity_method.call_count == 3
|
||||||
|
workflow_mock.logger.warning.assert_not_called()
|
||||||
|
|
||||||
# Assert - Check load_custom_query call
|
# Assert - Check load_custom_query call
|
||||||
# Query format matches psycopg2.sql output (identifiers with double quotes, literals with single quotes)
|
# Query format matches psycopg2.sql output (identifiers with double quotes, literals with single quotes)
|
||||||
expected_query = f"""
|
expected_query = f"""
|
||||||
@@ -130,13 +135,22 @@ async def test_run_empty_target_data(workflow_mock: AsyncMock, simple_metrics: S
|
|||||||
|
|
||||||
target_data = None
|
target_data = None
|
||||||
|
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
workflow_mock.execute_activity_method = AsyncMock(return_value=target_data)
|
workflow_mock.execute_activity_method = AsyncMock(return_value=target_data)
|
||||||
|
workflow_mock.execute_local_activity_method = AsyncMock()
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
await simple_metrics.run(input_data)
|
await simple_metrics.run(input_data)
|
||||||
|
|
||||||
# Assert - Should not call calculate_simple_metrics or export
|
# Assert - No calculate or export; load + adaptive state (empty window = healthy run)
|
||||||
assert workflow_mock.execute_activity_method.call_count == 1
|
workflow_mock.execute_local_activity_method.assert_not_called()
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert [c.args[0] for c in calls] == [
|
||||||
|
Activities.load_custom_query,
|
||||||
|
Activities.update_simple_metrics_adaptive_state,
|
||||||
|
]
|
||||||
|
assert calls[1].args[1]['simple_metrics'] == []
|
||||||
|
workflow_mock.logger.warning.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
@@ -159,15 +173,22 @@ async def test_run_empty_simple_metrics(workflow_mock: AsyncMock, simple_metrics
|
|||||||
target_data = {'data': 'test_target_data'}
|
target_data = {'data': 'test_target_data'}
|
||||||
simple_metrics_data = None
|
simple_metrics_data = None
|
||||||
|
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
workflow_mock.execute_activity_method = AsyncMock(return_value=target_data)
|
workflow_mock.execute_activity_method = AsyncMock(return_value=target_data)
|
||||||
workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data)
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
await simple_metrics.run(input_data)
|
await simple_metrics.run(input_data)
|
||||||
|
|
||||||
# Assert - Should call calculate_simple_metrics but not export
|
# Assert - calculate returns None: no export, adaptive state gets [] (the `or []` is live)
|
||||||
workflow_mock.execute_activity_method.assert_called_once()
|
|
||||||
workflow_mock.execute_local_activity_method.assert_called_once()
|
workflow_mock.execute_local_activity_method.assert_called_once()
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert [c.args[0] for c in calls] == [
|
||||||
|
Activities.load_custom_query,
|
||||||
|
Activities.update_simple_metrics_adaptive_state,
|
||||||
|
]
|
||||||
|
assert calls[1].args[1]['simple_metrics'] == []
|
||||||
|
workflow_mock.logger.warning.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
@@ -190,12 +211,17 @@ async def test_run_default_metrics(workflow_mock: AsyncMock, simple_metrics: Sim
|
|||||||
target_data = {'data': 'test_target_data'}
|
target_data = {'data': 'test_target_data'}
|
||||||
simple_metrics_data = {'metrics': 'test_simple_metrics_data'}
|
simple_metrics_data = {'metrics': 'test_simple_metrics_data'}
|
||||||
|
|
||||||
workflow_mock.execute_activity_method = AsyncMock(side_effect=[target_data, None])
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[target_data, None, None])
|
||||||
workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data)
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=simple_metrics_data)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
await simple_metrics.run(input_data)
|
await simple_metrics.run(input_data)
|
||||||
|
|
||||||
|
# Assert - all three activities ran
|
||||||
|
assert workflow_mock.execute_activity_method.call_count == 3
|
||||||
|
workflow_mock.logger.warning.assert_not_called()
|
||||||
|
|
||||||
# Assert - Check calculate_simple_metrics call with default metrics
|
# Assert - Check calculate_simple_metrics call with default metrics
|
||||||
workflow_mock.execute_activity_method.assert_any_call(
|
workflow_mock.execute_activity_method.assert_any_call(
|
||||||
Activities.load_custom_query,
|
Activities.load_custom_query,
|
||||||
@@ -245,7 +271,7 @@ _METRICS = [{'metric': 'rmse', 'value': 9.0}]
|
|||||||
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
async def test_run_below_min_samples_drops_thresholds(workflow_mock, simple_metrics, n_rows):
|
async def test_run_below_min_samples_drops_thresholds(workflow_mock, simple_metrics, n_rows):
|
||||||
workflow_mock.logger = MagicMock()
|
workflow_mock.logger = MagicMock()
|
||||||
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * n_rows, None])
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * n_rows, None, None])
|
||||||
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_METRICS)
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_METRICS)
|
||||||
|
|
||||||
await simple_metrics.run(
|
await simple_metrics.run(
|
||||||
@@ -254,8 +280,8 @@ async def test_run_below_min_samples_drops_thresholds(workflow_mock, simple_metr
|
|||||||
|
|
||||||
activity_input = workflow_mock.execute_local_activity_method.call_args.args[1]
|
activity_input = workflow_mock.execute_local_activity_method.call_args.args[1]
|
||||||
assert activity_input['thresholds'] is None
|
assert activity_input['thresholds'] is None
|
||||||
# Persist, don't alert.
|
# Persist, don't alert: load, export, adaptive state.
|
||||||
assert workflow_mock.execute_activity_method.call_count == 2
|
assert workflow_mock.execute_activity_method.call_count == 3
|
||||||
export_call = workflow_mock.execute_activity_method.call_args_list[1]
|
export_call = workflow_mock.execute_activity_method.call_args_list[1]
|
||||||
assert export_call.args[0] == Activities.export_data_to_postgres
|
assert export_call.args[0] == Activities.export_data_to_postgres
|
||||||
assert export_call.args[1]['data'] == _METRICS
|
assert export_call.args[1]['data'] == _METRICS
|
||||||
@@ -265,7 +291,7 @@ async def test_run_below_min_samples_drops_thresholds(workflow_mock, simple_metr
|
|||||||
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
async def test_run_at_min_samples_keeps_thresholds(workflow_mock, simple_metrics):
|
async def test_run_at_min_samples_keeps_thresholds(workflow_mock, simple_metrics):
|
||||||
workflow_mock.logger = MagicMock()
|
workflow_mock.logger = MagicMock()
|
||||||
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * 5, None])
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * 5, None, None])
|
||||||
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_METRICS)
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_METRICS)
|
||||||
|
|
||||||
await simple_metrics.run(
|
await simple_metrics.run(
|
||||||
@@ -274,7 +300,52 @@ async def test_run_at_min_samples_keeps_thresholds(workflow_mock, simple_metrics
|
|||||||
|
|
||||||
activity_input = workflow_mock.execute_local_activity_method.call_args.args[1]
|
activity_input = workflow_mock.execute_local_activity_method.call_args.args[1]
|
||||||
assert activity_input['thresholds'] == {'rmse_max': 1.0}
|
assert activity_input['thresholds'] == {'rmse_max': 1.0}
|
||||||
assert workflow_mock.execute_activity_method.call_count == 2
|
assert workflow_mock.execute_activity_method.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
_BREACH_ROWS = [{'metric': 'rmse', 'value': 9.0}]
|
||||||
|
_THRESHOLDS = {'rmse_max': 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_updates_adaptive_state_after_export(workflow_mock, simple_metrics):
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(
|
||||||
|
side_effect=[[_ROW] * 3, None, {'escalated': True, 'healthy_streak': 0}]
|
||||||
|
)
|
||||||
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_BREACH_ROWS)
|
||||||
|
|
||||||
|
await simple_metrics.run(_input(simple_metrics_thresholds=_THRESHOLDS))
|
||||||
|
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert calls[1].args[0] is Activities.export_data_to_postgres
|
||||||
|
assert calls[2].args[0] is Activities.update_simple_metrics_adaptive_state
|
||||||
|
assert calls[2].args[1] == {
|
||||||
|
**metadata,
|
||||||
|
'simple_metrics': _BREACH_ROWS,
|
||||||
|
'thresholds': _THRESHOLDS,
|
||||||
|
'interval_minutes': 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_without_thresholds_still_updates_adaptive_state(workflow_mock, simple_metrics):
|
||||||
|
# No thresholds = never a breach; still calling lets an escalated doc recover after the
|
||||||
|
# thresholds are removed.
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * 3, None, None])
|
||||||
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_BREACH_ROWS)
|
||||||
|
|
||||||
|
await simple_metrics.run(_input())
|
||||||
|
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert len(calls) == 3
|
||||||
|
assert calls[2].args[0] is Activities.update_simple_metrics_adaptive_state
|
||||||
|
assert calls[2].args[1]['thresholds'] is None
|
||||||
|
assert calls[2].args[1]['simple_metrics'] == _BREACH_ROWS
|
||||||
|
workflow_mock.logger.warning.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
@@ -284,7 +355,7 @@ async def test_run_at_min_samples_keeps_thresholds(workflow_mock, simple_metrics
|
|||||||
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
async def test_run_guard_off_keeps_thresholds(workflow_mock, simple_metrics, min_samples, warns):
|
async def test_run_guard_off_keeps_thresholds(workflow_mock, simple_metrics, min_samples, warns):
|
||||||
workflow_mock.logger = MagicMock()
|
workflow_mock.logger = MagicMock()
|
||||||
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * 2, None])
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * 2, None, None])
|
||||||
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_METRICS)
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_METRICS)
|
||||||
|
|
||||||
await simple_metrics.run(
|
await simple_metrics.run(
|
||||||
@@ -294,3 +365,54 @@ async def test_run_guard_off_keeps_thresholds(workflow_mock, simple_metrics, min
|
|||||||
activity_input = workflow_mock.execute_local_activity_method.call_args.args[1]
|
activity_input = workflow_mock.execute_local_activity_method.call_args.args[1]
|
||||||
assert activity_input['thresholds'] == {'rmse_max': 1.0}
|
assert activity_input['thresholds'] == {'rmse_max': 1.0}
|
||||||
assert workflow_mock.logger.warning.call_count == int(warns)
|
assert workflow_mock.logger.warning.call_count == int(warns)
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_empty_window_counts_as_healthy_run(workflow_mock, simple_metrics):
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[], None])
|
||||||
|
workflow_mock.execute_local_activity_method = AsyncMock()
|
||||||
|
|
||||||
|
await simple_metrics.run(_input(simple_metrics_thresholds=_THRESHOLDS))
|
||||||
|
|
||||||
|
workflow_mock.execute_local_activity_method.assert_not_called()
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert len(calls) == 2 # load + adaptive state, no export
|
||||||
|
assert calls[1].args[0] is Activities.update_simple_metrics_adaptive_state
|
||||||
|
assert calls[1].args[1]['simple_metrics'] == []
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_below_min_samples_sends_no_rows_to_adaptive_state(workflow_mock, simple_metrics):
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(side_effect=[[_ROW] * 2, None, None])
|
||||||
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_BREACH_ROWS)
|
||||||
|
|
||||||
|
await simple_metrics.run(
|
||||||
|
_input(simple_metrics_thresholds=_THRESHOLDS, simple_metrics_min_samples=5)
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert calls[1].args[0] is Activities.export_data_to_postgres # metrics still persisted
|
||||||
|
assert calls[2].args[0] is Activities.update_simple_metrics_adaptive_state
|
||||||
|
assert calls[2].args[1]['simple_metrics'] == [] # never a breach
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('laborious.workflows.simple_metrics.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_adaptive_state_failure_fails_workflow(workflow_mock, simple_metrics):
|
||||||
|
workflow_mock.logger = MagicMock()
|
||||||
|
workflow_mock.execute_activity_method = AsyncMock(
|
||||||
|
side_effect=[[_ROW] * 3, None, RuntimeError('dispatch failed')]
|
||||||
|
)
|
||||||
|
workflow_mock.execute_local_activity_method = AsyncMock(return_value=_BREACH_ROWS)
|
||||||
|
input_data = _input(simple_metrics_thresholds=_THRESHOLDS)
|
||||||
|
|
||||||
|
with raises(RuntimeError, match='dispatch failed'):
|
||||||
|
await simple_metrics.run(input_data)
|
||||||
|
|
||||||
|
# Metrics were exported before the failing call
|
||||||
|
calls = workflow_mock.execute_activity_method.call_args_list
|
||||||
|
assert calls[1].args[0] is Activities.export_data_to_postgres
|
||||||
|
|||||||
Reference in New Issue
Block a user