22from bs4 import BeautifulSoup
33from rich .panel import Panel
44from rich .table import Table
5+ from rich .tree import Tree
56import re
7+ from collections import defaultdict , deque
8+ from typing import List , Set , Tuple , Dict
9+
10+
11+ def detect_cycles (nodes_dict : Dict [str , str ], edges : List [Tuple [str , str ]]) -> List [List [str ]]:
12+ """
13+ Detect all cycles in the workflow graph using DFS.
14+
15+ Args:
16+ nodes_dict: Mapping of node IDs to labels
17+ edges: List of (source_id, target_id) tuples
18+
19+ Returns:
20+ List of cycles, where each cycle is a list of node IDs
21+ """
22+ # Build adjacency list
23+ graph = defaultdict (list )
24+ for source , target in edges :
25+ graph [source ].append (target )
26+
27+ cycles = []
28+ visited = set ()
29+ rec_stack = set ()
30+ path = []
31+
32+ def dfs (node ):
33+ visited .add (node )
34+ rec_stack .add (node )
35+ path .append (node )
36+
37+ for neighbor in graph [node ]:
38+ if neighbor not in visited :
39+ dfs (neighbor )
40+ elif neighbor in rec_stack :
41+ # Found a cycle
42+ cycle_start = path .index (neighbor )
43+ cycle = path [cycle_start :] + [neighbor ]
44+ cycles .append (cycle )
45+
46+ path .pop ()
47+ rec_stack .remove (node )
48+
49+ # Run DFS from each unvisited node
50+ for node in nodes_dict .keys ():
51+ if node not in visited :
52+ dfs (node )
53+
54+ return cycles
55+
56+
57+ def analyze_control_loop (cycle : List [str ], nodes_dict : Dict [str , str ]) -> Dict :
58+ """
59+ Analyze if a cycle represents a valid control loop.
60+
61+ A valid control loop typically has:
62+ - A controller node (contains 'control', 'controller', 'pid', 'mpc')
63+ - A plant/PM node (contains 'pm', 'plant', 'model')
64+ - At least 2 nodes (for feedback)
65+
66+ Returns:
67+ Dict with analysis results
68+ """
69+ # Get unique nodes in cycle (cycle has duplicate first/last node)
70+ unique_nodes = []
71+ seen = set ()
72+ for node_id in cycle :
73+ if node_id not in seen :
74+ unique_nodes .append (node_id )
75+ seen .add (node_id )
76+
77+ node_labels = [nodes_dict .get (node_id , '' ).lower () for node_id in unique_nodes if node_id in nodes_dict ]
78+
79+ # Keywords for different node types
80+ controller_keywords = ['control' , 'controller' , 'pid' , 'mpc' , 'observer' , 'regulator' ]
81+ plant_keywords = ['pm' , 'plant' , 'model' , 'physio' , 'cardiac' , 'neural' ]
82+
83+ has_controller = any (any (keyword in label for keyword in controller_keywords ) for label in node_labels )
84+ has_plant = any (any (keyword in label for keyword in plant_keywords ) for label in node_labels )
85+
86+ cycle_length = len (unique_nodes )
87+
88+ analysis = {
89+ 'is_valid_control_loop' : has_controller and has_plant and cycle_length >= 2 ,
90+ 'has_controller' : has_controller ,
91+ 'has_plant' : has_plant ,
92+ 'length' : cycle_length ,
93+ 'nodes' : [nodes_dict .get (nid , nid ) for nid in unique_nodes ]
94+ }
95+
96+ return analysis
97+
98+
99+ def check_graph_connectivity (nodes_dict : Dict [str , str ], edges : List [Tuple [str , str ]]) -> Tuple [bool , List [str ]]:
100+ """
101+ Check if all nodes are reachable in the graph.
102+
103+ Returns:
104+ (is_fully_connected, list_of_unreachable_nodes)
105+ """
106+ if not nodes_dict :
107+ return True , []
108+
109+ # Build adjacency list (undirected for connectivity check)
110+ graph = defaultdict (set )
111+ for source , target in edges :
112+ graph [source ].add (target )
113+ graph [target ].add (source )
114+
115+ # BFS from first node
116+ start_node = next (iter (nodes_dict .keys ()))
117+ visited = set ()
118+ queue = deque ([start_node ])
119+
120+ while queue :
121+ node = queue .popleft ()
122+ if node in visited :
123+ continue
124+ visited .add (node )
125+ for neighbor in graph [node ]:
126+ if neighbor not in visited :
127+ queue .append (neighbor )
128+
129+ unreachable = [nodes_dict [nid ] for nid in nodes_dict .keys () if nid not in visited ]
130+
131+ return len (unreachable ) == 0 , unreachable
132+
6133
7134def validate_workflow (workflow_file , console ):
8135 workflow_path = Path (workflow_file )
@@ -104,6 +231,72 @@ def validate_workflow(workflow_file, console):
104231 if file_edges > 0 :
105232 info .append (f"File-based edges: { file_edges } " )
106233
234+ # NEW: Advanced graph analysis
235+ # Build edge list for cycle detection
236+ edge_list = []
237+ for edge in edges :
238+ source = edge .get ('source' )
239+ target = edge .get ('target' )
240+ if source and target and source in node_ids and target in node_ids :
241+ edge_list .append ((source , target ))
242+
243+ # Build node dictionary (id -> label)
244+ node_id_to_label = {}
245+ for node in nodes :
246+ node_id = node .get ('id' )
247+ if node_id :
248+ label_tag = node .find ('y:NodeLabel' )
249+ if label_tag and label_tag .text :
250+ node_id_to_label [node_id ] = label_tag .text .strip ()
251+ else :
252+ node_id_to_label [node_id ] = node_id
253+
254+ # Check connectivity
255+ is_connected , unreachable = check_graph_connectivity (node_id_to_label , edge_list )
256+ if not is_connected :
257+ for node_label in unreachable :
258+ warnings .append (f"Unreachable node: { node_label } " )
259+
260+ # Detect cycles
261+ cycles = detect_cycles (node_id_to_label , edge_list )
262+
263+ if cycles :
264+ info .append (f"Found { len (cycles )} cycle(s) in workflow" )
265+
266+ # Analyze each cycle
267+ control_loops = []
268+ other_cycles = []
269+
270+ for cycle in cycles :
271+ analysis = analyze_control_loop (cycle , node_id_to_label )
272+ if analysis ['is_valid_control_loop' ]:
273+ control_loops .append (analysis )
274+ else :
275+ other_cycles .append (analysis )
276+
277+ if control_loops :
278+ info .append (f"Valid control loops: { len (control_loops )} " )
279+ console .print ()
280+ console .print ("[green]Control Loops Detected:[/green]" )
281+ for i , loop in enumerate (control_loops , 1 ):
282+ console .print (f" [green]Loop { i } :[/green] { ' -> ' .join (loop ['nodes' ])} -> [cycle]" )
283+
284+ if other_cycles :
285+ warnings .append (f"Non-standard cycles detected: { len (other_cycles )} " )
286+ console .print ()
287+ console .print ("[yellow]! Non-Standard Cycles:[/yellow]" )
288+ for i , cycle_info in enumerate (other_cycles , 1 ):
289+ cycle_desc = ' -> ' .join (cycle_info ['nodes' ])
290+ console .print (f" [yellow]Cycle { i } :[/yellow] { cycle_desc } -> [cycle]" )
291+
292+ if not cycle_info ['has_controller' ]:
293+ console .print (f" [dim]Missing controller node[/dim]" )
294+ if not cycle_info ['has_plant' ]:
295+ console .print (f" [dim]Missing plant/PM node[/dim]" )
296+ else :
297+ info .append ("No cycles detected (DAG workflow)" )
298+ warnings .append ("Workflow has no feedback loops - not a control system" )
299+
107300 show_results (console , errors , warnings , info )
108301
109302 except FileNotFoundError :
@@ -113,27 +306,27 @@ def validate_workflow(workflow_file, console):
113306
114307def show_results (console , errors , warnings , info ):
115308 if errors :
116- console .print ("[red]✗ Validation failed[/red]\n " )
309+ console .print ("[red]X Validation failed[/red]\n " )
117310 for error in errors :
118- console .print (f" [red]✗ [/red] { error } " )
311+ console .print (f" [red]X [/red] { error } " )
119312 else :
120- console .print ("[green]✓ Validation passed[/green]\n " )
313+ console .print ("[green]OK Validation passed[/green]\n " )
121314
122315 if warnings :
123316 console .print ()
124317 for warning in warnings :
125- console .print (f" [yellow]⚠ [/yellow] { warning } " )
318+ console .print (f" [yellow]! [/yellow] { warning } " )
126319
127320 if info :
128321 console .print ()
129322 for item in info :
130- console .print (f" [blue]ℹ [/blue] { item } " )
323+ console .print (f" [blue]i [/blue] { item } " )
131324
132325 console .print ()
133326
134327 if not errors :
135328 console .print (Panel .fit (
136- "[green]✓ [/green] Workflow is valid and ready to run" ,
329+ "[green]OK [/green] Workflow is valid and ready to run" ,
137330 border_style = "green"
138331 ))
139332 else :
0 commit comments