Coverage for src / invariant / executor.py: 94.63%

149 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-25 08:17 +0000

1"""Executor: The runtime engine for executing DAGs.""" 

2 

3from collections.abc import Iterable 

4from decimal import Decimal 

5from typing import TYPE_CHECKING, Any 

6 

7from invariant.cacheable import is_cacheable 

8from invariant.expressions import resolve_params 

9from invariant.graph import Graph 

10from invariant.hashing import hash_manifest 

11from invariant.invocation import invoke_op 

12from invariant.node import Node, SubGraphNode, SwitchNode 

13 

14if TYPE_CHECKING: 

15 from invariant.registry import OpRegistry 

16 from invariant.store.base import ArtifactStore 

17 

18 

19class Executor: 

20 """Runtime engine for executing DAGs. 

21 

22 Manages the two-phase execution: 

23 - Phase 1: Context Resolution (Graph -> Manifest) 

24 - Phase 2: Action Execution (Manifest -> Artifact) 

25 """ 

26 

27 def __init__( 

28 self, 

29 registry: "OpRegistry", 

30 store: "ArtifactStore", 

31 *, 

32 cache: bool = True, 

33 ) -> None: 

34 """Initialize Executor. 

35 

36 Args: 

37 registry: OpRegistry for looking up operations. 

38 store: ArtifactStore for caching artifacts. 

39 cache: Disable manifest hashing and artifact-store access for one-shot 

40 execution. Node identity memoisation and validation still apply. 

41 """ 

42 self.registry = registry 

43 self.store = store 

44 self.cache = cache 

45 

46 def execute( 

47 self, 

48 graph: Graph, 

49 outputs: Iterable[str], 

50 context: dict[str, Any] | None = None, 

51 ) -> dict[str, Any]: 

52 """Execute requested graph outputs and return their artifacts. 

53 

54 Execution is demand-driven from the requested output roots. Unreachable 

55 vertices and inactive SwitchNode branches are not dependency-resolved, 

56 registry-validated, cache-checked, or executed. 

57 

58 Args: 

59 graph: Dictionary mapping node IDs to graph vertices. 

60 outputs: Iterable of graph node IDs to produce. 

61 context: Optional dictionary of external dependencies (values not in graph). 

62 These are injected as artifacts available to any node that declares 

63 them in deps. 

64 

65 Returns: 

66 Dictionary mapping requested output IDs to their resulting artifacts. 

67 

68 Raises: 

69 ValueError: If requested outputs or active paths are invalid. 

70 """ 

71 output_ids = self._normalize_outputs(outputs, graph) 

72 artifacts_by_node, _ = self._execute_requested_outputs( 

73 graph, 

74 output_ids, 

75 context=context, 

76 ) 

77 return {output: artifacts_by_node[output] for output in output_ids} 

78 

79 def _execute_requested_outputs( 

80 self, 

81 graph: Graph, 

82 outputs: tuple[str, ...], 

83 context: dict[str, Any] | None = None, 

84 uncacheable_context_keys: set[str] | None = None, 

85 ) -> tuple[dict[str, Any], set[str]]: 

86 """Demand-execute the active paths for requested graph outputs.""" 

87 context = context or {} 

88 uncacheable_nodes = set(uncacheable_context_keys or set()) 

89 artifacts_by_node: dict[str, Any] = {} 

90 visiting: list[str] = [] 

91 visiting_set: set[str] = set() 

92 

93 def resolve_artifact(node_id: str) -> Any: 

94 if node_id in artifacts_by_node: 

95 return artifacts_by_node[node_id] 

96 

97 if node_id in graph: 

98 if node_id in visiting_set: 

99 cycle = " -> ".join([*visiting, node_id]) 

100 raise ValueError(f"Graph contains cycles on active path: {cycle}") 

101 

102 visiting.append(node_id) 

103 visiting_set.add(node_id) 

104 try: 

105 node = graph[node_id] 

106 for dep_id in node.deps: 

107 resolve_artifact(dep_id) 

108 

109 depends_on_uncacheable = any( 

110 dep_id in uncacheable_nodes for dep_id in node.deps 

111 ) 

112 

113 if isinstance(node, SwitchNode): 

114 self._validate_switch_targets(node, node_id, graph) 

115 target_id = self._select_switch_target( 

116 node, 

117 node_id, 

118 artifacts_by_node, 

119 ) 

120 if target_id not in graph: 

121 raise ValueError( 

122 f"SwitchNode '{node_id}' targets '{target_id}' " 

123 "which doesn't exist in graph" 

124 ) 

125 resolve_artifact(target_id) 

126 artifacts_by_node[node_id] = artifacts_by_node[target_id] 

127 if depends_on_uncacheable or target_id in uncacheable_nodes: 

128 uncacheable_nodes.add(node_id) 

129 elif isinstance(node, SubGraphNode): 

130 manifest = self._build_manifest( 

131 node, 

132 node_id, 

133 graph, 

134 artifacts_by_node, 

135 ) 

136 inner_uncacheable_context_keys = ( 

137 set(manifest.keys()) if depends_on_uncacheable else set() 

138 ) 

139 inner_results, inner_uncacheable_nodes = ( 

140 self._execute_requested_outputs( 

141 node.graph, 

142 (node.output,), 

143 context=manifest, 

144 uncacheable_context_keys=inner_uncacheable_context_keys, 

145 ) 

146 ) 

147 artifacts_by_node[node_id] = inner_results[node.output] 

148 if ( 

149 depends_on_uncacheable 

150 or node.output in inner_uncacheable_nodes 

151 ): 

152 uncacheable_nodes.add(node_id) 

153 else: 

154 manifest = self._build_manifest( 

155 node, 

156 node_id, 

157 graph, 

158 artifacts_by_node, 

159 ) 

160 artifact = self._execute_node( 

161 node, 

162 node_id, 

163 manifest, 

164 depends_on_uncacheable, 

165 uncacheable_nodes, 

166 ) 

167 artifacts_by_node[node_id] = artifact 

168 finally: 

169 visiting.pop() 

170 visiting_set.remove(node_id) 

171 

172 return artifacts_by_node[node_id] 

173 

174 if node_id in context: 

175 value = context[node_id] 

176 if not is_cacheable(value): 

177 raise ValueError( 

178 f"Context value for '{node_id}' is not cacheable, " 

179 f"got {type(value)}" 

180 ) 

181 artifacts_by_node[node_id] = value 

182 return value 

183 

184 raise ValueError( 

185 f"Node depends on '{node_id}' but it doesn't exist in graph " 

186 f"or context. Available: graph={sorted(graph)}, " 

187 f"context={sorted(context)}" 

188 ) 

189 

190 for output in outputs: 

191 resolve_artifact(output) 

192 return artifacts_by_node, uncacheable_nodes 

193 

194 def _normalize_outputs( 

195 self, 

196 outputs: Iterable[str], 

197 graph: Graph, 

198 ) -> tuple[str, ...]: 

199 """Validate and freeze requested output IDs.""" 

200 if isinstance(outputs, (str, bytes)): 

201 raise ValueError( 

202 "outputs must be an iterable of node IDs, not str or bytes" 

203 ) 

204 

205 try: 

206 output_ids = tuple(outputs) 

207 except TypeError as exc: 

208 raise ValueError("outputs must be an iterable of node IDs") from exc 

209 

210 if not output_ids: 

211 raise ValueError("outputs must not be empty") 

212 

213 seen: set[str] = set() 

214 for output in output_ids: 

215 if not isinstance(output, str) or not output: 

216 raise ValueError("outputs must contain non-empty strings") 

217 if output in seen: 

218 raise ValueError(f"outputs contains duplicate node ID '{output}'") 

219 seen.add(output) 

220 if output not in graph: 

221 raise ValueError( 

222 f"Output node '{output}' is not in graph. " 

223 f"Available: {sorted(graph)}" 

224 ) 

225 

226 return output_ids 

227 

228 def _validate_switch_targets( 

229 self, 

230 node: SwitchNode, 

231 node_id: str, 

232 graph: Graph, 

233 ) -> None: 

234 """Validate branch targets for an active SwitchNode.""" 

235 targets = list(node.cases.values()) 

236 if node.default is not None: 

237 targets.append(node.default) 

238 for target in targets: 

239 if target not in graph: 

240 raise ValueError( 

241 f"SwitchNode '{node_id}' targets '{target}' which doesn't " 

242 "exist in graph" 

243 ) 

244 

245 def _build_manifest( 

246 self, 

247 node: Node | SubGraphNode, 

248 node_id: str, 

249 graph: Graph, 

250 artifacts_by_node: dict[str, Any], 

251 ) -> dict[str, Any]: 

252 """Build the input manifest for a node (Phase 1). 

253 

254 The manifest is built entirely from resolved params. Dependencies are NOT 

255 injected into the manifest directly - they are only available for ref()/cel() 

256 resolution within params. 

257 

258 Args: 

259 node: The node to build manifest for. 

260 node_id: The ID of the node. 

261 graph: The full graph (for reference). 

262 artifacts_by_node: Already computed artifacts for upstream nodes. 

263 

264 Returns: 

265 The manifest dictionary mapping parameter names to resolved values. 

266 """ 

267 # Collect dependency artifacts for ref()/cel() resolution 

268 dependencies: dict[str, Any] = {} 

269 for dep_id in node.deps: 

270 if dep_id not in artifacts_by_node: 

271 raise ValueError( 

272 f"Node '{node_id}' depends on '{dep_id}' but artifact not found. " 

273 "This should not happen after active dependency resolution." 

274 ) 

275 dependencies[dep_id] = artifacts_by_node[dep_id] 

276 

277 # Manifest = resolved params only. No dependency injection. 

278 # ref() and cel() markers in params are resolved using dependencies. 

279 return resolve_params(node.params, dependencies) 

280 

281 def _resolve_selector( 

282 self, 

283 node: SwitchNode, 

284 node_id: str, 

285 artifacts_by_node: dict[str, Any], 

286 ) -> Any: 

287 """Resolve a SwitchNode selector from its declared deps.""" 

288 dependencies: dict[str, Any] = {} 

289 for dep_id in node.deps: 

290 if dep_id not in artifacts_by_node: 

291 raise ValueError( 

292 f"SwitchNode '{node_id}' depends on '{dep_id}' but artifact " 

293 "not found" 

294 ) 

295 dependencies[dep_id] = artifacts_by_node[dep_id] 

296 return resolve_params({"selector": node.selector}, dependencies)["selector"] 

297 

298 def _select_switch_target( 

299 self, 

300 node: SwitchNode, 

301 node_id: str, 

302 artifacts_by_node: dict[str, Any], 

303 ) -> str: 

304 """Resolve a SwitchNode selector and return the chosen target node ID.""" 

305 selector_value = self._resolve_selector(node, node_id, artifacts_by_node) 

306 case_key = self._normalize_switch_key(selector_value, node_id) 

307 if case_key in node.cases: 

308 return node.cases[case_key] 

309 if node.default is not None: 

310 return node.default 

311 available = ", ".join(sorted(node.cases)) 

312 raise ValueError( 

313 f"SwitchNode '{node_id}' selector resolved to {case_key!r}, " 

314 f"which has no case. Available cases: {available}" 

315 ) 

316 

317 def _normalize_switch_key(self, value: Any, node_id: str) -> str: 

318 """Normalize selector results to switch case keys.""" 

319 if isinstance(value, str): 

320 return value 

321 if isinstance(value, bool): 

322 return "true" if value else "false" 

323 if value is None: 

324 return "null" 

325 if isinstance(value, int): 

326 return str(value) 

327 if isinstance(value, Decimal): 

328 return str(value) 

329 raise ValueError( 

330 f"SwitchNode '{node_id}' selector returned unsupported " 

331 f"{type(value).__name__}; expected str, bool, null, int, or Decimal" 

332 ) 

333 

334 def _execute_node( 

335 self, 

336 node: Node, 

337 node_id: str, 

338 manifest: dict[str, Any], 

339 depends_on_uncacheable: bool, 

340 uncacheable_nodes: set[str], 

341 ) -> Any: 

342 """Execute one Node using the existing cache semantics.""" 

343 if not self.registry.has(node.op_name): 

344 raise ValueError( 

345 f"Node '{node_id}' references unregistered op '{node.op_name}'" 

346 ) 

347 

348 should_cache = self.cache and node.cache and not depends_on_uncacheable 

349 if not should_cache: 

350 op = self.registry.get(node.op_name) 

351 artifact = self._invoke_op(op, node.op_name, manifest) 

352 uncacheable_nodes.add(node_id) 

353 return artifact 

354 

355 digest = hash_manifest(manifest) 

356 if self.store.exists(node.op_name, digest): 

357 return self.store.get(node.op_name, digest) 

358 

359 op = self.registry.get(node.op_name) 

360 artifact = self._invoke_op(op, node.op_name, manifest) 

361 self.store.put(node.op_name, digest, artifact) 

362 return artifact 

363 

364 def _invoke_op(self, op: Any, op_name: str, manifest: dict[str, Any]) -> Any: 

365 """Invoke an operation with kwargs dispatch and return validation. 

366 

367 Args: 

368 op: The callable operation to invoke. 

369 op_name: The name of the operation (for error messages). 

370 manifest: The manifest dictionary mapping parameter names to values. 

371 

372 Returns: 

373 The operation result (native type or ICacheable domain type). 

374 

375 Raises: 

376 ValueError: If required parameters are missing. 

377 TypeError: If return value is not cacheable. 

378 """ 

379 return invoke_op(op, op_name, manifest)