From 2f293a649079f3c428cad9e0f6867638ed1cd9f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 02:42:50 +0100 Subject: [PATCH] Deepen structural graph analysis --- README.md | 20 ++- RESEARCH.md | 154 ++++++++++++++++++ src/lib.rs | 440 ++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 581 insertions(+), 33 deletions(-) create mode 100644 RESEARCH.md diff --git a/README.md b/README.md index c3e8060..fe75b52 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,13 @@ The crate exposes `analyze(&graph)` for layout-independent structure reads: - weak components - strongly connected components +- biconnected components +- label-propagation communities +- k-core shells - articulation points - bridge edges - per-node degree metrics +- local clustering coefficients - edge roles - cyclic node flags @@ -88,6 +92,9 @@ The crate exposes `analyze(&graph)` for layout-independent structure reads: - SCC cohesion folds cycles and tight feedback regions into local bodies. - weak component cohesion keeps broad regions visually legible. +- community cohesion gives dense modules local territory. +- k-core anchoring lets dense nuclei read differently from peripheral leaves. +- local clustering cohesion tightens triangle-rich neighborhoods. - bridge hinges keep connector edges readable between bodies. - centrality anchoring keeps important nodes from drifting into decorative exile. - cycle folding uses depth so feedback does not have to lose every argument @@ -107,10 +114,15 @@ for group in result.fold_groups { } ``` -Fold groups currently cover weak components, strongly connected components, and -cycle bodies. They are meant to be useful handles for Bevy interactions: -collapse a component, pulse a cycle, dim a subtree, or unfold a dense region -without asking the raw node soup for permission. +Fold groups currently cover weak components, strongly connected components, +biconnected components, communities, core shells, and cycle bodies. They are +meant to be useful handles for Bevy interactions: collapse a component, pulse a +cycle, dim a subtree, or unfold a dense region without asking the raw node soup +for permission. + +See [RESEARCH.md](RESEARCH.md) for the paper trail and the next deeper cuts: +multilevel coarsening, constrained stress, edge bundling, adaptive solvers, and +layout memory. ## Notes diff --git a/RESEARCH.md b/RESEARCH.md new file mode 100644 index 0000000..b126409 --- /dev/null +++ b/RESEARCH.md @@ -0,0 +1,154 @@ +# Research Notes + +This crate is moving toward a staged graph anatomy pipeline: + +```text +graph -> analysis -> coarse bodies -> constraints -> 3D relaxation -> fold groups +``` + +The useful lesson from the literature is blunt: do not ask one force soup to +discover every structure at once. Extract graph structure first, then make the +solver express it. + +## Landed + +These features are implemented in the crate now: + +- SCC detection for cyclic bodies. +- weak components for broad spatial islands. +- articulation points and bridges for hinge-like connectors. +- biconnected components for blocks that should fold as more durable bodies. +- k-core numbers and shells for dense nucleus/periphery structure. +- local clustering coefficients for triangle-rich neighborhoods. +- deterministic label-propagation communities. +- fold groups for weak components, SCCs, biconnected blocks, communities, + core shells, and cycles. + +## Paper-Derived Design Moves + +### Constrained Stress + +Dwyer, Koren, and Marriott show that stress majorization can be augmented with +linear ordering/separation constraints. That gives us a more principled future +solver than hand-added forces when rank, spacing, or no-overlap constraints need +to become harder. + +Implementation direction: + +- Add a `StressConfig`. +- Convert Sugiyama ranks, ordering, and component separation into weighted + constraints. +- Use force relaxation as the interactive fast path and constrained stress as + the higher-quality settle pass. + +Source: +https://doi.org/10.1016/j.disc.2007.12.103 + +### Multilevel Layout + +Walshaw's multilevel force-directed method repeatedly coarsens the graph, lays +out the coarse graph, then expands and refines. This is exactly the missing +piece for large graphs and foldable bodies: layout the anatomy before the cells. + +Implementation direction: + +- Coarsen by SCC, community, biconnected block, or k-core shell. +- Lay out coarse bodies first. +- Expand each body into local coordinates. +- Relax with cross-body hinge and boundary forces. + +Source: +https://doi.org/10.7155/jgaa.00070 + +### Continuous Layout Ergonomics + +ForceAtlas2 is not magic; it is a practical assembly of force choices, adaptive +speed, Barnes-Hut approximation, and usability knobs. The useful part for this +crate is adaptive step control and Barnes-Hut for large interactive layouts. + +Implementation direction: + +- Replace fixed cooling with adaptive speed. +- Add Barnes-Hut or grid approximation for repulsion. +- Expose live-tick APIs for Bevy interaction instead of only batch layout. + +Source: +https://doi.org/10.1371/journal.pone.0098679 + +### Communities + +Louvain and Leiden are the serious modularity route. Leiden improves Louvain by +guaranteeing better-connected communities. The crate currently uses deterministic +label propagation because it is small, fast, dependency-free, and good enough as +a first structural signal. It is not the final boss. + +Implementation direction: + +- Keep label propagation as the cheap default. +- Add optional Louvain/Leiden-style modularity optimization later. +- Use community stability across multiple passes as a visual confidence score. + +Sources: +https://arxiv.org/abs/0803.0476 +https://www.nature.com/articles/s41598-019-41695-z +https://doi.org/10.1103/PhysRevE.76.036106 + +### Core/Periphery + +k-core decomposition is cheap and useful for visualization. Dense cores should +read as nuclei; low-core leaves should read as peripheral atmosphere, not equal +citizens in the same spatial argument. + +Implementation direction: + +- Use core number to scale anchoring, node mass, and opacity hints. +- Add `FoldGroupKind::CoreShell` interactions in Bevy. +- Give high-core regions their own collision shell. + +Sources: +https://arxiv.org/abs/cs/0504107 +https://www.nature.com/articles/30918 + +### Edge Bundling + +Edge bundling is powerful but dangerous. It can reduce clutter while also making +the underlying graph harder to recover. The right version here is structural and +inspectable: bundles should be generated from known bodies, edge roles, and +hierarchy paths, not vague visual mush. + +Implementation direction: + +- Add edge control points derived from fold groups. +- Bundle bridge/cross/back edges differently. +- Preserve unbundled edge identity for picking and inspection. +- Treat bundling as renderer geometry, not graph truth. + +Sources: +https://www.microsoft.com/en-us/research/publication/improving-layered-graph-layouts-with-edgebundling/ +https://arxiv.org/abs/2108.05467 + +### Dynamic Mental Map + +Mental-map preservation is more task-dependent than the usual folklore admits. +The useful move is not "never move nodes"; it is preserving stable landmarks +when the user needs orientation, while allowing meaningful rearrangement when +new structure appears. + +Implementation direction: + +- Add stable node anchors from previous layouts. +- Preserve fold group centers more strongly than individual leaf nodes. +- Expose a `LayoutMemory` object for incremental Bevy updates. + +Source: +https://doi.org/10.1016/j.ijhcs.2013.08.004 + +## Next High-Value Crate Work + +1. Add multilevel coarsening and expansion. +2. Add a live iterative solver API for Bevy. +3. Add Barnes-Hut/grid repulsion. +4. Add edge routing and structural bundling output. +5. Add optional constrained-stress settling. + +That is the machine with teeth. Everything else is decoration wearing a badge. diff --git a/src/lib.rs b/src/lib.rs index ed63c68..00d6ecf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,8 @@ //! ordering, and edge direction pressure. A deterministic force solver then //! relaxes those constraints alongside springs, repulsion, and collision. -use std::collections::VecDeque; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, VecDeque}; /// Index of a node in a [`Graph`]. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -140,6 +141,12 @@ pub struct Layout3dConfig { pub cycle_fold_strength: f32, /// Force anchoring high-centrality nodes closer to their component center. pub centrality_anchor_strength: f32, + /// Force pulling label-propagation communities into local regions. + pub community_cohesion_strength: f32, + /// Force giving dense core nodes stronger horizontal anchoring. + pub core_anchor_strength: f32, + /// Force keeping high-clustering neighborhoods compact. + pub clustering_cohesion_strength: f32, } impl Default for Layout3dConfig { @@ -156,6 +163,9 @@ impl Default for Layout3dConfig { bridge_hinge_strength: 0.025, cycle_fold_strength: 0.035, centrality_anchor_strength: 0.02, + community_cohesion_strength: 0.025, + core_anchor_strength: 0.018, + clustering_cohesion_strength: 0.02, } } } @@ -210,12 +220,17 @@ pub struct Layout3d { pub struct GraphAnalysis { pub weak_components: Vec, pub strongly_connected_components: Vec, + pub biconnected_components: Vec, + pub communities: Vec, + pub core_shells: Vec, pub articulation_points: Vec, pub bridges: Vec, pub node_metrics: Vec, pub edge_roles: Vec, pub node_to_weak_component: Vec, pub node_to_strong_component: Vec, + pub node_to_community: Vec, + pub max_core: usize, } #[derive(Clone, Debug, PartialEq)] @@ -230,6 +245,10 @@ pub struct NodeMetrics { pub out_degree: usize, pub undirected_degree: usize, pub degree_centrality: f32, + pub core_number: usize, + pub local_clustering_coefficient: f32, + pub community_id: usize, + pub biconnected_component_count: usize, pub is_articulation: bool, pub is_cyclic: bool, } @@ -259,6 +278,9 @@ pub struct FoldGroup { pub enum FoldGroupKind { WeakComponent, StrongComponent, + BiconnectedComponent, + Community, + CoreShell, Cycle, } @@ -379,15 +401,26 @@ pub fn analyze(graph: &Graph) -> GraphAnalysis { let undirected = undirected_adjacency(graph); let weak_components = weak_components(&undirected); let (sccs, node_to_scc) = strongly_connected_components(&directed); - let (articulation_points, bridges) = articulation_points_and_bridges(&undirected); + let (articulation_points, bridges, biconnected_components) = + articulation_points_bridges_and_blocks(&undirected); + let core_numbers = core_numbers(&undirected); + let max_core = core_numbers.iter().copied().max().unwrap_or(0); + let core_shells = core_shells(&core_numbers); + let local_clustering = local_clustering_coefficients(&undirected); + let (communities, node_to_community) = label_propagation_communities(&undirected, 16); + let biconnected_counts = biconnected_component_counts(n, &biconnected_components); let edge_roles = classify_edges(graph, &node_to_scc, &bridges); - let node_metrics = node_metrics( + let node_metrics = node_metrics(MetricsInput { graph, - &undirected, - &articulation_points, - &sccs, - &node_to_scc, - ); + undirected: &undirected, + articulation_points: &articulation_points, + sccs: &sccs, + node_to_scc: &node_to_scc, + core_numbers: &core_numbers, + local_clustering: &local_clustering, + node_to_community: &node_to_community, + biconnected_counts: &biconnected_counts, + }); let mut node_to_weak_component = vec![0usize; n]; for component in &weak_components { for node in &component.nodes { @@ -398,12 +431,17 @@ pub fn analyze(graph: &Graph) -> GraphAnalysis { GraphAnalysis { weak_components, strongly_connected_components: sccs, + biconnected_components, + communities, + core_shells, articulation_points, bridges, node_metrics, edge_roles, node_to_weak_component, node_to_strong_component: node_to_scc, + node_to_community, + max_core, } } @@ -579,9 +617,9 @@ fn strongly_connected_components(adjacency: &[Vec]) -> (Vec, V (tarjan.components, tarjan.node_to_component) } -fn articulation_points_and_bridges( +fn articulation_points_bridges_and_blocks( adjacency: &[Vec<(usize, EdgeId)>], -) -> (Vec, Vec) { +) -> (Vec, Vec, Vec) { struct Dfs<'a> { adjacency: &'a [Vec<(usize, EdgeId)>], time: usize, @@ -590,6 +628,8 @@ fn articulation_points_and_bridges( low: Vec, articulation: Vec, bridges: Vec, + edge_stack: Vec, + blocks: Vec, } impl Dfs<'_> { @@ -605,17 +645,24 @@ fn articulation_points_and_bridges( continue; } if self.visited[next] { + if self.discovery[next] < self.discovery[node] { + self.edge_stack.push(edge); + } self.low[node] = self.low[node].min(self.discovery[next]); continue; } child_count += 1; + self.edge_stack.push(edge); self.visit(next, Some(edge)); self.low[node] = self.low[node].min(self.low[next]); if parent_edge.is_some() && self.low[next] >= self.discovery[node] { self.articulation[node] = true; } + if self.low[next] >= self.discovery[node] { + self.pop_block(edge); + } if self.low[next] > self.discovery[node] { self.bridges.push(edge); } @@ -625,6 +672,26 @@ fn articulation_points_and_bridges( self.articulation[node] = true; } } + + fn pop_block(&mut self, stop_edge: EdgeId) { + let mut nodes = Vec::new(); + while let Some(edge) = self.edge_stack.pop() { + let (source, target) = endpoints_for_edge(self.adjacency, edge); + nodes.push(NodeId(source)); + nodes.push(NodeId(target)); + if edge == stop_edge { + break; + } + } + nodes.sort_by_key(|node| node.0); + nodes.dedup(); + if !nodes.is_empty() { + self.blocks.push(Component { + id: self.blocks.len(), + nodes, + }); + } + } } let n = adjacency.len(); @@ -636,6 +703,8 @@ fn articulation_points_and_bridges( low: vec![0; n], articulation: vec![false; n], bridges: Vec::new(), + edge_stack: Vec::new(), + blocks: Vec::new(), }; for node in 0..n { @@ -651,7 +720,180 @@ fn articulation_points_and_bridges( .filter_map(|(idx, &is_articulation)| is_articulation.then_some(NodeId(idx))) .collect(); dfs.bridges.sort_by_key(|edge| edge.0); - (articulation_points, dfs.bridges) + (articulation_points, dfs.bridges, dfs.blocks) +} + +fn endpoints_for_edge(adjacency: &[Vec<(usize, EdgeId)>], edge: EdgeId) -> (usize, usize) { + for (source, edges) in adjacency.iter().enumerate() { + for &(target, candidate) in edges { + if candidate == edge && source <= target { + return (source, target); + } + } + } + + for (source, edges) in adjacency.iter().enumerate() { + for &(target, candidate) in edges { + if candidate == edge { + return (source, target); + } + } + } + + (0, 0) +} + +fn core_numbers(adjacency: &[Vec<(usize, EdgeId)>]) -> Vec { + let n = adjacency.len(); + let mut degree = adjacency.iter().map(Vec::len).collect::>(); + let mut core = vec![0usize; n]; + let mut removed = vec![false; n]; + let mut heap = BinaryHeap::new(); + + for (node, &node_degree) in degree.iter().enumerate() { + heap.push(Reverse((node_degree, node))); + } + + while let Some(Reverse((candidate_degree, node))) = heap.pop() { + if removed[node] || candidate_degree != degree[node] { + continue; + } + + removed[node] = true; + core[node] = candidate_degree; + + for &(next, _) in &adjacency[node] { + if !removed[next] && degree[next] > candidate_degree { + degree[next] -= 1; + heap.push(Reverse((degree[next], next))); + } + } + } + + core +} + +fn core_shells(core_numbers: &[usize]) -> Vec { + let max_core = core_numbers.iter().copied().max().unwrap_or(0); + let mut shells = vec![Vec::new(); max_core + 1]; + for (idx, &core) in core_numbers.iter().enumerate() { + shells[core].push(NodeId(idx)); + } + + shells + .into_iter() + .enumerate() + .filter_map(|(id, nodes)| (!nodes.is_empty()).then_some(Component { id, nodes })) + .collect() +} + +fn local_clustering_coefficients(adjacency: &[Vec<(usize, EdgeId)>]) -> Vec { + let n = adjacency.len(); + let mut marks = vec![false; n]; + let mut coefficients = vec![0.0f32; n]; + + for node in 0..n { + let neighbors = adjacency[node] + .iter() + .map(|&(neighbor, _)| neighbor) + .collect::>(); + let degree = neighbors.len(); + if degree < 2 { + continue; + } + + for &neighbor in &neighbors { + marks[neighbor] = true; + } + + let mut links = 0usize; + for &neighbor in &neighbors { + for &(candidate, _) in &adjacency[neighbor] { + if marks[candidate] { + links += 1; + } + } + } + + for &neighbor in &neighbors { + marks[neighbor] = false; + } + + coefficients[node] = links as f32 / (degree * (degree - 1)) as f32; + } + + coefficients +} + +fn label_propagation_communities( + adjacency: &[Vec<(usize, EdgeId)>], + max_iterations: usize, +) -> (Vec, Vec) { + let n = adjacency.len(); + let mut labels = (0..n).collect::>(); + let mut counts = Vec::<(usize, usize)>::new(); + + for _ in 0..max_iterations { + let mut changed = false; + for node in 0..n { + counts.clear(); + for &(neighbor, _) in &adjacency[node] { + let label = labels[neighbor]; + if let Some((_, count)) = counts.iter_mut().find(|(seen, _)| *seen == label) { + *count += 1; + } else { + counts.push((label, 1)); + } + } + + if counts.is_empty() { + continue; + } + + counts.sort_by_key(|&(label, count)| (Reverse(count), label)); + let best_label = counts[0].0; + if labels[node] != best_label { + labels[node] = best_label; + changed = true; + } + } + + if !changed { + break; + } + } + + let mut unique = labels.clone(); + unique.sort_unstable(); + unique.dedup(); + + let mut node_to_community = vec![0usize; n]; + let mut communities = unique + .iter() + .enumerate() + .map(|(id, _)| Component { + id, + nodes: Vec::new(), + }) + .collect::>(); + + for (node, label) in labels.iter().enumerate() { + let community = unique.binary_search(label).unwrap_or(0); + node_to_community[node] = community; + communities[community].nodes.push(NodeId(node)); + } + + (communities, node_to_community) +} + +fn biconnected_component_counts(n: usize, components: &[Component]) -> Vec { + let mut counts = vec![0usize; n]; + for component in components { + for node in &component.nodes { + counts[node.0] += 1; + } + } + counts } fn classify_edges(graph: &Graph, node_to_scc: &[usize], bridges: &[EdgeId]) -> Vec { @@ -676,35 +918,41 @@ fn classify_edges(graph: &Graph, node_to_scc: &[usize], bridges: &[EdgeId]) -> V roles } -fn node_metrics( - graph: &Graph, - undirected: &[Vec<(usize, EdgeId)>], - articulation_points: &[NodeId], - sccs: &[Component], - node_to_scc: &[usize], -) -> Vec { - let n = graph.node_count(); +struct MetricsInput<'a> { + graph: &'a Graph, + undirected: &'a [Vec<(usize, EdgeId)>], + articulation_points: &'a [NodeId], + sccs: &'a [Component], + node_to_scc: &'a [usize], + core_numbers: &'a [usize], + local_clustering: &'a [f32], + node_to_community: &'a [usize], + biconnected_counts: &'a [usize], +} + +fn node_metrics(input: MetricsInput<'_>) -> Vec { + let n = input.graph.node_count(); let mut in_degree = vec![0usize; n]; let mut out_degree = vec![0usize; n]; - for &(source, target) in graph.edges() { + for &(source, target) in input.graph.edges() { out_degree[source.0] += 1; in_degree[target.0] += 1; } let mut articulation = vec![false; n]; - for node in articulation_points { + for node in input.articulation_points { articulation[node.0] = true; } - let mut cyclic_scc = vec![false; sccs.len()]; - for component in sccs { + let mut cyclic_scc = vec![false; input.sccs.len()]; + for component in input.sccs { if component.nodes.len() > 1 { cyclic_scc[component.id] = true; } } - for &(source, target) in graph.edges() { + for &(source, target) in input.graph.edges() { if source == target { - cyclic_scc[node_to_scc[source.0]] = true; + cyclic_scc[input.node_to_scc[source.0]] = true; } } @@ -713,10 +961,14 @@ fn node_metrics( .map(|idx| NodeMetrics { in_degree: in_degree[idx], out_degree: out_degree[idx], - undirected_degree: undirected[idx].len(), - degree_centrality: undirected[idx].len() as f32 / centrality_denominator, + undirected_degree: input.undirected[idx].len(), + degree_centrality: input.undirected[idx].len() as f32 / centrality_denominator, + core_number: input.core_numbers[idx], + local_clustering_coefficient: input.local_clustering[idx], + community_id: input.node_to_community[idx], + biconnected_component_count: input.biconnected_counts[idx], is_articulation: articulation[idx], - is_cyclic: cyclic_scc[node_to_scc[idx]], + is_cyclic: cyclic_scc[input.node_to_scc[idx]], }) .collect() } @@ -903,6 +1155,7 @@ fn relax_3d( ) { let n = graph.node_count(); let mut forces = vec![(0.0f32, 0.0f32, 0.0f32); n]; + let undirected = undirected_adjacency(graph); for iter in 0..config.base.iterations { forces.fill((0.0, 0.0, 0.0)); @@ -910,7 +1163,7 @@ fn relax_3d( apply_repulsion_3d(positions, &mut forces, config); apply_springs_3d(graph, positions, &mut forces, config); apply_constraints_3d(constraints, positions, &mut forces, config); - apply_structural_forces_3d(graph, analysis, positions, &mut forces, config); + apply_structural_forces_3d(graph, &undirected, analysis, positions, &mut forces, config); apply_grounding_3d(positions, &mut forces, config); let cooling = 1.0 - (iter as f32 / config.base.iterations.max(1) as f32); @@ -925,6 +1178,7 @@ fn relax_3d( fn apply_structural_forces_3d( graph: &Graph, + undirected: &[Vec<(usize, EdgeId)>], analysis: &GraphAnalysis, positions: &[(f32, f32, f32)], forces: &mut [(f32, f32, f32)], @@ -942,7 +1196,15 @@ fn apply_structural_forces_3d( forces, config.weak_component_cohesion_strength, ); + apply_component_cohesion( + &analysis.communities, + positions, + forces, + config.community_cohesion_strength, + ); apply_centrality_anchors(analysis, positions, forces, config); + apply_core_anchors(analysis, positions, forces, config); + apply_clustering_cohesion(graph, undirected, analysis, positions, forces, config); apply_cycle_folds(analysis, positions, forces, config); apply_bridge_hinges(graph, analysis, positions, forces, config); } @@ -998,6 +1260,59 @@ fn apply_centrality_anchors( } } +fn apply_core_anchors( + analysis: &GraphAnalysis, + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + if config.core_anchor_strength <= 0.0 || analysis.max_core == 0 { + return; + } + + for component in &analysis.weak_components { + let center = component_center(&component.nodes, positions); + for node in &component.nodes { + let idx = node.0; + let core_ratio = + analysis.node_metrics[idx].core_number as f32 / analysis.max_core as f32; + let strength = config.core_anchor_strength * core_ratio * core_ratio; + forces[idx].0 += (center[0] - positions[idx].0) * strength; + forces[idx].2 += (center[2] - positions[idx].2) * strength; + } + } +} + +fn apply_clustering_cohesion( + graph: &Graph, + adjacency: &[Vec<(usize, EdgeId)>], + analysis: &GraphAnalysis, + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + if config.clustering_cohesion_strength <= 0.0 { + return; + } + + for node in 0..graph.node_count() { + let coefficient = analysis.node_metrics[node].local_clustering_coefficient; + if coefficient <= 0.0 || adjacency[node].len() < 2 { + continue; + } + + let mut nodes = adjacency[node] + .iter() + .map(|&(neighbor, _)| NodeId(neighbor)) + .collect::>(); + nodes.push(NodeId(node)); + let center = component_center(&nodes, positions); + let strength = config.clustering_cohesion_strength * coefficient; + forces[node].0 += (center[0] - positions[node].0) * strength; + forces[node].2 += (center[2] - positions[node].2) * strength; + } +} + fn apply_cycle_folds( analysis: &GraphAnalysis, positions: &[(f32, f32, f32)], @@ -1109,6 +1424,53 @@ fn build_fold_groups(analysis: &GraphAnalysis, positions: &[(f32, f32, f32)]) -> )); } + for component in &analysis.biconnected_components { + if component.nodes.len() < 3 { + continue; + } + let parent = component + .nodes + .first() + .map(|node| analysis.node_to_weak_component[node.0]); + groups.push(fold_group( + groups.len(), + FoldGroupKind::BiconnectedComponent, + component.nodes.clone(), + positions, + parent, + )); + } + + for component in &analysis.communities { + if component.nodes.len() < 2 { + continue; + } + let parent = component + .nodes + .first() + .map(|node| analysis.node_to_weak_component[node.0]); + groups.push(fold_group( + groups.len(), + FoldGroupKind::Community, + component.nodes.clone(), + positions, + parent, + )); + } + + for component in &analysis.core_shells { + if component.nodes.len() < 2 { + continue; + } + groups.push(fold_group( + groups.len(), + FoldGroupKind::CoreShell, + component.nodes.clone(), + positions, + None, + )); + } + for component in &analysis.strongly_connected_components { let is_cycle = component .nodes @@ -1471,6 +1833,14 @@ mod tests { assert!(analysis.articulation_points.contains(&c)); assert!(analysis.bridges.contains(&bridge)); assert_eq!(analysis.edge_roles[bridge.0], EdgeRole::Bridge); + assert!(analysis.biconnected_components.len() >= 2); + assert!(analysis.max_core >= 1); + assert!(analysis.node_metrics[a.0].core_number >= 2); + assert!(analysis.node_metrics[a.0].local_clustering_coefficient > 0.0); + assert_eq!( + analysis.node_metrics[c.0].biconnected_component_count, + analysis.biconnected_components.len() + ); } #[test] @@ -1503,5 +1873,17 @@ mod tests { .iter() .any(|group| group.kind == FoldGroupKind::Cycle) ); + assert!( + result + .fold_groups + .iter() + .any(|group| group.kind == FoldGroupKind::Community) + ); + assert!( + result + .fold_groups + .iter() + .any(|group| group.kind == FoldGroupKind::CoreShell) + ); } }