flowsense-engine 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. flowsense/__init__.py +77 -0
  2. flowsense/application/__init__.py +20 -0
  3. flowsense/application/analyzer.py +68 -0
  4. flowsense/application/output.py +128 -0
  5. flowsense/application/pipeline.py +210 -0
  6. flowsense/application/ports.py +11 -0
  7. flowsense/application/request.py +32 -0
  8. flowsense/application/serialization.py +177 -0
  9. flowsense/cli/__init__.py +0 -0
  10. flowsense/cli/main.py +195 -0
  11. flowsense/cli/report.py +233 -0
  12. flowsense/collector/__init__.py +0 -0
  13. flowsense/collector/airflow_client.py +5 -0
  14. flowsense/config.py +72 -0
  15. flowsense/domain/__init__.py +52 -0
  16. flowsense/domain/enums.py +47 -0
  17. flowsense/domain/exceptions.py +25 -0
  18. flowsense/domain/models.py +19 -0
  19. flowsense/domain/policy.py +55 -0
  20. flowsense/domain/results.py +187 -0
  21. flowsense/engine/__init__.py +0 -0
  22. flowsense/engine/analyzer.py +17 -0
  23. flowsense/engine/change_point.py +87 -0
  24. flowsense/engine/drift.py +80 -0
  25. flowsense/engine/history.py +34 -0
  26. flowsense/engine/impact.py +51 -0
  27. flowsense/engine/propagation.py +173 -0
  28. flowsense/engine/root_cause.py +148 -0
  29. flowsense/engine/timing.py +221 -0
  30. flowsense/engine/trend.py +82 -0
  31. flowsense/infrastructure/__init__.py +1 -0
  32. flowsense/infrastructure/airflow/__init__.py +13 -0
  33. flowsense/infrastructure/airflow/client.py +373 -0
  34. flowsense/infrastructure/airflow/dto.py +33 -0
  35. flowsense/infrastructure/airflow/exceptions.py +31 -0
  36. flowsense/infrastructure/airflow/mapper.py +27 -0
  37. flowsense/mcp/__init__.py +0 -0
  38. flowsense/mcp/server.py +75 -0
  39. flowsense/models/__init__.py +7 -0
  40. flowsense/models/dag_analysis.py +3 -0
  41. flowsense/models/task_run.py +3 -0
  42. flowsense/py.typed +0 -0
  43. flowsense/version.py +6 -0
  44. flowsense_engine-0.2.1.dist-info/METADATA +447 -0
  45. flowsense_engine-0.2.1.dist-info/RECORD +49 -0
  46. flowsense_engine-0.2.1.dist-info/WHEEL +5 -0
  47. flowsense_engine-0.2.1.dist-info/entry_points.txt +3 -0
  48. flowsense_engine-0.2.1.dist-info/licenses/LICENSE +17 -0
  49. flowsense_engine-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from flowsense.domain import DriftResult, PropagationResult, Severity
4
+ from flowsense.domain.enums import SEVERITY_SCORE
5
+
6
+ _HOP_DECAY = 0.8
7
+ _MAX_SEVERITY_SCORE = max(SEVERITY_SCORE.values())
8
+
9
+
10
+ def _build_reverse_dependencies(
11
+ dependencies: dict[str, list[str]],
12
+ ) -> dict[str, list[str]]:
13
+ reverse_dependencies: dict[str, list[str]] = {
14
+ task_id: [] for task_id in dependencies
15
+ }
16
+
17
+ for upstream_task, downstream_tasks in dependencies.items():
18
+ for downstream_task in downstream_tasks:
19
+ reverse_dependencies.setdefault(downstream_task, []).append(upstream_task)
20
+
21
+ return reverse_dependencies
22
+
23
+
24
+ def _has_anomalous_upstream(
25
+ task_id: str,
26
+ drift_results: dict[str, DriftResult],
27
+ reverse_dependencies: dict[str, list[str]],
28
+ visited: set[str] | None = None,
29
+ ) -> bool:
30
+ if visited is None:
31
+ visited = set()
32
+
33
+ if task_id in visited:
34
+ return False
35
+
36
+ visited = {*visited, task_id}
37
+
38
+ for upstream_task in reverse_dependencies.get(task_id, []):
39
+ upstream_drift = drift_results.get(upstream_task)
40
+
41
+ if upstream_drift is None or upstream_drift.severity == Severity.NORMAL:
42
+ continue
43
+
44
+ if upstream_drift.severity in {
45
+ Severity.HIGH,
46
+ Severity.CRITICAL,
47
+ }:
48
+ return True
49
+
50
+ if _has_anomalous_upstream(
51
+ task_id=upstream_task,
52
+ drift_results=drift_results,
53
+ reverse_dependencies=reverse_dependencies,
54
+ visited=visited,
55
+ ):
56
+ return True
57
+
58
+ return False
59
+
60
+
61
+ def _find_propagation_paths(
62
+ origin_task: str,
63
+ current_task: str,
64
+ drift_results: dict[str, DriftResult],
65
+ dependencies: dict[str, list[str]],
66
+ path: list[str],
67
+ visited: set[str],
68
+ ) -> list[list[str]]:
69
+ paths: list[list[str]] = []
70
+
71
+ for downstream_task in dependencies.get(current_task, []):
72
+ if downstream_task in visited:
73
+ continue
74
+
75
+ downstream_drift = drift_results.get(downstream_task)
76
+
77
+ if downstream_drift is None:
78
+ continue
79
+
80
+ if downstream_drift.severity == Severity.NORMAL:
81
+ continue
82
+
83
+ next_path = [*path, downstream_task]
84
+ next_visited = {*visited, downstream_task}
85
+
86
+ child_paths = _find_propagation_paths(
87
+ origin_task=origin_task,
88
+ current_task=downstream_task,
89
+ drift_results=drift_results,
90
+ dependencies=dependencies,
91
+ path=next_path,
92
+ visited=next_visited,
93
+ )
94
+
95
+ if child_paths:
96
+ paths.extend(child_paths)
97
+ else:
98
+ paths.append(next_path)
99
+
100
+ return paths
101
+
102
+
103
+ def _calculate_propagation_score(
104
+ affected_tasks: list[str],
105
+ drift_results: dict[str, DriftResult],
106
+ ) -> float:
107
+ """Return a bounded, distance-weighted score for a propagation path."""
108
+ weighted_severity = 0.0
109
+ total_weight = 0.0
110
+
111
+ for hop, task_id in enumerate(affected_tasks):
112
+ weight = _HOP_DECAY**hop
113
+ normalized_severity = (
114
+ SEVERITY_SCORE[drift_results[task_id].severity] / _MAX_SEVERITY_SCORE
115
+ )
116
+ weighted_severity += normalized_severity * weight
117
+ total_weight += weight
118
+
119
+ if total_weight == 0.0:
120
+ return 0.0
121
+
122
+ return weighted_severity / total_weight
123
+
124
+
125
+ def analyze_propagation(
126
+ drift_results: dict[str, DriftResult],
127
+ dependencies: dict[str, list[str]],
128
+ ) -> list[PropagationResult]:
129
+ results: list[PropagationResult] = []
130
+
131
+ reverse_dependencies = _build_reverse_dependencies(dependencies)
132
+
133
+ for task_id, drift in drift_results.items():
134
+ if drift.severity not in {Severity.HIGH, Severity.CRITICAL}:
135
+ continue
136
+
137
+ if _has_anomalous_upstream(
138
+ task_id=task_id,
139
+ drift_results=drift_results,
140
+ reverse_dependencies=reverse_dependencies,
141
+ ):
142
+ continue
143
+
144
+ paths = _find_propagation_paths(
145
+ origin_task=task_id,
146
+ current_task=task_id,
147
+ drift_results=drift_results,
148
+ dependencies=dependencies,
149
+ path=[task_id],
150
+ visited={task_id},
151
+ )
152
+
153
+ for path in paths:
154
+ affected_tasks = path[1:]
155
+
156
+ if not affected_tasks:
157
+ continue
158
+
159
+ propagation_score = _calculate_propagation_score(
160
+ affected_tasks=affected_tasks,
161
+ drift_results=drift_results,
162
+ )
163
+
164
+ results.append(
165
+ PropagationResult(
166
+ origin_task=task_id,
167
+ affected_tasks=affected_tasks,
168
+ path=path,
169
+ propagation_score=propagation_score,
170
+ )
171
+ )
172
+
173
+ return results
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from flowsense.domain import (
4
+ DriftResult,
5
+ ImpactClassification,
6
+ PropagationResult,
7
+ RootCauseResult,
8
+ Severity,
9
+ TaskImpact,
10
+ )
11
+ from flowsense.domain.enums import SEVERITY_SCORE
12
+
13
+
14
+ def _build_reverse_dependencies(
15
+ dependencies: dict[str, list[str]],
16
+ ) -> dict[str, list[str]]:
17
+ reverse_dependencies: dict[str, list[str]] = {}
18
+
19
+ for upstream_task, downstream_tasks in dependencies.items():
20
+ reverse_dependencies.setdefault(upstream_task, [])
21
+
22
+ for downstream_task in downstream_tasks:
23
+ reverse_dependencies.setdefault(
24
+ downstream_task,
25
+ [],
26
+ ).append(upstream_task)
27
+
28
+ return reverse_dependencies
29
+
30
+
31
+ def _has_candidate_upstream(
32
+ task_id: str,
33
+ candidate_tasks: set[str],
34
+ drift_results: dict[str, DriftResult],
35
+ reverse_dependencies: dict[str, list[str]],
36
+ visited: set[str] | None = None,
37
+ ) -> bool:
38
+ if visited is None:
39
+ visited = set()
40
+
41
+ if task_id in visited:
42
+ return False
43
+
44
+ visited.add(task_id)
45
+
46
+ for upstream_task in reverse_dependencies.get(task_id, []):
47
+ upstream_drift = drift_results.get(upstream_task)
48
+
49
+ if upstream_drift is None or upstream_drift.severity == Severity.NORMAL:
50
+ continue
51
+
52
+ if upstream_task in candidate_tasks:
53
+ return True
54
+
55
+ if _has_candidate_upstream(
56
+ task_id=upstream_task,
57
+ candidate_tasks=candidate_tasks,
58
+ drift_results=drift_results,
59
+ reverse_dependencies=reverse_dependencies,
60
+ visited=visited,
61
+ ):
62
+ return True
63
+
64
+ return False
65
+
66
+
67
+ def select_primary_origin(
68
+ drift_results: dict[str, DriftResult],
69
+ task_impacts: dict[str, TaskImpact],
70
+ dependencies: dict[str, list[str]],
71
+ propagation_results: list[PropagationResult],
72
+ ) -> RootCauseResult | None:
73
+ candidate_tasks = {
74
+ task_id
75
+ for task_id, impact in task_impacts.items()
76
+ if impact.classification
77
+ in {
78
+ ImpactClassification.OWN_DRIFT,
79
+ ImpactClassification.COMBINED,
80
+ }
81
+ }
82
+
83
+ if not candidate_tasks:
84
+ return None
85
+
86
+ reverse_dependencies = _build_reverse_dependencies(
87
+ dependencies,
88
+ )
89
+
90
+ root_candidates = {
91
+ task_id
92
+ for task_id in candidate_tasks
93
+ if not _has_candidate_upstream(
94
+ task_id=task_id,
95
+ candidate_tasks=candidate_tasks,
96
+ drift_results=drift_results,
97
+ reverse_dependencies=reverse_dependencies,
98
+ )
99
+ }
100
+
101
+ if not root_candidates:
102
+ root_candidates = candidate_tasks
103
+
104
+ propagation_scores: dict[str, float] = {}
105
+
106
+ for result in propagation_results:
107
+ current_score = propagation_scores.get(
108
+ result.origin_task,
109
+ 0.0,
110
+ )
111
+
112
+ propagation_scores[result.origin_task] = max(
113
+ current_score,
114
+ result.propagation_score,
115
+ )
116
+
117
+ def ranking(task_id: str) -> tuple[float, int]:
118
+ propagation_score = propagation_scores.get(
119
+ task_id,
120
+ 0.0,
121
+ )
122
+
123
+ drift = drift_results.get(task_id)
124
+
125
+ severity_score = SEVERITY_SCORE[drift.severity] if drift else 0
126
+
127
+ return (
128
+ propagation_score,
129
+ severity_score,
130
+ )
131
+
132
+ primary_task = max(
133
+ root_candidates,
134
+ key=ranking,
135
+ )
136
+
137
+ impact = task_impacts[primary_task]
138
+ drift = drift_results[primary_task]
139
+
140
+ return RootCauseResult(
141
+ task_id=primary_task,
142
+ classification=impact.classification,
143
+ severity=drift.severity,
144
+ propagation_score=propagation_scores.get(
145
+ primary_task,
146
+ 0.0,
147
+ ),
148
+ )
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from flowsense.domain import (
7
+ DEFAULT_ANALYSIS_POLICY,
8
+ AnalysisPolicy,
9
+ DriftResult,
10
+ InvalidTaskTimingError,
11
+ TaskRun,
12
+ )
13
+ from flowsense.engine.drift import calculate_drift
14
+
15
+
16
+ @dataclass
17
+ class HandoffTiming:
18
+ upstream_task: str
19
+ downstream_task: str
20
+ dag_run_id: str
21
+ handoff_delay: float
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class HandoffHistoryDiagnostic:
26
+ code: str
27
+ upstream_task: str
28
+ downstream_task: str
29
+ dag_run_id: str
30
+ message: str
31
+
32
+
33
+ @dataclass
34
+ class HandoffHistoryResult:
35
+ history: dict[tuple[str, str], list[float]]
36
+ diagnostics: list[HandoffHistoryDiagnostic]
37
+
38
+
39
+ def _required_end_date(task_run: TaskRun) -> datetime:
40
+ if task_run.end_date is None:
41
+ raise InvalidTaskTimingError("Upstream task end_date is required.")
42
+ return task_run.end_date
43
+
44
+
45
+ def _required_start_date(task_run: TaskRun) -> datetime:
46
+ if task_run.start_date is None:
47
+ raise InvalidTaskTimingError("Downstream task start_date is required.")
48
+ return task_run.start_date
49
+
50
+
51
+ def calculate_handoff_delay(
52
+ upstream_run: TaskRun,
53
+ downstream_run: TaskRun,
54
+ ) -> HandoffTiming:
55
+ if upstream_run.end_date is None:
56
+ raise InvalidTaskTimingError("Upstream task end_date is required.")
57
+
58
+ if downstream_run.start_date is None:
59
+ raise InvalidTaskTimingError("Downstream task start_date is required.")
60
+
61
+ if upstream_run.dag_run_id != downstream_run.dag_run_id:
62
+ raise InvalidTaskTimingError("Task runs must belong to the same DAG run.")
63
+
64
+ handoff_delay = (downstream_run.start_date - upstream_run.end_date).total_seconds()
65
+
66
+ return HandoffTiming(
67
+ upstream_task=upstream_run.task_id,
68
+ downstream_task=downstream_run.task_id,
69
+ dag_run_id=upstream_run.dag_run_id,
70
+ handoff_delay=handoff_delay,
71
+ )
72
+
73
+
74
+ def build_handoff_history(
75
+ task_runs: list[TaskRun],
76
+ dependencies: dict[str, list[str]],
77
+ ) -> dict[tuple[str, str], list[float]]:
78
+ return build_handoff_history_with_diagnostics(
79
+ task_runs=task_runs,
80
+ dependencies=dependencies,
81
+ ).history
82
+
83
+
84
+ def build_handoff_history_with_diagnostics(
85
+ task_runs: list[TaskRun],
86
+ dependencies: dict[str, list[str]],
87
+ ) -> HandoffHistoryResult:
88
+ """Build logical-edge history across regular and dynamically mapped tasks.
89
+
90
+ A mapped upstream is complete at its latest instance end, while a mapped
91
+ downstream starts at its earliest instance start.
92
+ """
93
+ runs_by_id: dict[str, dict[str, list[TaskRun]]] = {}
94
+
95
+ for task_run in task_runs:
96
+ tasks_in_run = runs_by_id.setdefault(task_run.dag_run_id, {})
97
+ tasks_in_run.setdefault(task_run.task_id, []).append(task_run)
98
+
99
+ history: dict[tuple[str, str], list[float]] = {}
100
+ diagnostics: list[HandoffHistoryDiagnostic] = []
101
+
102
+ for dag_run_id, tasks_in_run in runs_by_id.items():
103
+ for upstream_task, downstream_tasks in dependencies.items():
104
+ upstream_runs = tasks_in_run.get(upstream_task, [])
105
+
106
+ if not upstream_runs:
107
+ for downstream_task in downstream_tasks:
108
+ diagnostics.append(
109
+ HandoffHistoryDiagnostic(
110
+ code="MISSING_UPSTREAM_TASK_RUN",
111
+ upstream_task=upstream_task,
112
+ downstream_task=downstream_task,
113
+ dag_run_id=dag_run_id,
114
+ message=(
115
+ f"{upstream_task} has no task run in {dag_run_id}."
116
+ ),
117
+ )
118
+ )
119
+ continue
120
+
121
+ if any(task_run.end_date is None for task_run in upstream_runs):
122
+ for downstream_task in downstream_tasks:
123
+ diagnostics.append(
124
+ HandoffHistoryDiagnostic(
125
+ code="MISSING_UPSTREAM_END_DATE",
126
+ upstream_task=upstream_task,
127
+ downstream_task=downstream_task,
128
+ dag_run_id=dag_run_id,
129
+ message=(
130
+ f"{upstream_task} has no complete end_date in "
131
+ f"{dag_run_id}."
132
+ ),
133
+ )
134
+ )
135
+ continue
136
+
137
+ upstream_run = max(
138
+ upstream_runs,
139
+ key=_required_end_date,
140
+ )
141
+
142
+ for downstream_task in downstream_tasks:
143
+ downstream_runs = tasks_in_run.get(downstream_task, [])
144
+
145
+ if not downstream_runs:
146
+ diagnostics.append(
147
+ HandoffHistoryDiagnostic(
148
+ code="MISSING_DOWNSTREAM_TASK_RUN",
149
+ upstream_task=upstream_task,
150
+ downstream_task=downstream_task,
151
+ dag_run_id=dag_run_id,
152
+ message=(
153
+ f"{downstream_task} has no task run in {dag_run_id}."
154
+ ),
155
+ )
156
+ )
157
+ continue
158
+
159
+ if any(task_run.start_date is None for task_run in downstream_runs):
160
+ diagnostics.append(
161
+ HandoffHistoryDiagnostic(
162
+ code="MISSING_DOWNSTREAM_START_DATE",
163
+ upstream_task=upstream_task,
164
+ downstream_task=downstream_task,
165
+ dag_run_id=dag_run_id,
166
+ message=(
167
+ f"{downstream_task} has no complete start_date in "
168
+ f"{dag_run_id}."
169
+ ),
170
+ )
171
+ )
172
+ continue
173
+
174
+ downstream_run = min(
175
+ downstream_runs,
176
+ key=_required_start_date,
177
+ )
178
+
179
+ try:
180
+ timing = calculate_handoff_delay(
181
+ upstream_run=upstream_run,
182
+ downstream_run=downstream_run,
183
+ )
184
+ except InvalidTaskTimingError as exc:
185
+ diagnostics.append(
186
+ HandoffHistoryDiagnostic(
187
+ code="INVALID_HANDOFF_TIMING",
188
+ upstream_task=upstream_task,
189
+ downstream_task=downstream_task,
190
+ dag_run_id=dag_run_id,
191
+ message=str(exc),
192
+ )
193
+ )
194
+ continue
195
+
196
+ edge = (
197
+ upstream_task,
198
+ downstream_task,
199
+ )
200
+
201
+ history.setdefault(edge, []).append(timing.handoff_delay)
202
+
203
+ return HandoffHistoryResult(
204
+ history=history,
205
+ diagnostics=diagnostics,
206
+ )
207
+
208
+
209
+ def calculate_handoff_drift(
210
+ upstream_task: str,
211
+ downstream_task: str,
212
+ handoff_delays: list[float],
213
+ policy: AnalysisPolicy = DEFAULT_ANALYSIS_POLICY,
214
+ ) -> DriftResult:
215
+ edge_id = f"{upstream_task}->{downstream_task}"
216
+
217
+ return calculate_drift(
218
+ task_id=edge_id,
219
+ durations=handoff_delays,
220
+ policy=policy,
221
+ )
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ from flowsense.domain import TrendDirection, TrendResult
8
+
9
+
10
+ def _theil_sen_slope(observations: np.ndarray) -> float:
11
+ slopes = [
12
+ float((observations[end] - observations[start]) / (end - start))
13
+ for start in range(len(observations) - 1)
14
+ for end in range(start + 1, len(observations))
15
+ ]
16
+ return float(np.median(slopes))
17
+
18
+
19
+ def detect_trend(
20
+ subject_id: str,
21
+ values: list[float],
22
+ *,
23
+ minimum_observations: int = 5,
24
+ score_threshold: float = 3.5,
25
+ minimum_directional_consistency: float = 0.6,
26
+ ) -> TrendResult | None:
27
+ """Detect a sustained linear trend using a robust Theil-Sen slope."""
28
+ if minimum_observations < 3:
29
+ raise ValueError("minimum_observations must be at least 3")
30
+
31
+ if score_threshold <= 0:
32
+ raise ValueError("score_threshold must be positive")
33
+
34
+ if not 0 < minimum_directional_consistency <= 1:
35
+ raise ValueError("minimum_directional_consistency must be in (0, 1]")
36
+
37
+ if len(values) < minimum_observations:
38
+ return None
39
+
40
+ observations = np.asarray(values, dtype=float)
41
+ slope = _theil_sen_slope(observations)
42
+
43
+ if math.isclose(slope, 0.0):
44
+ return None
45
+
46
+ consecutive_changes = np.diff(observations)
47
+ aligned_changes = np.count_nonzero(consecutive_changes * slope > 0)
48
+ directional_consistency = float(aligned_changes / len(consecutive_changes))
49
+
50
+ if directional_consistency < minimum_directional_consistency:
51
+ return None
52
+
53
+ indices = np.arange(len(observations), dtype=float)
54
+ intercept = float(np.median(observations - slope * indices))
55
+ residuals = np.abs(observations - (intercept + slope * indices))
56
+ residual_mad = float(np.median(residuals))
57
+ estimated_change = slope * (len(observations) - 1)
58
+
59
+ if math.isclose(residual_mad, 0.0):
60
+ score = 5.0
61
+ else:
62
+ score = abs(0.6745 * estimated_change / residual_mad)
63
+
64
+ if score < score_threshold:
65
+ return None
66
+
67
+ change_percent = (
68
+ estimated_change / intercept * 100 if not math.isclose(intercept, 0.0) else None
69
+ )
70
+
71
+ return TrendResult(
72
+ subject_id=subject_id,
73
+ direction=(
74
+ TrendDirection.INCREASING if slope > 0 else TrendDirection.DECREASING
75
+ ),
76
+ slope_per_observation=slope,
77
+ estimated_change=estimated_change,
78
+ change_percent=change_percent,
79
+ score=score,
80
+ directional_consistency=directional_consistency,
81
+ observations=len(observations),
82
+ )
@@ -0,0 +1 @@
1
+ """Infrastructure adapters for external systems."""
@@ -0,0 +1,13 @@
1
+ from flowsense.infrastructure.airflow.client import AirflowClient
2
+ from flowsense.infrastructure.airflow.exceptions import (
3
+ AirflowApiError,
4
+ AirflowDagRunNotFoundError,
5
+ AirflowDataError,
6
+ )
7
+
8
+ __all__ = [
9
+ "AirflowApiError",
10
+ "AirflowClient",
11
+ "AirflowDagRunNotFoundError",
12
+ "AirflowDataError",
13
+ ]