1"""Dependency graph for issue management.

2

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

4 providing topological sorting and cycle detection.

5"""

6

7 from __future__ import annotations

8

9 import logging

10 from collections import deque

11 from dataclasses import dataclass, field

12 from typing import TYPE_CHECKING

13

14 if TYPE_CHECKING:

15 from little_loops.config import DependencyMappingConfig

16 from little_loops.issue_parser import IssueInfo

17

18 logger = logging.getLogger(__name__)

19

20

21@dataclass

22 class 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

33 class 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

389 def 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