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.
- flowsense/__init__.py +77 -0
- flowsense/application/__init__.py +20 -0
- flowsense/application/analyzer.py +68 -0
- flowsense/application/output.py +128 -0
- flowsense/application/pipeline.py +210 -0
- flowsense/application/ports.py +11 -0
- flowsense/application/request.py +32 -0
- flowsense/application/serialization.py +177 -0
- flowsense/cli/__init__.py +0 -0
- flowsense/cli/main.py +195 -0
- flowsense/cli/report.py +233 -0
- flowsense/collector/__init__.py +0 -0
- flowsense/collector/airflow_client.py +5 -0
- flowsense/config.py +72 -0
- flowsense/domain/__init__.py +52 -0
- flowsense/domain/enums.py +47 -0
- flowsense/domain/exceptions.py +25 -0
- flowsense/domain/models.py +19 -0
- flowsense/domain/policy.py +55 -0
- flowsense/domain/results.py +187 -0
- flowsense/engine/__init__.py +0 -0
- flowsense/engine/analyzer.py +17 -0
- flowsense/engine/change_point.py +87 -0
- flowsense/engine/drift.py +80 -0
- flowsense/engine/history.py +34 -0
- flowsense/engine/impact.py +51 -0
- flowsense/engine/propagation.py +173 -0
- flowsense/engine/root_cause.py +148 -0
- flowsense/engine/timing.py +221 -0
- flowsense/engine/trend.py +82 -0
- flowsense/infrastructure/__init__.py +1 -0
- flowsense/infrastructure/airflow/__init__.py +13 -0
- flowsense/infrastructure/airflow/client.py +373 -0
- flowsense/infrastructure/airflow/dto.py +33 -0
- flowsense/infrastructure/airflow/exceptions.py +31 -0
- flowsense/infrastructure/airflow/mapper.py +27 -0
- flowsense/mcp/__init__.py +0 -0
- flowsense/mcp/server.py +75 -0
- flowsense/models/__init__.py +7 -0
- flowsense/models/dag_analysis.py +3 -0
- flowsense/models/task_run.py +3 -0
- flowsense/py.typed +0 -0
- flowsense/version.py +6 -0
- flowsense_engine-0.2.1.dist-info/METADATA +447 -0
- flowsense_engine-0.2.1.dist-info/RECORD +49 -0
- flowsense_engine-0.2.1.dist-info/WHEEL +5 -0
- flowsense_engine-0.2.1.dist-info/entry_points.txt +3 -0
- flowsense_engine-0.2.1.dist-info/licenses/LICENSE +17 -0
- 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
|
+
]
|