Stageless: prettier cycle reporting (#7463)
Graph theory make head hurty. Closes #7367. Basically, when we topologically sort the dependency graph, we already find its strongly-connected components (a really [neat algorithm][1]). This PR adds an algorithm that can dissect those into simple cycles, giving us more useful error reports. test: `system_build_errors::dependency_cycle` ``` schedule has 1 before/after cycle(s): cycle 1: system set 'A' must run before itself system set 'A' ... which must run before system set 'B' ... which must run before system set 'A' ``` ``` schedule has 1 before/after cycle(s): cycle 1: system 'foo' must run before itself system 'foo' ... which must run before system 'bar' ... which must run before system 'foo' ``` test: `system_build_errors::hierarchy_cycle` ``` schedule has 1 in_set cycle(s): cycle 1: system set 'A' contains itself system set 'A' ... which contains system set 'B' ... which contains system set 'A' ``` [1]: https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
This commit is contained in:
		
							parent
							
								
									5bd1907517
								
							
						
					
					
						commit
						3ec87e49ca
					
				| @ -1,7 +1,7 @@ | |||||||
| use std::fmt::Debug; | use std::fmt::Debug; | ||||||
| 
 | 
 | ||||||
| use bevy_utils::{ | use bevy_utils::{ | ||||||
|     petgraph::{graphmap::NodeTrait, prelude::*}, |     petgraph::{algo::TarjanScc, graphmap::NodeTrait, prelude::*}, | ||||||
|     HashMap, HashSet, |     HashMap, HashSet, | ||||||
| }; | }; | ||||||
| use fixedbitset::FixedBitSet; | use fixedbitset::FixedBitSet; | ||||||
| @ -274,3 +274,119 @@ where | |||||||
|         transitive_closure, |         transitive_closure, | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// Returns the simple cycles in a strongly-connected component of a directed graph.
 | ||||||
|  | ///
 | ||||||
|  | /// The algorithm implemented comes from
 | ||||||
|  | /// ["Finding all the elementary circuits of a directed graph"][1] by D. B. Johnson.
 | ||||||
|  | ///
 | ||||||
|  | /// [1]: https://doi.org/10.1137/0204007
 | ||||||
|  | pub fn simple_cycles_in_component<N>(graph: &DiGraphMap<N, ()>, scc: &[N]) -> Vec<Vec<N>> | ||||||
|  | where | ||||||
|  |     N: NodeTrait + Debug, | ||||||
|  | { | ||||||
|  |     let mut cycles = vec![]; | ||||||
|  |     let mut sccs = vec![scc.to_vec()]; | ||||||
|  | 
 | ||||||
|  |     while let Some(mut scc) = sccs.pop() { | ||||||
|  |         // only look at nodes and edges in this strongly-connected component
 | ||||||
|  |         let mut subgraph = DiGraphMap::new(); | ||||||
|  |         for &node in &scc { | ||||||
|  |             subgraph.add_node(node); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         for &node in &scc { | ||||||
|  |             for successor in graph.neighbors(node) { | ||||||
|  |                 if subgraph.contains_node(successor) { | ||||||
|  |                     subgraph.add_edge(node, successor, ()); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // path of nodes that may form a cycle
 | ||||||
|  |         let mut path = Vec::with_capacity(subgraph.node_count()); | ||||||
|  |         // we mark nodes as "blocked" to avoid finding permutations of the same cycles
 | ||||||
|  |         let mut blocked = HashSet::with_capacity(subgraph.node_count()); | ||||||
|  |         // connects nodes along path segments that can't be part of a cycle (given current root)
 | ||||||
|  |         // those nodes can be unblocked at the same time
 | ||||||
|  |         let mut unblock_together: HashMap<N, HashSet<N>> = | ||||||
|  |             HashMap::with_capacity(subgraph.node_count()); | ||||||
|  |         // stack for unblocking nodes
 | ||||||
|  |         let mut unblock_stack = Vec::with_capacity(subgraph.node_count()); | ||||||
|  |         // nodes can be involved in multiple cycles
 | ||||||
|  |         let mut maybe_in_more_cycles: HashSet<N> = HashSet::with_capacity(subgraph.node_count()); | ||||||
|  |         // stack for DFS
 | ||||||
|  |         let mut stack = Vec::with_capacity(subgraph.node_count()); | ||||||
|  | 
 | ||||||
|  |         // we're going to look for all cycles that begin and end at this node
 | ||||||
|  |         let root = scc.pop().unwrap(); | ||||||
|  |         // start a path at the root
 | ||||||
|  |         path.clear(); | ||||||
|  |         path.push(root); | ||||||
|  |         // mark this node as blocked
 | ||||||
|  |         blocked.insert(root); | ||||||
|  | 
 | ||||||
|  |         // DFS
 | ||||||
|  |         stack.clear(); | ||||||
|  |         stack.push((root, subgraph.neighbors(root))); | ||||||
|  |         while !stack.is_empty() { | ||||||
|  |             let (ref node, successors) = stack.last_mut().unwrap(); | ||||||
|  |             if let Some(next) = successors.next() { | ||||||
|  |                 if next == root { | ||||||
|  |                     // found a cycle
 | ||||||
|  |                     maybe_in_more_cycles.extend(path.iter()); | ||||||
|  |                     cycles.push(path.clone()); | ||||||
|  |                 } else if !blocked.contains(&next) { | ||||||
|  |                     // first time seeing `next` on this path
 | ||||||
|  |                     maybe_in_more_cycles.remove(&next); | ||||||
|  |                     path.push(next); | ||||||
|  |                     blocked.insert(next); | ||||||
|  |                     stack.push((next, subgraph.neighbors(next))); | ||||||
|  |                     continue; | ||||||
|  |                 } else { | ||||||
|  |                     // not first time seeing `next` on this path
 | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if successors.peekable().peek().is_none() { | ||||||
|  |                 if maybe_in_more_cycles.contains(node) { | ||||||
|  |                     unblock_stack.push(*node); | ||||||
|  |                     // unblock this node's ancestors
 | ||||||
|  |                     while let Some(n) = unblock_stack.pop() { | ||||||
|  |                         if blocked.remove(&n) { | ||||||
|  |                             let unblock_predecessors = | ||||||
|  |                                 unblock_together.entry(n).or_insert_with(HashSet::new); | ||||||
|  |                             unblock_stack.extend(unblock_predecessors.iter()); | ||||||
|  |                             unblock_predecessors.clear(); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     // if its descendants can be unblocked later, this node will be too
 | ||||||
|  |                     for successor in subgraph.neighbors(*node) { | ||||||
|  |                         unblock_together | ||||||
|  |                             .entry(successor) | ||||||
|  |                             .or_insert_with(HashSet::new) | ||||||
|  |                             .insert(*node); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 // remove node from path and DFS stack
 | ||||||
|  |                 path.pop(); | ||||||
|  |                 stack.pop(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // remove node from subgraph
 | ||||||
|  |         subgraph.remove_node(root); | ||||||
|  | 
 | ||||||
|  |         // divide remainder into smaller SCCs
 | ||||||
|  |         let mut tarjan_scc = TarjanScc::new(); | ||||||
|  |         tarjan_scc.run(&subgraph, |scc| { | ||||||
|  |             if scc.len() > 1 { | ||||||
|  |                 sccs.push(scc.to_vec()); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     cycles | ||||||
|  | } | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ use bevy_utils::default; | |||||||
| #[cfg(feature = "trace")] | #[cfg(feature = "trace")] | ||||||
| use bevy_utils::tracing::info_span; | use bevy_utils::tracing::info_span; | ||||||
| use bevy_utils::{ | use bevy_utils::{ | ||||||
|     petgraph::prelude::*, |     petgraph::{algo::TarjanScc, prelude::*}, | ||||||
|     thiserror::Error, |     thiserror::Error, | ||||||
|     tracing::{error, warn}, |     tracing::{error, warn}, | ||||||
|     HashMap, HashSet, |     HashMap, HashSet, | ||||||
| @ -942,7 +942,7 @@ impl ScheduleGraph { | |||||||
| 
 | 
 | ||||||
|         // check hierarchy for cycles
 |         // check hierarchy for cycles
 | ||||||
|         self.hierarchy.topsort = self |         self.hierarchy.topsort = self | ||||||
|             .topsort_graph(&self.hierarchy.graph) |             .topsort_graph(&self.hierarchy.graph, ReportCycles::Hierarchy) | ||||||
|             .map_err(|_| ScheduleBuildError::HierarchyCycle)?; |             .map_err(|_| ScheduleBuildError::HierarchyCycle)?; | ||||||
| 
 | 
 | ||||||
|         let hier_results = check_graph(&self.hierarchy.graph, &self.hierarchy.topsort); |         let hier_results = check_graph(&self.hierarchy.graph, &self.hierarchy.topsort); | ||||||
| @ -960,7 +960,7 @@ impl ScheduleGraph { | |||||||
| 
 | 
 | ||||||
|         // check dependencies for cycles
 |         // check dependencies for cycles
 | ||||||
|         self.dependency.topsort = self |         self.dependency.topsort = self | ||||||
|             .topsort_graph(&self.dependency.graph) |             .topsort_graph(&self.dependency.graph, ReportCycles::Dependency) | ||||||
|             .map_err(|_| ScheduleBuildError::DependencyCycle)?; |             .map_err(|_| ScheduleBuildError::DependencyCycle)?; | ||||||
| 
 | 
 | ||||||
|         // check for systems or system sets depending on sets they belong to
 |         // check for systems or system sets depending on sets they belong to
 | ||||||
| @ -1083,7 +1083,7 @@ impl ScheduleGraph { | |||||||
| 
 | 
 | ||||||
|         // topsort
 |         // topsort
 | ||||||
|         self.dependency_flattened.topsort = self |         self.dependency_flattened.topsort = self | ||||||
|             .topsort_graph(&dependency_flattened) |             .topsort_graph(&dependency_flattened, ReportCycles::Dependency) | ||||||
|             .map_err(|_| ScheduleBuildError::DependencyCycle)?; |             .map_err(|_| ScheduleBuildError::DependencyCycle)?; | ||||||
|         self.dependency_flattened.graph = dependency_flattened; |         self.dependency_flattened.graph = dependency_flattened; | ||||||
| 
 | 
 | ||||||
| @ -1318,6 +1318,12 @@ impl ScheduleGraph { | |||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /// Used to select the appropriate reporting function.
 | ||||||
|  | enum ReportCycles { | ||||||
|  |     Hierarchy, | ||||||
|  |     Dependency, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // methods for reporting errors
 | // methods for reporting errors
 | ||||||
| impl ScheduleGraph { | impl ScheduleGraph { | ||||||
|     fn get_node_name(&self, id: &NodeId) -> String { |     fn get_node_name(&self, id: &NodeId) -> String { | ||||||
| @ -1345,7 +1351,7 @@ impl ScheduleGraph { | |||||||
|         name |         name | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fn get_node_kind(id: &NodeId) -> &'static str { |     fn get_node_kind(&self, id: &NodeId) -> &'static str { | ||||||
|         match id { |         match id { | ||||||
|             NodeId::System(_) => "system", |             NodeId::System(_) => "system", | ||||||
|             NodeId::Set(_) => "system set", |             NodeId::Set(_) => "system set", | ||||||
| @ -1366,7 +1372,7 @@ impl ScheduleGraph { | |||||||
|             writeln!( |             writeln!( | ||||||
|                 message, |                 message, | ||||||
|                 " -- {:?} '{:?}' cannot be child of set '{:?}', longer path exists", |                 " -- {:?} '{:?}' cannot be child of set '{:?}', longer path exists", | ||||||
|                 Self::get_node_kind(child), |                 self.get_node_kind(child), | ||||||
|                 self.get_node_name(child), |                 self.get_node_name(child), | ||||||
|                 self.get_node_name(parent), |                 self.get_node_name(parent), | ||||||
|             ) |             ) | ||||||
| @ -1376,48 +1382,95 @@ impl ScheduleGraph { | |||||||
|         error!("{}", message); |         error!("{}", message); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Get topology sorted [`NodeId`], also ensures the graph contains no cycle
 |     /// Tries to topologically sort `graph`.
 | ||||||
|     /// returns Err(()) if there are cycles
 |     ///
 | ||||||
|     fn topsort_graph(&self, graph: &DiGraphMap<NodeId, ()>) -> Result<Vec<NodeId>, ()> { |     /// If the graph is acyclic, returns [`Ok`] with the list of [`NodeId`] in a valid
 | ||||||
|         // tarjan_scc's run order is reverse topological order
 |     /// topological order. If the graph contains cycles, returns [`Err`] with the list of
 | ||||||
|         let mut rev_top_sorted_nodes = Vec::<NodeId>::with_capacity(graph.node_count()); |     /// strongly-connected components that contain cycles (also in a valid topological order).
 | ||||||
|         let mut tarjan_scc = bevy_utils::petgraph::algo::TarjanScc::new(); |     ///
 | ||||||
|         let mut sccs_with_cycle = Vec::<Vec<NodeId>>::new(); |     /// # Errors
 | ||||||
|  |     ///
 | ||||||
|  |     /// If the graph contain cycles, then an error is returned.
 | ||||||
|  |     fn topsort_graph( | ||||||
|  |         &self, | ||||||
|  |         graph: &DiGraphMap<NodeId, ()>, | ||||||
|  |         report: ReportCycles, | ||||||
|  |     ) -> Result<Vec<NodeId>, Vec<Vec<NodeId>>> { | ||||||
|  |         // Tarjan's SCC algorithm returns elements in *reverse* topological order.
 | ||||||
|  |         let mut tarjan_scc = TarjanScc::new(); | ||||||
|  |         let mut top_sorted_nodes = Vec::with_capacity(graph.node_count()); | ||||||
|  |         let mut sccs_with_cycles = Vec::new(); | ||||||
| 
 | 
 | ||||||
|         tarjan_scc.run(graph, |scc| { |         tarjan_scc.run(graph, |scc| { | ||||||
|             // by scc's definition, each scc is the cluster of nodes that they can reach each other
 |             // A strongly-connected component is a group of nodes who can all reach each other
 | ||||||
|             // so scc with size larger than 1, means there is/are cycle(s).
 |             // through one or more paths. If an SCC contains more than one node, there must be
 | ||||||
|  |             // at least one cycle within them.
 | ||||||
|             if scc.len() > 1 { |             if scc.len() > 1 { | ||||||
|                 sccs_with_cycle.push(scc.to_vec()); |                 sccs_with_cycles.push(scc.to_vec()); | ||||||
|             } |             } | ||||||
|             rev_top_sorted_nodes.extend_from_slice(scc); |             top_sorted_nodes.extend_from_slice(scc); | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         if sccs_with_cycle.is_empty() { |         if sccs_with_cycles.is_empty() { | ||||||
|             // reverse the reverted to get topological order
 |             // reverse to get topological order
 | ||||||
|             let mut top_sorted_nodes = rev_top_sorted_nodes; |  | ||||||
|             top_sorted_nodes.reverse(); |             top_sorted_nodes.reverse(); | ||||||
|             Ok(top_sorted_nodes) |             Ok(top_sorted_nodes) | ||||||
|         } else { |         } else { | ||||||
|             self.report_cycles(&sccs_with_cycle); |             let mut cycles = Vec::new(); | ||||||
|             Err(()) |             for scc in &sccs_with_cycles { | ||||||
|  |                 cycles.append(&mut simple_cycles_in_component(graph, scc)); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             match report { | ||||||
|  |                 ReportCycles::Hierarchy => self.report_hierarchy_cycles(&cycles), | ||||||
|  |                 ReportCycles::Dependency => self.report_dependency_cycles(&cycles), | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             Err(sccs_with_cycles) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Print detailed cycle messages
 |     /// Logs details of cycles in the hierarchy graph.
 | ||||||
|     fn report_cycles(&self, sccs_with_cycles: &[Vec<NodeId>]) { |     fn report_hierarchy_cycles(&self, cycles: &[Vec<NodeId>]) { | ||||||
|         let mut message = format!( |         let mut message = format!("schedule has {} in_set cycle(s):\n", cycles.len()); | ||||||
|             "schedule contains at least {} cycle(s)", |         for (i, cycle) in cycles.iter().enumerate() { | ||||||
|             sccs_with_cycles.len() |             let mut names = cycle.iter().map(|id| self.get_node_name(id)); | ||||||
|         ); |             let first_name = names.next().unwrap(); | ||||||
|  |             writeln!( | ||||||
|  |                 message, | ||||||
|  |                 "cycle {}: set '{first_name}' contains itself", | ||||||
|  |                 i + 1, | ||||||
|  |             ) | ||||||
|  |             .unwrap(); | ||||||
|  |             writeln!(message, "set '{first_name}'").unwrap(); | ||||||
|  |             for name in names.chain(std::iter::once(first_name)) { | ||||||
|  |                 writeln!(message, " ... which contains set '{name}'").unwrap(); | ||||||
|  |             } | ||||||
|  |             writeln!(message).unwrap(); | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|         writeln!(message, " -- cycle(s) found within:").unwrap(); |         error!("{}", message); | ||||||
|         for (i, scc) in sccs_with_cycles.iter().enumerate() { |     } | ||||||
|             let names = scc | 
 | ||||||
|  |     /// Logs details of cycles in the dependency graph.
 | ||||||
|  |     fn report_dependency_cycles(&self, cycles: &[Vec<NodeId>]) { | ||||||
|  |         let mut message = format!("schedule has {} before/after cycle(s):\n", cycles.len()); | ||||||
|  |         for (i, cycle) in cycles.iter().enumerate() { | ||||||
|  |             let mut names = cycle | ||||||
|                 .iter() |                 .iter() | ||||||
|                 .map(|id| self.get_node_name(id)) |                 .map(|id| (self.get_node_kind(id), self.get_node_name(id))); | ||||||
|                 .collect::<Vec<_>>(); |             let (first_kind, first_name) = names.next().unwrap(); | ||||||
|             writeln!(message, " ---- {i}: {names:?}").unwrap(); |             writeln!( | ||||||
|  |                 message, | ||||||
|  |                 "cycle {}: {first_kind} '{first_name}' must run before itself", | ||||||
|  |                 i + 1, | ||||||
|  |             ) | ||||||
|  |             .unwrap(); | ||||||
|  |             writeln!(message, "{first_kind} '{first_name}'").unwrap(); | ||||||
|  |             for (kind, name) in names.chain(std::iter::once((first_kind, first_name))) { | ||||||
|  |                 writeln!(message, " ... which must run before {kind} '{name}'").unwrap(); | ||||||
|  |             } | ||||||
|  |             writeln!(message).unwrap(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         error!("{}", message); |         error!("{}", message); | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 Cameron
						Cameron