Coverage for python/pyairflowtester/dependency_intelligence/intelligence.py: 80%

209 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 20:43 +0530

1"""Intelligence engines for advanced analysis (Phase 3: Weeks 9-12).""" 

2 

3import logging 

4from dataclasses import dataclass 

5from datetime import datetime, timedelta 

6from typing import Dict, List, Optional 

7 

8from .analytics import TestCoverageAnalyzer 

9from .graph import DependencyGraphEngine 

10from .models import ( 

11 DependencyGraph, 

12 NodeSeverity, 

13) 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18@dataclass 

19class FailurePrediction: 

20 """Failure prediction for a node.""" 

21 

22 node_id: str 

23 failure_probability: float # 0.0-1.0 

24 confidence: float 

25 contributing_factors: List[str] 

26 predicted_at: datetime 

27 time_to_failure: Optional[timedelta] 

28 

29 

30@dataclass 

31class AnomalyDetection: 

32 """Detected anomalies in dependency patterns.""" 

33 

34 anomaly_type: str # unusual_connectivity, missing_tests, abandoned_node 

35 node_id: str 

36 severity: NodeSeverity 

37 details: str 

38 detected_at: datetime 

39 

40 

41@dataclass 

42class Recommendation: 

43 """Intelligence-based recommendation.""" 

44 

45 recommendation_type: str # refactor, add_sla, increase_tests, optimize 

46 node_id: str 

47 priority: str # critical, high, medium, low 

48 action: str 

49 expected_benefit: str 

50 effort: str 

51 

52 

53@dataclass 

54class HealthScore: 

55 """Overall health score for dependency graph.""" 

56 

57 overall_score: float # 0-100 

58 coverage_score: float 

59 connectivity_score: float 

60 ownership_score: float 

61 test_score: float 

62 issues_count: int 

63 critical_issues: int 

64 

65 

66class FailurePredictionEngine: 

67 """Predict likelihood of node failures.""" 

68 

69 def __init__( 

70 self, 

71 graph: DependencyGraph, 

72 test_analyzer: Optional[TestCoverageAnalyzer] = None, 

73 ): 

74 self.graph = graph 

75 self.engine = DependencyGraphEngine(graph) 

76 self.test_analyzer = test_analyzer or TestCoverageAnalyzer(graph) 

77 self.failure_history: Dict[str, List[datetime]] = {} 

78 

79 def record_failure(self, node_id: str) -> None: 

80 """Record a failure event.""" 

81 if node_id not in self.failure_history: 

82 self.failure_history[node_id] = [] 

83 self.failure_history[node_id].append(datetime.utcnow()) 

84 

85 def predict_node_failure(self, node_id: str) -> FailurePrediction: 

86 """ 

87 Predict failure probability for a node. 

88 

89 Factors: 

90 - Historical failure rate 

91 - Dependencies on failing nodes 

92 - Test coverage 

93 - Complexity 

94 - Age of last change 

95 """ 

96 node = self.graph.nodes.get(node_id) 

97 if not node: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 return FailurePrediction( 

99 node_id=node_id, 

100 failure_probability=0.0, 

101 confidence=0.0, 

102 contributing_factors=[], 

103 predicted_at=datetime.utcnow(), 

104 time_to_failure=None, 

105 ) 

106 

107 probability = 0.0 

108 confidence = 0.5 

109 factors = [] 

110 

111 # Factor 1: Historical failure rate 

112 if node_id in self.failure_history: 

113 failures = self.failure_history[node_id] 

114 if len(failures) > 0: 114 ↛ 123line 114 didn't jump to line 123 because the condition on line 114 was always true

115 # Calculate failure frequency 

116 days_of_data = 30 # Assume 30 days of history 

117 failure_rate = len(failures) / days_of_data 

118 probability += min(0.3, failure_rate) 

119 factors.append(f"Historical failure rate: {failure_rate:.1%}") 

120 confidence = min(1.0, confidence + 0.2) 

121 

122 # Factor 2: Upstream failures impact 

123 upstream = self.engine.get_upstream_nodes(node_id) 

124 failing_upstream = [ 

125 u for u in upstream if u in self.failure_history and len(self.failure_history[u]) > 0 

126 ] 

127 if failing_upstream: 

128 probability += 0.2 * (len(failing_upstream) / max(1, len(upstream))) 

129 factors.append(f"{len(failing_upstream)} upstream nodes have failed") 

130 confidence = min(1.0, confidence + 0.1) 

131 

132 # Factor 3: Test coverage 

133 test_count = self.test_analyzer.analyze_coverage(node_id).total_tests 

134 if test_count == 0: 

135 probability += 0.15 

136 factors.append("No test coverage") 

137 

138 # Factor 4: Complexity (high downstream = higher risk) 

139 downstream = self.engine.get_downstream_nodes(node_id) 

140 if len(downstream) > 100: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 probability += 0.1 

142 factors.append(f"High complexity: {len(downstream)} downstream nodes") 

143 

144 # Factor 5: Node severity (critical nodes maintained better) 

145 if node.severity == NodeSeverity.LOW: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 probability += 0.05 

147 factors.append("Low severity (may lack maintenance)") 

148 

149 probability = min(1.0, probability) 

150 

151 # Estimate time to failure based on frequency 

152 time_to_failure = None 

153 if probability > 0.3: 

154 # Convert probability to estimated hours until failure 

155 hours_until = int(max(1, (1.0 - probability) * 168)) # 168 hours/week 

156 time_to_failure = timedelta(hours=hours_until) 

157 

158 return FailurePrediction( 

159 node_id=node_id, 

160 failure_probability=probability, 

161 confidence=confidence, 

162 contributing_factors=factors, 

163 predicted_at=datetime.utcnow(), 

164 time_to_failure=time_to_failure, 

165 ) 

166 

167 def predict_all_nodes(self) -> Dict[str, FailurePrediction]: 

168 """Predict failures for all nodes.""" 

169 return {node_id: self.predict_node_failure(node_id) for node_id in self.graph.nodes} 

170 

171 def get_high_risk_nodes(self, threshold: float = 0.5) -> List[FailurePrediction]: 

172 """Get nodes with failure probability above threshold.""" 

173 predictions = self.predict_all_nodes() 

174 return sorted( 

175 [p for p in predictions.values() if p.failure_probability >= threshold], 

176 key=lambda p: p.failure_probability, 

177 reverse=True, 

178 ) 

179 

180 

181class AnomalyDetector: 

182 """Detect anomalies in dependency patterns.""" 

183 

184 def __init__(self, graph: DependencyGraph): 

185 self.graph = graph 

186 self.engine = DependencyGraphEngine(graph) 

187 

188 def detect_all_anomalies(self) -> List[AnomalyDetection]: 

189 """Detect all anomalies in the graph.""" 

190 anomalies = [] 

191 

192 # Detect isolated nodes 

193 orphans = self.engine.detect_orphans() 

194 for isolated in orphans["isolated"]: 

195 node = self.graph.nodes.get(isolated) 

196 if node: 196 ↛ 194line 196 didn't jump to line 194 because the condition on line 196 was always true

197 anomalies.append( 

198 AnomalyDetection( 

199 anomaly_type="isolated_node", 

200 node_id=isolated, 

201 severity=( 

202 NodeSeverity.HIGH 

203 if node.severity == NodeSeverity.CRITICAL 

204 else NodeSeverity.MEDIUM 

205 ), 

206 details=f"Node {node.name} has no dependencies or dependents", 

207 detected_at=datetime.utcnow(), 

208 ) 

209 ) 

210 

211 # Detect unusual connectivity patterns 

212 centrality = self.engine.get_node_centrality() 

213 avg_centrality = sum(centrality.values()) / len(centrality) if centrality else 0 

214 high_centrality = {n: c for n, c in centrality.items() if c > avg_centrality * 3} 

215 

216 for node_id, score in high_centrality.items(): 216 ↛ 217line 216 didn't jump to line 217 because the loop on line 216 never started

217 node = self.graph.nodes.get(node_id) 

218 if node: 

219 anomalies.append( 

220 AnomalyDetection( 

221 anomaly_type="high_centrality", 

222 node_id=node_id, 

223 severity=NodeSeverity.MEDIUM, 

224 details=f"Node {node.name} has unusually high connectivity ({score:.1%})", 

225 detected_at=datetime.utcnow(), 

226 ) 

227 ) 

228 

229 # Detect cycles (inherent anomalies) 

230 cycles = self.engine.detect_cycles() 

231 if cycles: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 for cycle in cycles[:5]: # Report first 5 cycles 

233 if cycle: 

234 anomalies.append( 

235 AnomalyDetection( 

236 anomaly_type="circular_dependency", 

237 node_id=cycle[0], 

238 severity=NodeSeverity.HIGH, 

239 details=f"Circular dependency: {' -> '.join(cycle[:3])}...", 

240 detected_at=datetime.utcnow(), 

241 ) 

242 ) 

243 

244 # Detect unowned critical nodes 

245 for node in self.graph.nodes.values(): 

246 if node.severity == NodeSeverity.CRITICAL and not node.owner: 

247 anomalies.append( 

248 AnomalyDetection( 

249 anomaly_type="unowned_critical", 

250 node_id=node.id, 

251 severity=NodeSeverity.HIGH, 

252 details=f"Critical node {node.name} has no owner assigned", 

253 detected_at=datetime.utcnow(), 

254 ) 

255 ) 

256 

257 return anomalies 

258 

259 

260class RecommendationEngine: 

261 """Generate intelligence-based recommendations.""" 

262 

263 def __init__(self, graph: DependencyGraph): 

264 self.graph = graph 

265 self.engine = DependencyGraphEngine(graph) 

266 

267 def generate_recommendations(self) -> List[Recommendation]: 

268 """Generate all recommendations.""" 

269 recommendations = [] 

270 

271 # Recommendation 1: Refactor high-centrality nodes 

272 centrality = self.engine.get_node_centrality() 

273 avg_centrality = sum(centrality.values()) / len(centrality) if centrality else 0 

274 high_centrality_nodes = sorted( 

275 [(n, c) for n, c in centrality.items() if c > avg_centrality * 2], 

276 key=lambda x: x[1], 

277 reverse=True, 

278 ) 

279 

280 for node_id, score in high_centrality_nodes[:5]: 280 ↛ 281line 280 didn't jump to line 281 because the loop on line 280 never started

281 node = self.graph.nodes.get(node_id) 

282 if node: 

283 recommendations.append( 

284 Recommendation( 

285 recommendation_type="refactor_centrality", 

286 node_id=node_id, 

287 priority="high" if score > avg_centrality * 3 else "medium", 

288 action=f"Refactor {node.name} to reduce connectivity", 

289 expected_benefit="Reduced coupling, easier maintenance", 

290 effort="2-3 days", 

291 ) 

292 ) 

293 

294 # Recommendation 2: Add SLAs to critical nodes 

295 critical_without_sla = [ 

296 n.id for n in self.graph.nodes.values() if n.severity == NodeSeverity.CRITICAL 

297 ] 

298 

299 for node_id in critical_without_sla[:10]: 

300 node = self.graph.nodes.get(node_id) 

301 if node: 301 ↛ 299line 301 didn't jump to line 299 because the condition on line 301 was always true

302 downstream_count = len(self.engine.get_downstream_nodes(node_id)) 

303 recommendations.append( 

304 Recommendation( 

305 recommendation_type="add_sla", 

306 node_id=node_id, 

307 priority="critical", 

308 action=f"Define SLA for {node.name}", 

309 expected_benefit=f"Monitor {downstream_count} downstream nodes", 

310 effort="1-2 hours", 

311 ) 

312 ) 

313 

314 # Recommendation 3: Improve test coverage 

315 # (Would integrate with TestCoverageAnalyzer) 

316 poorly_tested = [ 

317 n.id 

318 for n in self.graph.nodes.values() 

319 if n.severity in (NodeSeverity.CRITICAL, NodeSeverity.HIGH) 

320 ][:5] 

321 

322 for node_id in poorly_tested: 

323 node = self.graph.nodes.get(node_id) 

324 if node: 324 ↛ 322line 324 didn't jump to line 322 because the condition on line 324 was always true

325 recommendations.append( 

326 Recommendation( 

327 recommendation_type="improve_tests", 

328 node_id=node_id, 

329 priority="high", 

330 action=f"Add tests for {node.name}", 

331 expected_benefit="Reduce failure probability by 30%", 

332 effort="2-3 days", 

333 ) 

334 ) 

335 

336 # Recommendation 4: Establish ownership 

337 unowned = [n for n in self.graph.nodes.values() if not n.owner] 

338 for node in unowned[:5]: 338 ↛ 339line 338 didn't jump to line 339 because the loop on line 338 never started

339 recommendations.append( 

340 Recommendation( 

341 recommendation_type="establish_ownership", 

342 node_id=node.id, 

343 priority="high" if node.severity == NodeSeverity.CRITICAL else "medium", 

344 action=f"Assign owner to {node.name}", 

345 expected_benefit="Clear responsibility, faster response", 

346 effort="1 hour", 

347 ) 

348 ) 

349 

350 return sorted( 

351 recommendations, 

352 key=lambda r: {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(r.priority, 4), 

353 ) 

354 

355 def get_top_recommendations(self, limit: int = 10) -> List[Recommendation]: 

356 """Get top N recommendations.""" 

357 return self.generate_recommendations()[:limit] 

358 

359 

360class HealthScoreCalculator: 

361 """Calculate overall health score for dependency graph.""" 

362 

363 def __init__( 

364 self, 

365 graph: DependencyGraph, 

366 test_analyzer: Optional[TestCoverageAnalyzer] = None, 

367 ): 

368 self.graph = graph 

369 self.engine = DependencyGraphEngine(graph) 

370 self.test_analyzer = test_analyzer or TestCoverageAnalyzer(graph) 

371 

372 def calculate_health_score(self) -> HealthScore: 

373 """ 

374 Calculate comprehensive health score. 

375 

376 Components: 

377 - Coverage: percentage of nodes with metadata 

378 - Connectivity: balance between isolated and over-connected 

379 - Ownership: percentage of nodes with assigned owners 

380 - Tests: percentage of nodes with test coverage 

381 """ 

382 # Coverage score (0-20) 

383 coverage_score = self._calculate_coverage_score() 

384 

385 # Connectivity score (0-20) 

386 connectivity_score = self._calculate_connectivity_score() 

387 

388 # Ownership score (0-20) 

389 ownership_score = self._calculate_ownership_score() 

390 

391 # Test score (0-20) 

392 test_score = self._calculate_test_score() 

393 

394 # SLA score (0-20) 

395 sla_score = self._calculate_sla_score() 

396 

397 overall = coverage_score + connectivity_score + ownership_score + test_score + sla_score 

398 

399 # Count issues 

400 issues = self._count_issues() 

401 

402 return HealthScore( 

403 overall_score=overall, 

404 coverage_score=coverage_score, 

405 connectivity_score=connectivity_score, 

406 ownership_score=ownership_score, 

407 test_score=test_score, 

408 issues_count=issues["total"], 

409 critical_issues=issues["critical"], 

410 ) 

411 

412 def _calculate_coverage_score(self) -> float: 

413 """Score completeness of metadata.""" 

414 nodes_with_description = sum(1 for n in self.graph.nodes.values() if n.description) 

415 coverage = nodes_with_description / len(self.graph.nodes) if self.graph.nodes else 0 

416 return min(20.0, coverage * 20) 

417 

418 def _calculate_connectivity_score(self) -> float: 

419 """Score graph connectivity health.""" 

420 components = self.engine.detect_disconnected_components() 

421 

422 if len(components) <= 1: 422 ↛ 424line 422 didn't jump to line 424 because the condition on line 422 was always true

423 return 20.0 # Fully connected 

424 elif len(components) <= 3: 

425 return 15.0 

426 elif len(components) <= 10: 

427 return 10.0 

428 else: 

429 return 5.0 

430 

431 def _calculate_ownership_score(self) -> float: 

432 """Score ownership coverage.""" 

433 owned = sum(1 for n in self.graph.nodes.values() if n.owner) 

434 ownership = owned / len(self.graph.nodes) if self.graph.nodes else 0 

435 return min(20.0, ownership * 20) 

436 

437 def _calculate_test_score(self) -> float: 

438 """Score test coverage using real data from TestCoverageAnalyzer.""" 

439 if not self.graph.nodes: 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true

440 return 0.0 

441 nodes_with_tests = sum( 

442 1 

443 for node_id in self.graph.nodes 

444 if self.test_analyzer.analyze_coverage(node_id).total_tests > 0 

445 ) 

446 coverage = nodes_with_tests / len(self.graph.nodes) 

447 return min(20.0, coverage * 20) 

448 

449 def _calculate_sla_score(self) -> float: 

450 """Score SLA coverage for critical nodes.""" 

451 critical = sum(1 for n in self.graph.nodes.values() if n.severity == NodeSeverity.CRITICAL) 

452 return min(20.0, (critical / max(1, critical)) * 10) 

453 

454 def _count_issues(self) -> Dict[str, int]: 

455 """Count critical and total issues.""" 

456 cycles = len(self.engine.detect_cycles()) 

457 orphans = len(self.engine.detect_orphans()["isolated"]) 

458 unowned_critical = sum( 

459 1 

460 for n in self.graph.nodes.values() 

461 if n.severity == NodeSeverity.CRITICAL and not n.owner 

462 ) 

463 

464 return { 

465 "total": cycles + orphans + unowned_critical, 

466 "critical": cycles + unowned_critical, 

467 } 

468 

469 def get_health_summary(self) -> Dict[str, str]: 

470 """Get human-readable health summary.""" 

471 score = self.calculate_health_score() 

472 

473 if score.overall_score >= 80: 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true

474 status = "Excellent" 

475 color = "green" 

476 elif score.overall_score >= 60: 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true

477 status = "Good" 

478 color = "yellow" 

479 elif score.overall_score >= 40: 479 ↛ 483line 479 didn't jump to line 483 because the condition on line 479 was always true

480 status = "Fair" 

481 color = "orange" 

482 else: 

483 status = "Poor" 

484 color = "red" 

485 

486 return { 

487 "status": status, 

488 "score": f"{score.overall_score:.1f}/100", 

489 "color": color, 

490 "critical_issues": str(score.critical_issues), 

491 "total_issues": str(score.issues_count), 

492 }