Coverage for little_loops / dependency_graph.py: 15%

214 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -0500

1"""Dependency graph for issue management. 

2 

3Constructs a directed acyclic graph (DAG) from issue dependencies, 

4providing topological sorting and cycle detection. 

5""" 

6 

7from __future__ import annotations 

8 

9import logging 

10from collections import deque 

11from dataclasses import dataclass, field 

12from typing import TYPE_CHECKING 

13 

14if TYPE_CHECKING: 

15 from little_loops.config import DependencyMappingConfig 

16 from little_loops.issue_parser import IssueInfo 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21@dataclass 

22class WaveContentionNote: 

23 """Annotation for a wave that was split due to file overlap.""" 

24 

25 contended_paths: list[str] 

26 sub_wave_index: int 

27 total_sub_waves: int 

28 parent_wave_index: int = 0 

29 has_unknown_hints: bool = False # True when split includes issues with no file/directory hints 

30 

31 

32@dataclass 

33class DependencyGraph: 

34 """Directed acyclic graph of issue dependencies. 

35 

36 Builds a graph from issue dependencies where edges represent 

37 "blocked by" relationships. Provides methods for: 

38 - Topological sorting (dependency order) 

39 - Cycle detection 

40 - Ready issue queries (blockers resolved) 

41 - Blocking issue queries 

42 

43 Attributes: 

44 issues: Mapping from issue_id to IssueInfo 

45 blocked_by: Mapping from issue_id to set of blocking issue_ids 

46 blocks: Mapping from issue_id to set of blocked issue_ids 

47 depends_on_edges: Mapping from issue_id to set of soft-prerequisite issue_ids 

48 """ 

49 

50 issues: dict[str, IssueInfo] = field(default_factory=dict) 

51 blocked_by: dict[str, set[str]] = field(default_factory=dict) 

52 blocks: dict[str, set[str]] = field(default_factory=dict) 

53 depends_on_edges: dict[str, set[str]] = field(default_factory=dict) 

54 

55 @classmethod 

56 def from_issues( 

57 cls, 

58 issues: list[IssueInfo], 

59 completed_ids: set[str] | None = None, 

60 all_known_ids: set[str] | None = None, 

61 ) -> DependencyGraph: 

62 """Build graph from list of issues. 

63 

64 Constructs a dependency graph where: 

65 - Each issue is a node 

66 - blocked_by relationships create edges from blockers to blocked issues 

67 - Completed issues are treated as satisfied (not blocking) 

68 - Missing issues are logged as warnings but don't block 

69 

70 Args: 

71 issues: List of IssueInfo objects with blocked_by/blocks fields 

72 completed_ids: Set of completed issue IDs (treated as resolved) 

73 all_known_ids: Set of all issue IDs that exist on disk. When 

74 provided, references to issues in this set are silently 

75 skipped (not warned) even if they are not in the graph. 

76 

77 Returns: 

78 Constructed DependencyGraph 

79 

80 Example: 

81 >>> issues = [issue_a, issue_b, issue_c] 

82 >>> completed = {"FEAT-001"} # Already done 

83 >>> graph = DependencyGraph.from_issues(issues, completed) 

84 """ 

85 completed = completed_ids or set() 

86 graph = cls() 

87 

88 # Index all issues by ID 

89 for issue in issues: 

90 graph.issues[issue.issue_id] = issue 

91 graph.blocked_by[issue.issue_id] = set() 

92 graph.blocks[issue.issue_id] = set() 

93 

94 # Build dependency edges 

95 all_issue_ids = set(graph.issues.keys()) 

96 for issue in issues: 

97 for blocker_id in issue.blocked_by: 

98 # Skip completed blockers (already satisfied) 

99 if blocker_id in completed: 

100 continue 

101 # Skip blockers not in the graph 

102 if blocker_id not in all_issue_ids: 

103 # Only warn if the issue truly doesn't exist on disk 

104 if all_known_ids is None or blocker_id not in all_known_ids: 

105 logger.warning( 

106 f"Issue {issue.issue_id} blocked by unknown issue {blocker_id}" 

107 ) 

108 continue 

109 # Add bidirectional edge 

110 graph.blocked_by[issue.issue_id].add(blocker_id) 

111 graph.blocks[blocker_id].add(issue.issue_id) 

112 

113 # Second pass: honour one-sided blocks: declarations. 

114 # If A declares blocks: [B] without a matching blocked_by: [A] on B, 

115 # the A→B edge was never added above; add it now (dedup guard prevents 

116 # double-counting symmetric declarations). 

117 for issue in issues: 

118 for blocked_id in issue.blocks: 

119 if blocked_id in completed: 

120 continue 

121 if blocked_id not in all_issue_ids: 

122 if all_known_ids is None or blocked_id not in all_known_ids: 

123 logger.warning(f"Issue {issue.issue_id} blocks unknown issue {blocked_id}") 

124 continue 

125 if issue.issue_id not in graph.blocked_by.get(blocked_id, set()): 

126 graph.blocked_by[blocked_id].add(issue.issue_id) 

127 graph.blocks[issue.issue_id].add(blocked_id) 

128 

129 # Third pass: populate depends_on_edges (soft prerequisite relationships). 

130 # depends_on is one-directional — no reverse edge is built here. 

131 for issue in issues: 

132 for target_id in issue.depends_on: 

133 if target_id in completed: 

134 continue 

135 if target_id not in all_issue_ids: 

136 if all_known_ids is None or target_id not in all_known_ids: 

137 logger.warning( 

138 f"Issue {issue.issue_id} has depends_on unknown issue {target_id}" 

139 ) 

140 continue 

141 if issue.issue_id not in graph.depends_on_edges: 

142 graph.depends_on_edges[issue.issue_id] = set() 

143 graph.depends_on_edges[issue.issue_id].add(target_id) 

144 

145 return graph 

146 

147 def get_ready_issues(self, completed: set[str] | None = None) -> list[IssueInfo]: 

148 """Return issues whose blockers are all completed. 

149 

150 An issue is "ready" if: 

151 - It is not already completed 

152 - All its blockers are either completed or not in the graph 

153 

154 Args: 

155 completed: Set of completed issue IDs 

156 

157 Returns: 

158 List of IssueInfo for issues with no active blockers, 

159 sorted by priority (highest first) then issue_id 

160 """ 

161 completed = completed or set() 

162 ready = [] 

163 for issue_id, issue in self.issues.items(): 

164 if issue_id in completed: 

165 continue 

166 blockers = self.get_blocking_issues(issue_id, completed) 

167 if not blockers: 

168 ready.append(issue) 

169 # Sort by priority then issue_id for consistent ordering 

170 ready.sort(key=lambda x: (x.priority_int, x.issue_id)) 

171 return ready 

172 

173 def get_execution_waves(self, completed: set[str] | None = None) -> list[list[IssueInfo]]: 

174 """Return issues grouped into parallel execution waves. 

175 

176 Wave 1: All issues with no blockers (or blockers already completed) 

177 Wave 2: Issues whose blockers are all in wave 1 

178 Wave N: Issues whose blockers are all in waves 1..N-1 

179 

180 This is similar to topological_sort but groups issues by "level" 

181 rather than returning a flat list. 

182 

183 Args: 

184 completed: Set of already-completed issue IDs 

185 

186 Returns: 

187 List of waves, each wave is a list of issues that can run in parallel. 

188 Empty list if graph is empty or all issues are completed. 

189 

190 Raises: 

191 ValueError: If graph contains cycles (not a DAG) 

192 

193 Example: 

194 If A blocks B and C, and B and C block D: 

195 - Wave 1: [A] 

196 - Wave 2: [B, C] 

197 - Wave 3: [D] 

198 """ 

199 completed = completed or set() 

200 waves: list[list[IssueInfo]] = [] 

201 processed: set[str] = set(completed) 

202 

203 while True: 

204 # Get issues ready to run (all hard blockers in processed set) 

205 wave = self.get_ready_issues(completed=processed) 

206 if not wave: 

207 break 

208 

209 # Soft ordering: nudge depends_on targets into this wave when their 

210 # hard blockers are all satisfied by processed ∪ current wave. 

211 after_wave: set[str] = processed | {i.issue_id for i in wave} 

212 nudged: list[IssueInfo] = [] 

213 for issue in wave: 

214 for target_id in self.depends_on_edges.get(issue.issue_id, set()): 

215 if target_id in after_wave or target_id not in self.issues: 

216 continue 

217 if not self.get_blocking_issues(target_id, after_wave): 

218 nudged.append(self.issues[target_id]) 

219 after_wave.add(target_id) 

220 wave.extend(nudged) 

221 

222 waves.append(wave) 

223 # Mark this wave (including nudged) as processed for next iteration 

224 for issue in wave: 

225 processed.add(issue.issue_id) 

226 

227 # Check for cycles - if we have unprocessed issues, there's a cycle 

228 remaining = set(self.issues.keys()) - processed 

229 if remaining: 

230 cycles = self.detect_cycles() 

231 cycle_str = ", ".join(" -> ".join(cycle) for cycle in cycles) 

232 raise ValueError(f"Dependency graph contains cycles: {cycle_str}") 

233 

234 return waves 

235 

236 def is_blocked(self, issue_id: str, completed: set[str] | None = None) -> bool: 

237 """Check if an issue is still blocked. 

238 

239 Args: 

240 issue_id: Issue ID to check 

241 completed: Set of completed issue IDs 

242 

243 Returns: 

244 True if issue has unresolved blockers, False otherwise 

245 """ 

246 return bool(self.get_blocking_issues(issue_id, completed)) 

247 

248 def get_blocking_issues(self, issue_id: str, completed: set[str] | None = None) -> set[str]: 

249 """Return incomplete issues blocking this one. 

250 

251 Args: 

252 issue_id: Issue ID to check 

253 completed: Set of completed issue IDs 

254 

255 Returns: 

256 Set of issue IDs still blocking this issue 

257 """ 

258 completed = completed or set() 

259 blockers = self.blocked_by.get(issue_id, set()) 

260 return blockers - completed 

261 

262 def get_blocked_by_issue(self, issue_id: str) -> set[str]: 

263 """Return issues that this issue blocks. 

264 

265 Args: 

266 issue_id: Issue ID to check 

267 

268 Returns: 

269 Set of issue IDs that are blocked by this issue 

270 """ 

271 return self.blocks.get(issue_id, set()).copy() 

272 

273 def topological_sort(self) -> list[IssueInfo]: 

274 """Return issues in dependency order (Kahn's algorithm). 

275 

276 Issues with no dependencies come first, followed by issues 

277 whose dependencies have been satisfied. Within each "level", 

278 issues are sorted by priority then issue_id. 

279 

280 Returns: 

281 List of IssueInfo in topological order 

282 

283 Raises: 

284 ValueError: If graph contains cycles (not a DAG) 

285 

286 Example: 

287 If A blocks B, and B blocks C, returns [A, B, C] 

288 """ 

289 # Calculate in-degree for each node (number of blockers) 

290 in_degree: dict[str, int] = { 

291 issue_id: len(blockers) for issue_id, blockers in self.blocked_by.items() 

292 } 

293 

294 # Start with nodes that have no blockers, sorted by priority 

295 zero_degree = [ 

296 self.issues[issue_id] for issue_id, degree in in_degree.items() if degree == 0 

297 ] 

298 zero_degree.sort(key=lambda x: (x.priority_int, x.issue_id)) 

299 queue: deque[str] = deque(issue.issue_id for issue in zero_degree) 

300 

301 result: list[IssueInfo] = [] 

302 while queue: 

303 issue_id = queue.popleft() 

304 result.append(self.issues[issue_id]) 

305 

306 # Reduce in-degree for nodes this one blocks 

307 # Collect newly ready nodes, then sort before adding to queue 

308 newly_ready: list[IssueInfo] = [] 

309 for blocked_id in self.blocks.get(issue_id, set()): 

310 in_degree[blocked_id] -= 1 

311 if in_degree[blocked_id] == 0: 

312 newly_ready.append(self.issues[blocked_id]) 

313 

314 # Sort newly ready by priority for consistent ordering 

315 newly_ready.sort(key=lambda x: (x.priority_int, x.issue_id)) 

316 for issue in newly_ready: 

317 queue.append(issue.issue_id) 

318 

319 # Check for cycles - if we didn't process all nodes, there's a cycle 

320 if len(result) != len(self.issues): 

321 cycles = self.detect_cycles() 

322 cycle_str = ", ".join(" -> ".join(cycle) for cycle in cycles) 

323 raise ValueError(f"Dependency graph contains cycles: {cycle_str}") 

324 

325 return result 

326 

327 def detect_cycles(self) -> list[list[str]]: 

328 """Detect and return any dependency cycles. 

329 

330 Uses DFS with coloring to find back edges indicating cycles. 

331 A cycle exists when we encounter a node that is currently being 

332 visited (GRAY state) in the DFS traversal. 

333 

334 Returns: 

335 List of cycles, each cycle is a list of issue IDs forming 

336 a path from the cycle start back to itself. 

337 Empty list if no cycles exist. 

338 

339 Example: 

340 If A -> B -> C -> A (circular), returns [["A", "B", "C", "A"]] 

341 """ 

342 WHITE, GRAY, BLACK = 0, 1, 2 

343 color: dict[str, int] = dict.fromkeys(self.issues, WHITE) 

344 cycles: list[list[str]] = [] 

345 path: list[str] = [] 

346 

347 def dfs(node: str) -> None: 

348 color[node] = GRAY 

349 path.append(node) 

350 

351 # Traverse blockers (edges point from blocked to blocker) 

352 for neighbor in self.blocked_by.get(node, set()): 

353 if neighbor not in color: 

354 continue 

355 if color[neighbor] == GRAY: 

356 # Found cycle - extract from path 

357 cycle_start = path.index(neighbor) 

358 cycle = path[cycle_start:] + [neighbor] 

359 cycles.append(cycle) 

360 elif color[neighbor] == WHITE: 

361 dfs(neighbor) 

362 

363 path.pop() 

364 color[node] = BLACK 

365 

366 for issue_id in self.issues: 

367 if color[issue_id] == WHITE: 

368 dfs(issue_id) 

369 

370 return cycles 

371 

372 def has_cycles(self) -> bool: 

373 """Check if the graph contains any cycles. 

374 

375 Returns: 

376 True if cycles exist, False otherwise 

377 """ 

378 return len(self.detect_cycles()) > 0 

379 

380 def __len__(self) -> int: 

381 """Return number of issues in the graph.""" 

382 return len(self.issues) 

383 

384 def __contains__(self, issue_id: str) -> bool: 

385 """Check if an issue is in the graph.""" 

386 return issue_id in self.issues 

387 

388 

389def refine_waves_for_contention( 

390 waves: list[list[IssueInfo]], 

391 *, 

392 config: DependencyMappingConfig | None = None, 

393) -> tuple[list[list[IssueInfo]], list[WaveContentionNote | None]]: 

394 """Refine execution waves by splitting on file overlap. 

395 

396 For each wave with multiple issues, extracts file hints from issue 

397 content and checks for pairwise overlaps. Overlapping issues are 

398 split into sub-waves using greedy graph coloring so no two issues 

399 in the same sub-wave modify the same files. 

400 

401 Args: 

402 waves: Execution waves from get_execution_waves() 

403 

404 Returns: 

405 Tuple of (refined_waves, contention_notes). 

406 refined_waves: Wave list with contention-free sub-waves. 

407 contention_notes: Parallel list (same length as refined_waves). 

408 None for waves that weren't split, WaveContentionNote for sub-waves. 

409 """ 

410 from little_loops.parallel.file_hints import FileHints, extract_file_hints 

411 

412 refined: list[list[IssueInfo]] = [] 

413 annotations: list[WaveContentionNote | None] = [] 

414 

415 for orig_idx, wave in enumerate(waves): 

416 if len(wave) <= 1: 

417 refined.append(wave) 

418 annotations.append(None) 

419 continue 

420 

421 # Extract file hints for each issue in the wave 

422 hints: dict[str, FileHints] = {} 

423 for issue in wave: 

424 content = issue.path.read_text() if issue.path.exists() else "" 

425 hints[issue.issue_id] = extract_file_hints(content, issue.issue_id) 

426 

427 # Single pass — build conflict adjacency and collect contended paths together 

428 conflicts: dict[str, set[str]] = {issue.issue_id: set() for issue in wave} 

429 contended: set[str] = set() 

430 for i, a in enumerate(wave): 

431 for b in wave[i + 1 :]: 

432 paths = hints[a.issue_id].get_overlapping_paths(hints[b.issue_id], config=config) 

433 if paths: 

434 conflicts[a.issue_id].add(b.issue_id) 

435 conflicts[b.issue_id].add(a.issue_id) 

436 contended |= paths 

437 

438 # Conservative serialization: issues with no file OR directory hints cannot prove 

439 # they are safe to run in parallel with other hint-less issues. Add synthetic 

440 # conflict edges so the greedy coloring places each such issue in its own sub-wave. 

441 hintless_ids = [ 

442 issue.issue_id 

443 for issue in wave 

444 if not hints[issue.issue_id].files and not hints[issue.issue_id].directories 

445 ] 

446 has_unknown = len(hintless_ids) > 1 

447 if has_unknown: 

448 for i, a_id in enumerate(hintless_ids): 

449 for b_id in hintless_ids[i + 1 :]: 

450 conflicts[a_id].add(b_id) 

451 conflicts[b_id].add(a_id) 

452 

453 # If no conflicts, keep wave as-is 

454 if not any(conflicts.values()): 

455 refined.append(wave) 

456 annotations.append(None) 

457 continue 

458 

459 contended_paths = sorted(contended) 

460 

461 # Greedy graph coloring — assign each issue the lowest color 

462 # not used by any conflicting neighbor 

463 color: dict[str, int] = {} 

464 for issue in wave: # iterate in priority order (preserved from get_ready_issues) 

465 used_colors = {color[c] for c in conflicts[issue.issue_id] if c in color} 

466 c = 0 

467 while c in used_colors: 

468 c += 1 

469 color[issue.issue_id] = c 

470 

471 # Group issues by color into sub-waves, preserving priority order 

472 max_color = max(color.values()) 

473 total_sub_waves = max_color + 1 

474 for c in range(total_sub_waves): 

475 sub_wave = [issue for issue in wave if color[issue.issue_id] == c] 

476 if sub_wave: 

477 refined.append(sub_wave) 

478 annotations.append( 

479 WaveContentionNote( 

480 contended_paths=contended_paths, 

481 sub_wave_index=c, 

482 total_sub_waves=total_sub_waves, 

483 parent_wave_index=orig_idx, 

484 has_unknown_hints=has_unknown, 

485 ) 

486 ) 

487 

488 reason_parts = [] 

489 if contended_paths: 

490 reason_parts.append("file overlap") 

491 if has_unknown: 

492 reason_parts.append("unknown file hints") 

493 reason = " + ".join(reason_parts) if reason_parts else "unknown" 

494 logger.info(f" Wave split into {total_sub_waves} sub-waves due to {reason}") 

495 

496 return refined, annotations