Coverage for src/loman/planning.py: 100%

130 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-07 00:32 +0000

1"""Non-mutating validation and execution planning for computation graphs.""" 

2 

3from collections.abc import Collection, Mapping, Sequence 

4from dataclasses import dataclass 

5 

6import networkx as nx 

7import pandas as pd 

8 

9from .consts import NodeAttributes, States 

10from .nodekey import NodeKey 

11 

12_CURRENT_STATES = {States.UPTODATE, States.PINNED} 

13 

14 

15@dataclass(frozen=True) 

16class ValidationReport: 

17 """Structural and readiness findings for a computation graph.""" 

18 

19 cycles: tuple[tuple[NodeKey, ...], ...] 

20 placeholders: tuple[NodeKey, ...] 

21 uninitialized_inputs: tuple[NodeKey, ...] 

22 error_nodes: tuple[NodeKey, ...] 

23 missing_executors: tuple[tuple[NodeKey, str], ...] 

24 

25 @property 

26 def is_valid(self) -> bool: 

27 """Return whether the graph is structurally valid.""" 

28 return not self.cycles and not self.placeholders and not self.missing_executors 

29 

30 @property 

31 def is_ready(self) -> bool: 

32 """Return whether the graph is valid and has all required inputs.""" 

33 return self.is_valid and not self.uninitialized_inputs and not self.error_nodes 

34 

35 def to_df(self) -> pd.DataFrame: 

36 """Return validation findings as one row per issue.""" 

37 rows: list[dict[str, str]] = [] 

38 for component in self.cycles: 

39 nodes = ", ".join(str(node) for node in component) 

40 rows.append({"issue": "cycle", "node": nodes, "detail": f"Dependency cycle: {nodes}"}) 

41 rows.extend( 

42 {"issue": "placeholder", "node": str(node), "detail": "Node has no definition"} 

43 for node in self.placeholders 

44 ) 

45 rows.extend( 

46 {"issue": "uninitialized_input", "node": str(node), "detail": "Input value has not been supplied"} 

47 for node in self.uninitialized_inputs 

48 ) 

49 rows.extend( 

50 {"issue": "error", "node": str(node), "detail": "Previous calculation failed"} for node in self.error_nodes 

51 ) 

52 rows.extend( 

53 { 

54 "issue": "missing_executor", 

55 "node": str(node), 

56 "detail": f"Executor {executor_name!r} is not configured", 

57 } 

58 for node, executor_name in self.missing_executors 

59 ) 

60 return pd.DataFrame(rows, columns=["issue", "node", "detail"]) 

61 

62 

63@dataclass(frozen=True) 

64class ExecutionPlan: 

65 """A non-mutating description of work required by a computation.""" 

66 

67 targets: tuple[NodeKey, ...] | None 

68 execution_order: tuple[NodeKey, ...] 

69 current_nodes: tuple[NodeKey, ...] 

70 blocked_nodes: tuple[NodeKey, ...] 

71 node_states: tuple[tuple[NodeKey, States], ...] 

72 blocked_by: tuple[tuple[NodeKey, tuple[NodeKey, ...]], ...] 

73 blocker_reasons: tuple[tuple[NodeKey, str], ...] 

74 executor_assignments: tuple[tuple[NodeKey, str], ...] 

75 validation: ValidationReport 

76 

77 @property 

78 def is_feasible(self) -> bool: 

79 """Return whether all requested targets can be brought up to date.""" 

80 return self.validation.is_ready and not self.blocked_nodes 

81 

82 def to_df(self) -> pd.DataFrame: 

83 """Return current, runnable, and blocked nodes as an execution table.""" 

84 order = {node: index + 1 for index, node in enumerate(self.execution_order)} 

85 executors = dict(self.executor_assignments) 

86 states = dict(self.node_states) 

87 blocked_by = dict(self.blocked_by) 

88 reasons = dict(self.blocker_reasons) 

89 rows = [] 

90 for node in (*self.current_nodes, *self.execution_order, *self.blocked_nodes): 

91 blockers = blocked_by.get(node, ()) 

92 blocker_details = tuple(dict.fromkeys(reasons[blocker] for blocker in blockers)) 

93 plan_status = "blocked" if blockers else "current" if node in self.current_nodes else "pending" 

94 rows.append( 

95 { 

96 "node": str(node), 

97 "state": states[node], 

98 "plan_status": plan_status, 

99 "order": order.get(node), 

100 "executor": executors.get(node), 

101 "blocked_by": ", ".join(str(blocker) for blocker in blockers) or None, 

102 "reason": "; ".join(blocker_details) or None, 

103 } 

104 ) 

105 return pd.DataFrame( 

106 rows, 

107 columns=["node", "state", "plan_status", "order", "executor", "blocked_by", "reason"], 

108 ).set_index("node") 

109 

110 

111def _ordered_nodes(dag: nx.DiGraph, nodes: Collection[NodeKey]) -> tuple[NodeKey, ...]: 

112 """Return *nodes* in graph insertion order.""" 

113 return tuple(node for node in dag.nodes if node in nodes) 

114 

115 

116def _find_cycles(dag: nx.DiGraph, nodes: Collection[NodeKey]) -> tuple[tuple[NodeKey, ...], ...]: 

117 """Return cyclic strongly connected components in graph insertion order.""" 

118 node_order = {node: index for index, node in enumerate(dag.nodes)} 

119 graph = dag.subgraph(nodes) 

120 components = [] 

121 for component in nx.strongly_connected_components(graph): 

122 if len(component) > 1 or any(graph.has_edge(node, node) for node in component): 

123 components.append(tuple(sorted(component, key=node_order.__getitem__))) 

124 components.sort(key=lambda component: node_order[component[0]]) 

125 return tuple(components) 

126 

127 

128def validate_graph( 

129 dag: nx.DiGraph, 

130 executor_map: Mapping[str, object], 

131 *, 

132 nodes: Collection[NodeKey] | None = None, 

133 executor_nodes: Collection[NodeKey] | None = None, 

134) -> ValidationReport: 

135 """Inspect a graph, or a subset of it, without changing it.""" 

136 selected = set(dag.nodes if nodes is None else nodes) 

137 checked_executors = selected if executor_nodes is None else set(executor_nodes) 

138 

139 placeholders: set[NodeKey] = set() 

140 uninitialized_inputs: set[NodeKey] = set() 

141 error_nodes: set[NodeKey] = set() 

142 missing_executors: list[tuple[NodeKey, str]] = [] 

143 

144 for node_key in dag.nodes: 

145 if node_key not in selected: 

146 continue 

147 node = dag.nodes[node_key] 

148 state = node.get(NodeAttributes.STATE) 

149 if state == States.PLACEHOLDER: 

150 placeholders.add(node_key) 

151 elif state != States.ERROR and state not in _CURRENT_STATES and node.get(NodeAttributes.FUNC) is None: 

152 uninitialized_inputs.add(node_key) 

153 if state == States.ERROR: 

154 error_nodes.add(node_key) 

155 

156 executor_name = node.get(NodeAttributes.EXECUTOR) 

157 if ( 

158 node_key in checked_executors 

159 and node.get(NodeAttributes.FUNC) is not None 

160 and executor_name is not None 

161 and executor_name not in executor_map 

162 ): 

163 missing_executors.append((node_key, executor_name)) 

164 

165 return ValidationReport( 

166 cycles=_find_cycles(dag, selected), 

167 placeholders=_ordered_nodes(dag, placeholders), 

168 uninitialized_inputs=_ordered_nodes(dag, uninitialized_inputs), 

169 error_nodes=_ordered_nodes(dag, error_nodes), 

170 missing_executors=tuple(missing_executors), 

171 ) 

172 

173 

174def _select_required_nodes(dag: nx.DiGraph, targets: Sequence[NodeKey]) -> tuple[set[NodeKey], set[NodeKey]]: 

175 """Select target dependencies, stopping traversal at nodes with current values.""" 

176 selected: set[NodeKey] = set() 

177 current: set[NodeKey] = set() 

178 to_visit = list(reversed(targets)) 

179 

180 while to_visit: 

181 node_key = to_visit.pop() 

182 if node_key in selected: 

183 continue 

184 selected.add(node_key) 

185 if dag.nodes[node_key].get(NodeAttributes.STATE) in _CURRENT_STATES: 

186 current.add(node_key) 

187 continue 

188 to_visit.extend(reversed(list(dag.predecessors(node_key)))) 

189 

190 return selected, current 

191 

192 

193def create_execution_plan( 

194 dag: nx.DiGraph, 

195 executor_map: Mapping[str, object], 

196 targets: Sequence[NodeKey] | None, 

197) -> ExecutionPlan: 

198 """Build a deterministic execution plan without running or mutating nodes.""" 

199 plan_targets = tuple(dag.nodes) if targets is None else tuple(dict.fromkeys(targets)) 

200 selected, current = _select_required_nodes(dag, plan_targets) 

201 pending = selected - current 

202 validation = validate_graph(dag, executor_map, nodes=selected, executor_nodes=pending) 

203 

204 blocker_reasons: dict[NodeKey, str] = {} 

205 blocker_reasons.update((node, "Node has no definition") for node in validation.placeholders) 

206 blocker_reasons.update((node, "Input value has not been supplied") for node in validation.uninitialized_inputs) 

207 blocker_reasons.update((node, "Previous calculation failed") for node in validation.error_nodes) 

208 for component in validation.cycles: 

209 detail = f"Dependency cycle: {', '.join(str(node) for node in component)}" 

210 blocker_reasons.update((node, detail) for node in component) 

211 blocker_reasons.update( 

212 (node, f"Executor {executor_name!r} is not configured") for node, executor_name in validation.missing_executors 

213 ) 

214 

215 pending_graph = dag.subgraph(pending) 

216 blocked_by: dict[NodeKey, set[NodeKey]] = {node: {node} for node in blocker_reasons if node in pending} 

217 for blocker in blocker_reasons: 

218 if blocker in pending: 

219 for descendent in nx.descendants(pending_graph, blocker): 

220 blocked_by.setdefault(descendent, set()).add(blocker) 

221 

222 blocked = set(blocked_by) 

223 node_order = {node: index for index, node in enumerate(dag.nodes)} 

224 

225 runnable = pending - blocked 

226 runnable_graph = dag.subgraph(runnable) 

227 execution_order = tuple(nx.topological_sort(runnable_graph)) 

228 assignments = tuple( 

229 ( 

230 node_key, 

231 "default" 

232 if dag.nodes[node_key].get(NodeAttributes.EXECUTOR) is None 

233 else dag.nodes[node_key][NodeAttributes.EXECUTOR], 

234 ) 

235 for node_key in execution_order 

236 ) 

237 

238 return ExecutionPlan( 

239 targets=None if targets is None else tuple(dict.fromkeys(targets)), 

240 execution_order=execution_order, 

241 current_nodes=_ordered_nodes(dag, current), 

242 blocked_nodes=_ordered_nodes(dag, blocked), 

243 node_states=tuple((node, dag.nodes[node][NodeAttributes.STATE]) for node in dag.nodes if node in selected), 

244 blocked_by=tuple( 

245 (node, tuple(sorted(blocked_by[node], key=node_order.__getitem__))) 

246 for node in dag.nodes 

247 if node in blocked_by 

248 ), 

249 blocker_reasons=tuple((node, blocker_reasons[node]) for node in dag.nodes if node in blocker_reasons), 

250 executor_assignments=assignments, 

251 validation=validation, 

252 )