rc/SIENTIAPDE-2165- feat: rich metadata (filter/policy/counter) for mlflow_response_gate

This commit is contained in:
Carla Pinto
2026-10-05 22:14:18 +02:00
parent 14b27e4c30
commit 0742078fad
4 changed files with 112 additions and 35 deletions

View File

@@ -28,6 +28,7 @@ InputFilterFunc = Callable[[DataFrame, dict[str, Any]], bool]
ResponseFilterFunc = Callable[[dict[str, Any], dict[str, Any]], bool] ResponseFilterFunc = Callable[[dict[str, Any], dict[str, Any]], bool]
ContentFilterFunc = Callable[[DataFrame, dict[str, Any]], bool] ContentFilterFunc = Callable[[DataFrame, dict[str, Any]], bool]
InputCounterFunc = Callable[[DataFrame, dict[str, Any]], int] InputCounterFunc = Callable[[DataFrame, dict[str, Any]], int]
ResponseCounterFunc = Callable[[dict[str, Any], dict[str, Any]], int]
# Input filter function mappings # Input filter function mappings
input_filter_functions: dict[str, InputFilterFunc] = { input_filter_functions: dict[str, InputFilterFunc] = {
@@ -78,11 +79,31 @@ input_path_confidence: Mapping[str, int] = {
'REPEAT': -1, 'REPEAT': -1,
} }
def _count_api_error(_response: dict[str, Any], _config: dict[str, Any]) -> int:
"""
Count for the API_ERROR filter.
There's no per-row/per-item unit to count here — `api_error_filter` evaluates a single
API response as a whole, not tabular data — so this mirrors `_count_empty_data`'s
approach to a structural (not countable) trigger: a fixed value, here 1 (one API error
event), instead of 0 (zero rows present) like `_count_empty_data`'s own case.
Return:
int: Always 1.
"""
return 1
# MLFlow response filter function mappings # MLFlow response filter function mappings
mlflow_response_filter_functions: dict[str, ResponseFilterFunc] = { mlflow_response_filter_functions: dict[str, ResponseFilterFunc] = {
'API_ERROR': api_error_filter, 'API_ERROR': api_error_filter,
} }
mlflow_response_filter_counters: dict[str, ResponseCounterFunc] = {
'API_ERROR': _count_api_error,
}
mlflow_response_path_confidence: Mapping[str, int] = { mlflow_response_path_confidence: Mapping[str, int] = {
'STOP': -1, 'STOP': -1,
'CONTINUE': 10, 'CONTINUE': 10,
@@ -320,7 +341,9 @@ class Gates(SientiaMonitoring):
return None, 0, '', None return None, 0, '', None
@activity.defn(name='mlflow_response_gate') @activity.defn(name='mlflow_response_gate')
def mlflow_response_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]: def mlflow_response_gate(
self, input_data: dict[str, Any]
) -> tuple[str | None, int, str, dict[str, Any] | None]:
""" """
Validate MLFlow API response quality and integrity. Validate MLFlow API response quality and integrity.
@@ -334,6 +357,9 @@ class Gates(SientiaMonitoring):
3. Determines path decisions based on response validation results 3. Determines path decisions based on response validation results
4. Provides confidence scores and detailed validation comments 4. Provides confidence scores and detailed validation comments
5. Handles API errors and response validation failures 5. Handles API errors and response validation failures
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
(see `PredictionMetadata.write_prediction_metadata` for the actual write)
Args: Args:
input_data: Configuration and data for response validation input_data: Configuration and data for response validation
@@ -345,10 +371,14 @@ class Gates(SientiaMonitoring):
- path_priority (list[str]): Priority order for path decisions - path_priority (list[str]): Priority order for path decisions
Returns: Returns:
tuple: (path_flag, confidence, comment) tuple: (path_flag, confidence, comment, document)
- path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None) - path_flag (str | None): Decision path (STOP, CONTINUE, REPEAT, or None)
- confidence (int): Confidence score for the decision - confidence (int): Confidence score for the decision
- comment (str): Detailed explanation of the decision - comment (str): Detailed explanation of the decision
- document (dict | None): Metadata describing the filter(s) that produced
`path_flag`, shaped `{'filters': [{'name', 'policy', 'counter'}, ...]}`;
`None` when nothing was filtered. Not written anywhere by this activity —
the caller decides whether and how to persist it.
Raises: Raises:
Exception: If response validation fails or configuration is invalid Exception: If response validation fails or configuration is invalid
@@ -369,10 +399,11 @@ class Gates(SientiaMonitoring):
gate_type = input_data['type'] gate_type = input_data['type']
path_priority = input_data['path_priority'] path_priority = input_data['path_priority']
filter_output = []
comments = [] comments = []
# (filter_name, policy, filter_config) per filter that triggered, in configured order
triggered_filters: list[tuple[str, str, dict[str, Any]]] = []
status = payload.status or {} status = payload.status or {}
for fil, config in filters.items(): for fil, config in filters.items():
@@ -381,7 +412,7 @@ class Gates(SientiaMonitoring):
policy, filter_config = self._read_filter_entry(config) policy, filter_config = self._read_filter_entry(config)
try: try:
if mlflow_response_filter_functions[fil](status, filter_config): if mlflow_response_filter_functions[fil](status, filter_config):
filter_output.append(policy) triggered_filters.append((fil, policy, filter_config))
comments.append(status.get('message', 'Unknown MLFlow API error')) comments.append(status.get('message', 'Unknown MLFlow API error'))
self.send_notification( self.send_notification(
metadata=metadata, metadata=metadata,
@@ -402,16 +433,42 @@ class Gates(SientiaMonitoring):
attachment_content=trace, attachment_content=trace,
) )
triggered_policies = [policy for _, policy, _ in triggered_filters]
for path_flag in path_priority: for path_flag in path_priority:
if path_flag in filter_output: if path_flag in triggered_policies:
self.info(f'Mlflow response gate result: {path_flag}', metadata) self.info(f'Mlflow response gate result: {path_flag}', metadata)
return path_flag, mlflow_response_path_confidence[path_flag], ', '.join(comments)
matched = [
(fil, filter_config)
for fil, policy, filter_config in triggered_filters
if policy == path_flag
]
document = {
'filters': [
{
'name': fil,
'policy': path_flag,
'counter': mlflow_response_filter_counters.get(fil, lambda *_: 0)(
status, filter_config
),
}
for fil, filter_config in matched
],
}
return (
path_flag,
mlflow_response_path_confidence[path_flag],
', '.join(comments),
document,
)
self.info('Nothing was filtered by the mlflow response gate', metadata) self.info('Nothing was filtered by the mlflow response gate', metadata)
del data del data
return None, 0, '' return None, 0, '', None
@activity.defn(name='mlflow_content_gate') @activity.defn(name='mlflow_content_gate')
def mlflow_content_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]: def mlflow_content_gate(self, input_data: dict[str, Any]) -> tuple[str | None, int, str]:

View File

@@ -156,7 +156,7 @@ class PredictionProcess:
) )
# Validate MLFlow transform response # Validate MLFlow transform response
path_flag, confidence, comment = await workflow.execute_local_activity_method( path_flag, confidence, comment, document = await workflow.execute_local_activity_method(
Activities.mlflow_response_gate, Activities.mlflow_response_gate,
{ {
**metadata, **metadata,
@@ -171,7 +171,7 @@ class PredictionProcess:
# Handle path decision based on transform validation # Handle path decision based on transform validation
if await self.path_flag_handler( if await self.path_flag_handler(
data, path_flag, input_data, confidence, last_timestamp, comment data, path_flag, input_data, confidence, last_timestamp, comment, document
): ):
return return
@@ -206,7 +206,7 @@ class PredictionProcess:
) )
# Validate MLFlow prediction response # Validate MLFlow prediction response
path_flag, confidence, comment = await workflow.execute_local_activity_method( path_flag, confidence, comment, document = await workflow.execute_local_activity_method(
Activities.mlflow_response_gate, Activities.mlflow_response_gate,
{ {
**metadata, **metadata,
@@ -221,7 +221,7 @@ class PredictionProcess:
# Handle path decision based on prediction validation # Handle path decision based on prediction validation
if await self.path_flag_handler( if await self.path_flag_handler(
data, path_flag, input_data, confidence, last_timestamp, comment data, path_flag, input_data, confidence, last_timestamp, comment, document
): ):
return return
@@ -271,9 +271,9 @@ class PredictionProcess:
""" """
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
# `write_prediction_metadata` decide there's nothing to write — `mlflow_response_gate` # `write_prediction_metadata` decide there's nothing to write — `mlflow_content_gate`
# and `mlflow_content_gate` don't build a `document` yet, so this is the common case # doesn't build a `document` yet, so this is the common case today, not a rare edge
# today, not a rare edge case (review finding, 2026-10-01). # case (review finding, 2026-10-01).
return None return None
try: try:

View File

@@ -6,7 +6,13 @@ from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.metrics_controller import MetricsController from sientia_do.observability.metrics_controller import MetricsController
from sientia_do.observability.sientia_monitoring import SientiaMonitoring from sientia_do.observability.sientia_monitoring import SientiaMonitoring
from laborious.activities.gates import Gates, input_filter_counters, input_filter_functions from laborious.activities.gates import (
Gates,
input_filter_counters,
input_filter_functions,
mlflow_response_filter_counters,
mlflow_response_filter_functions,
)
@fixture(autouse=True) @fixture(autouse=True)
@@ -314,7 +320,7 @@ def test_mlflow_response_gate_invalid_filter(gates_activity):
result = gates_activity.mlflow_response_gate(input_data) result = gates_activity.mlflow_response_gate(input_data)
# Assert # Assert
assert result == (None, 0, '') assert result == (None, 0, '', None)
@patch('laborious.activities.gates.mlflow_response_filter_functions') @patch('laborious.activities.gates.mlflow_response_filter_functions')
@@ -341,7 +347,7 @@ def test_mlflow_response_gate_filter_exception(
result = gates_activity.mlflow_response_gate(input_data) result = gates_activity.mlflow_response_gate(input_data)
# Assert # Assert
assert result == (None, 0, '') assert result == (None, 0, '', None)
gates_activity.send_notification.assert_called_once_with( gates_activity.send_notification.assert_called_once_with(
metadata=metadata['metadata'], metadata=metadata['metadata'],
notification_id='MLFLOW_GATE_RESPONSE_FILTER__INVALID_FILTER', notification_id='MLFLOW_GATE_RESPONSE_FILTER__INVALID_FILTER',
@@ -369,7 +375,7 @@ def test_mlflow_response_gate_no_filters(gates_activity):
result = gates_activity.mlflow_response_gate(input_data) result = gates_activity.mlflow_response_gate(input_data)
# Assert # Assert
assert result == (None, 0, '') assert result == (None, 0, '', None)
gates_activity.debug.assert_called() gates_activity.debug.assert_called()
@@ -387,10 +393,11 @@ def test_mlflow_response_gate_with_filter(gates_activity):
} }
# Act # Act
result = gates_activity.mlflow_response_gate(input_data) path_flag, confidence, comment, document = gates_activity.mlflow_response_gate(input_data)
# Assert # Assert
assert result == ('STOP', -1, 'API error occurred') assert (path_flag, confidence, comment) == ('STOP', -1, 'API error occurred')
assert document == {'filters': [{'name': 'API_ERROR', 'policy': 'STOP', 'counter': 1}]}
gates_activity.debug.assert_called() gates_activity.debug.assert_called()
gates_activity.send_notification.assert_called() gates_activity.send_notification.assert_called()
@@ -412,7 +419,12 @@ def test_mlflow_response_gate_with_filter_capitalized_keys(gates_activity):
result = gates_activity.mlflow_response_gate(input_data) result = gates_activity.mlflow_response_gate(input_data)
# Assert # Assert
assert result == ('STOP', -1, 'API error occurred') assert result == (
'STOP',
-1,
'API error occurred',
{'filters': [{'name': 'API_ERROR', 'policy': 'STOP', 'counter': 1}]},
)
def test_mlflow_response_gate_with_filter_not_caught(gates_activity): def test_mlflow_response_gate_with_filter_not_caught(gates_activity):
@@ -432,10 +444,18 @@ def test_mlflow_response_gate_with_filter_not_caught(gates_activity):
result = gates_activity.mlflow_response_gate(input_data) result = gates_activity.mlflow_response_gate(input_data)
# Assert # Assert
assert result == (None, 0, '') assert result == (None, 0, '', None)
gates_activity.debug.assert_called() gates_activity.debug.assert_called()
def test_mlflow_response_filter_counters_covers_every_mlflow_response_filter_function():
# Same parity guard as `test_input_filter_counters_covers_every_input_filter_function`
# (review finding, 2026-09-30): a filter added to `mlflow_response_filter_functions` without
# a matching counter here degrades to counter=0 instead of crashing, but this test still
# catches the gap before it ships.
assert set(mlflow_response_filter_counters) == set(mlflow_response_filter_functions)
def test_mlflow_content_gate_invalid_filter(gates_activity): def test_mlflow_content_gate_invalid_filter(gates_activity):
# Arrange # Arrange
input_data = { input_data = {

View File

@@ -59,11 +59,11 @@ async def test_run(workflow_mock, prediction_process):
workflow_mock.execute_local_activity_method.side_effect = [ workflow_mock.execute_local_activity_method.side_effect = [
('continue', 0.95, 'Input data with bad quality', None), # input_gate ('continue', 0.95, 'Input data with bad quality', None), # input_gate
# mlflow_response_gate (transform) # mlflow_response_gate (transform)
('continue', 0.95, 'Error'), ('continue', 0.95, 'Error', None),
# mlflow_content_gate (transform) # mlflow_content_gate (transform)
('continue', 0.95, 'Transformed data not passed the content filter'), ('continue', 0.95, 'Transformed data not passed the content filter'),
# mlflow_response_gate (predict) # mlflow_response_gate (predict)
('continue', 0.95, 'Error'), ('continue', 0.95, 'Error', None),
] ]
# Act # Act
@@ -276,7 +276,7 @@ async def test_run_stop_at_first_mlflow_response_gate(workflow_mock, prediction_
] ]
workflow_mock.execute_local_activity_method.side_effect = [ workflow_mock.execute_local_activity_method.side_effect = [
('repeat', 0.95, 'Input data with bad quality', None), # input_gate ('repeat', 0.95, 'Input data with bad quality', None), # input_gate
('continue', 0.95, 'Error'), # mlflow_response_gate (transform) ('continue', 0.95, 'Error', None), # mlflow_response_gate (transform)
] ]
# Act # Act
@@ -367,7 +367,7 @@ async def test_run_stop_at_mlflow_content_gate(workflow_mock, prediction_process
workflow_mock.execute_local_activity_method.side_effect = [ workflow_mock.execute_local_activity_method.side_effect = [
('continue', 0.95, 'Input data with bad quality', None), # input_gate ('continue', 0.95, 'Input data with bad quality', None), # input_gate
# mlflow_response_gate (transform) # mlflow_response_gate (transform)
('continue', 0.95, 'Error'), ('continue', 0.95, 'Error', None),
# mlflow_content_gate (transform) # mlflow_content_gate (transform)
('continue', 0.95, 'Transformed data not passed the content filter'), ('continue', 0.95, 'Transformed data not passed the content filter'),
] ]
@@ -478,10 +478,10 @@ async def test_run_stop_at_mlflow_last_response_gate(workflow_mock, prediction_p
workflow_mock.execute_local_activity_method.side_effect = [ workflow_mock.execute_local_activity_method.side_effect = [
('continue', 0.95, 'Input data with bad quality', None), # input_gate ('continue', 0.95, 'Input data with bad quality', None), # input_gate
# mlflow_response_gate (transform) # mlflow_response_gate (transform)
('continue', 0.95, 'Error'), ('continue', 0.95, 'Error', None),
# mlflow_content_gate (transform) # mlflow_content_gate (transform)
('continue', 0.95, 'Transformed data not passed the content filter'), ('continue', 0.95, 'Transformed data not passed the content filter'),
('continue', 0.95, 'Error'), # mlflow_response_gate (predict) ('continue', 0.95, 'Error', None), # mlflow_response_gate (predict)
] ]
# Act # Act
@@ -696,9 +696,9 @@ async def test_path_flag_handler_repeat_no_document_skips_write_activity_call(
workflow_mock, prediction_process workflow_mock, prediction_process
): ):
# Arrange: no document (e.g. REPEAT triggered by a gate that doesn't build one yet — today, # Arrange: no document (e.g. REPEAT triggered by a gate that doesn't build one yet — today,
# `mlflow_response_gate`/`mlflow_content_gate`) must not even dispatch the remote # `mlflow_content_gate`) must not even dispatch the remote write_prediction_metadata
# write_prediction_metadata activity — that would be a wasted Temporal round-trip on every # activity — that would be a wasted Temporal round-trip on every REPEAT caused by that
# REPEAT caused by those two gates, not a rare edge case (review finding, 2026-10-01). # gate, not a rare edge case (review finding, 2026-10-01).
data = {'test': 'data'} data = {'test': 'data'}
# Act # Act
@@ -1075,9 +1075,9 @@ async def test_run_with_cleanup_prefixes(workflow_mock, prediction_process):
] ]
workflow_mock.execute_local_activity_method.side_effect = [ workflow_mock.execute_local_activity_method.side_effect = [
('continue', 0.95, 'ok', None), # input_gate ('continue', 0.95, 'ok', None), # input_gate
('continue', 0.95, ''), ('continue', 0.95, '', None), # mlflow_response_gate (transform)
('continue', 0.95, ''), ('continue', 0.95, ''), # mlflow_content_gate (transform)
('continue', 0.95, ''), ('continue', 0.95, '', None), # mlflow_response_gate (predict)
] ]
await prediction_process.run(input_data) await prediction_process.run(input_data)