Download src/validator.py from FlyingNunchucks/09-agentic-workflow-sys: direct link, hf CLI and curl.
- Browser
- Download file 6.86 kB
-
https://huggingface.co/spaces/FlyingNunchucks/09-agentic-workflow-sys/resolve/main/src/validator.py
- Command line
-
hf download hf://spaces/FlyingNunchucks/09-agentic-workflow-sys/src/validator.py
-
curl -L -o validator.py https://huggingface.co/spaces/FlyingNunchucks/09-agentic-workflow-sys/resolve/main/src/validator.py
6.86 kB
| """Deterministic validation for workflow dependency graphs and decision routes. | |
| The schema layer validates the shape of workflow definitions. This module validates | |
| relationships between nodes: unique identifiers, dependency references, decision | |
| route targets, branch separation, self-dependencies, and acyclicity. | |
| """ | |
| from __future__ import annotations | |
| from heapq import heappop, heappush | |
| from src.schemas import NodeType, WorkflowDefinition | |
| class WorkflowValidationError(ValueError): | |
| """Raised when a workflow definition is structurally invalid as a DAG.""" | |
| def validate_workflow(definition: WorkflowDefinition) -> None: | |
| """Validate cross-node graph and deterministic routing rules.""" | |
| _validate_unique_node_ids(definition) | |
| _validate_dependencies(definition) | |
| _validate_decision_routes(definition) | |
| _topological_order(definition) | |
| def topological_order(definition: WorkflowDefinition) -> list[str]: | |
| """Return a deterministic topological ordering after validating the workflow. | |
| When more than one node is ready, node identifiers are used as a stable | |
| tie-breaker. The ordering is deterministic for testing and the MVP's sequential | |
| executor; it does not imply that production workflows must execute independent | |
| nodes serially. | |
| """ | |
| _validate_unique_node_ids(definition) | |
| _validate_dependencies(definition) | |
| _validate_decision_routes(definition) | |
| return _topological_order(definition) | |
| def _validate_unique_node_ids(definition: WorkflowDefinition) -> None: | |
| seen: set[str] = set() | |
| duplicates: set[str] = set() | |
| for node in definition.nodes: | |
| if node.node_id in seen: | |
| duplicates.add(node.node_id) | |
| seen.add(node.node_id) | |
| if duplicates: | |
| duplicate_list = ", ".join(sorted(duplicates)) | |
| raise WorkflowValidationError(f"duplicate node_id values: {duplicate_list}") | |
| def _validate_dependencies(definition: WorkflowDefinition) -> None: | |
| node_ids = {node.node_id for node in definition.nodes} | |
| for node in definition.nodes: | |
| for dependency_id in node.depends_on: | |
| if dependency_id == node.node_id: | |
| raise WorkflowValidationError( | |
| f"node '{node.node_id}' cannot depend on itself" | |
| ) | |
| if dependency_id not in node_ids: | |
| raise WorkflowValidationError( | |
| f"node '{node.node_id}' depends on unknown node " | |
| f"'{dependency_id}'" | |
| ) | |
| def _validate_decision_routes(definition: WorkflowDefinition) -> None: | |
| node_by_id = {node.node_id: node for node in definition.nodes} | |
| dependents: dict[str, set[str]] = {node.node_id: set() for node in definition.nodes} | |
| for node in definition.nodes: | |
| for dependency_id in node.depends_on: | |
| dependents[dependency_id].add(node.node_id) | |
| for decision in definition.nodes: | |
| if decision.node_type is not NodeType.DECISION: | |
| continue | |
| route_targets = set(decision.routes.values()) | |
| for route_label, target_node_id in decision.routes.items(): | |
| target = node_by_id.get(target_node_id) | |
| if target is None: | |
| raise WorkflowValidationError( | |
| f"DECISION node '{decision.node_id}' route '{route_label}' targets " | |
| f"unknown node '{target_node_id}'" | |
| ) | |
| if target_node_id == decision.node_id: | |
| raise WorkflowValidationError( | |
| f"DECISION node '{decision.node_id}' cannot route to itself" | |
| ) | |
| if decision.node_id not in target.depends_on: | |
| raise WorkflowValidationError( | |
| f"route target '{target_node_id}' must directly depend on " | |
| f"DECISION node '{decision.node_id}'" | |
| ) | |
| direct_dependents = dependents[decision.node_id] | |
| if direct_dependents != route_targets: | |
| unrouted = sorted(direct_dependents - route_targets) | |
| extra = sorted(route_targets - direct_dependents) | |
| details: list[str] = [] | |
| if unrouted: | |
| details.append(f"unrouted dependents: {', '.join(unrouted)}") | |
| if extra: | |
| details.append(f"invalid route targets: {', '.join(extra)}") | |
| detail_text = "; ".join(details) | |
| raise WorkflowValidationError( | |
| f"DECISION node '{decision.node_id}' must route all direct dependents" | |
| + (f" ({detail_text})" if detail_text else "") | |
| ) | |
| branch_reach: dict[str, set[str]] = {} | |
| for target_node_id in sorted(route_targets): | |
| reachable = _reachable_nodes(target_node_id, dependents) | |
| for other_target, other_reachable in branch_reach.items(): | |
| overlap = sorted(reachable & other_reachable) | |
| if overlap: | |
| raise WorkflowValidationError( | |
| f"DECISION node '{decision.node_id}' branch reconvergence is " | |
| f"not supported; routes through '{other_target}' and " | |
| f"'{target_node_id}' both reach: {', '.join(overlap)}" | |
| ) | |
| branch_reach[target_node_id] = reachable | |
| def _reachable_nodes(start_node_id: str, dependents: dict[str, set[str]]) -> set[str]: | |
| reachable: set[str] = set() | |
| stack = [start_node_id] | |
| while stack: | |
| node_id = stack.pop() | |
| if node_id in reachable: | |
| continue | |
| reachable.add(node_id) | |
| stack.extend(sorted(dependents[node_id], reverse=True)) | |
| return reachable | |
| def _topological_order(definition: WorkflowDefinition) -> list[str]: | |
| """Use Kahn's algorithm to produce an order or reject a cyclic graph.""" | |
| indegree = {node.node_id: len(node.depends_on) for node in definition.nodes} | |
| dependents: dict[str, list[str]] = {node.node_id: [] for node in definition.nodes} | |
| for node in definition.nodes: | |
| for dependency_id in node.depends_on: | |
| dependents[dependency_id].append(node.node_id) | |
| ready: list[str] = [] | |
| for node_id, degree in indegree.items(): | |
| if degree == 0: | |
| heappush(ready, node_id) | |
| order: list[str] = [] | |
| while ready: | |
| node_id = heappop(ready) | |
| order.append(node_id) | |
| for dependent_id in sorted(dependents[node_id]): | |
| indegree[dependent_id] -= 1 | |
| if indegree[dependent_id] == 0: | |
| heappush(ready, dependent_id) | |
| if len(order) != len(definition.nodes): | |
| blocked = sorted(node_id for node_id, degree in indegree.items() if degree > 0) | |
| blocked_list = ", ".join(blocked) | |
| raise WorkflowValidationError( | |
| f"workflow dependency graph contains a cycle; blocked nodes: {blocked_list}" | |
| ) | |
| return order | |