diff --git a/PSGraph.Common/Model/PSCentralityRecord.cs b/PSGraph.Common/Model/PSCentralityRecord.cs new file mode 100644 index 0000000..9cc8f27 --- /dev/null +++ b/PSGraph.Common/Model/PSCentralityRecord.cs @@ -0,0 +1,9 @@ +using PSGraph.Model; + +namespace PSGraph.Common.Model; + +public class PSCentralityRecord +{ + public required PSVertex Vertex; + public double Centrality; +} diff --git a/PSGraph.Common/Model/PSConnectedComponentRecord.cs b/PSGraph.Common/Model/PSConnectedComponentRecord.cs new file mode 100644 index 0000000..cb3f779 --- /dev/null +++ b/PSGraph.Common/Model/PSConnectedComponentRecord.cs @@ -0,0 +1,9 @@ +using PSGraph.Model; + +namespace PSGraph.Common.Model; + +public class PSConnectedComponentRecord +{ + public required PSVertex Vertex; + public int ComponentId; +} diff --git a/PSGraph.Common/Model/PSCycleRecord.cs b/PSGraph.Common/Model/PSCycleRecord.cs new file mode 100644 index 0000000..b9f6ea5 --- /dev/null +++ b/PSGraph.Common/Model/PSCycleRecord.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using PSGraph.Model; + +namespace PSGraph.Common.Model; + +public class PSCycleRecord +{ + public required IList Vertices; +} diff --git a/PSGraph.Tests/FindGraphCycleTests.cs b/PSGraph.Tests/FindGraphCycleTests.cs new file mode 100644 index 0000000..90da7cb --- /dev/null +++ b/PSGraph.Tests/FindGraphCycleTests.cs @@ -0,0 +1,52 @@ +using Xunit; +using System; +using System.Management.Automation; +using PSGraph.Model; +using FluentAssertions; +using System.Linq; +using PSGraph.Common.Model; + +namespace PSGraph.Tests +{ + public class FindGraphCycleCmdletTests : IDisposable + { + private readonly PowerShell _powershell; + + public FindGraphCycleCmdletTests() + { + _powershell = PowerShell.Create(); + _powershell.AddCommand("Import-Module").AddParameter("Assembly", typeof(PSGraph.Cmdlets.FindGraphCycle).Assembly); + _powershell.Invoke(); + _powershell.Commands.Clear(); + } + + public void Dispose() + { + _powershell.Dispose(); + } + + [Fact] + public void FindGraphCycle_DetectsCycle() + { + _powershell.AddCommand("New-Graph"); + var graphResults = _powershell.Invoke(); + var graph = graphResults[0].BaseObject as PsBidirectionalGraph; + _powershell.Commands.Clear(); + + var a = new PSVertex("A"); + var b = new PSVertex("B"); + var cVertex = new PSVertex("C"); + var d = new PSVertex("D"); + graph.AddVertexRange(new[] { a, b, cVertex, d }); + graph.AddEdge(new PSEdge(a, b, new PSEdgeTag())); + graph.AddEdge(new PSEdge(b, cVertex, new PSEdgeTag())); + graph.AddEdge(new PSEdge(cVertex, a, new PSEdgeTag())); + + _powershell.AddCommand("Find-GraphCycle").AddParameter("Graph", graph); + var results = _powershell.Invoke(); + var cycles = results.Select(r => r.BaseObject as PSCycleRecord).ToList(); + cycles.Should().NotBeEmpty(); + cycles.Any(cycle => cycle.Vertices.Contains(a) && cycle.Vertices.Contains(b) && cycle.Vertices.Contains(cVertex)).Should().BeTrue(); + } + } +} diff --git a/PSGraph.Tests/GetGraphCentralityTests.cs b/PSGraph.Tests/GetGraphCentralityTests.cs new file mode 100644 index 0000000..774f70c --- /dev/null +++ b/PSGraph.Tests/GetGraphCentralityTests.cs @@ -0,0 +1,52 @@ +using Xunit; +using System; +using System.Management.Automation; +using PSGraph.Model; +using FluentAssertions; +using System.Linq; +using PSGraph.Common.Model; + +namespace PSGraph.Tests +{ + public class GetGraphCentralityCmdletTests : IDisposable + { + private readonly PowerShell _powershell; + + public GetGraphCentralityCmdletTests() + { + _powershell = PowerShell.Create(); + _powershell.AddCommand("Import-Module").AddParameter("Assembly", typeof(PSGraph.Cmdlets.GetGraphCentrality).Assembly); + _powershell.Invoke(); + _powershell.Commands.Clear(); + } + + public void Dispose() + { + _powershell.Dispose(); + } + + [Fact] + public void GetGraphCentrality_ComputesBetweenness() + { + _powershell.AddCommand("New-Graph"); + var graphResults = _powershell.Invoke(); + var graph = graphResults[0].BaseObject as PsBidirectionalGraph; + _powershell.Commands.Clear(); + + var a = new PSVertex("A"); + var b = new PSVertex("B"); + var c = new PSVertex("C"); + graph.AddVertexRange(new[] { a, b, c }); + graph.AddEdge(new PSEdge(a, b, new PSEdgeTag())); + graph.AddEdge(new PSEdge(b, c, new PSEdgeTag())); + + _powershell.AddCommand("Get-GraphCentrality").AddParameter("Graph", graph); + var results = _powershell.Invoke(); + var records = results.Select(r => r.BaseObject as PSCentralityRecord).ToList(); + + records.Should().HaveCount(3); + records.Single(r => r.Vertex == b).Centrality.Should().BeGreaterThan(records.Single(r => r.Vertex == a).Centrality); + records.Single(r => r.Vertex == b).Centrality.Should().BeGreaterThan(records.Single(r => r.Vertex == c).Centrality); + } + } +} diff --git a/PSGraph.Tests/GetGraphConnectedComponentTests.cs b/PSGraph.Tests/GetGraphConnectedComponentTests.cs new file mode 100644 index 0000000..c83bd0a --- /dev/null +++ b/PSGraph.Tests/GetGraphConnectedComponentTests.cs @@ -0,0 +1,57 @@ +using Xunit; +using System; +using System.Management.Automation; +using PSGraph.Model; +using FluentAssertions; +using System.Linq; +using PSGraph.Common.Model; + +namespace PSGraph.Tests +{ + public class GetGraphConnectedComponentCmdletTests : IDisposable + { + private readonly PowerShell _powershell; + + public GetGraphConnectedComponentCmdletTests() + { + _powershell = PowerShell.Create(); + _powershell.AddCommand("Import-Module").AddParameter("Assembly", typeof(PSGraph.Cmdlets.GetGraphConnectedComponent).Assembly); + _powershell.Invoke(); + _powershell.Commands.Clear(); + } + + public void Dispose() + { + _powershell.Dispose(); + } + + [Fact] + public void GetGraphConnectedComponent_ReturnsComponents() + { + _powershell.AddCommand("New-Graph"); + var graphResults = _powershell.Invoke(); + var graph = graphResults[0].BaseObject as PsBidirectionalGraph; + + _powershell.Commands.Clear(); + + var a = new PSVertex("A"); + var b = new PSVertex("B"); + var c = new PSVertex("C"); + var d = new PSVertex("D"); + graph.AddVertexRange(new[] { a, b, c, d }); + graph.AddEdge(new PSEdge(a, b, new PSEdgeTag())); + graph.AddEdge(new PSEdge(b, a, new PSEdgeTag())); + graph.AddEdge(new PSEdge(c, d, new PSEdgeTag())); + graph.AddEdge(new PSEdge(d, c, new PSEdgeTag())); + + _powershell.AddCommand("Get-GraphConnectedComponent").AddParameter("Graph", graph); + var results = _powershell.Invoke(); + + var records = results.Select(r => r.BaseObject as PSConnectedComponentRecord).ToList(); + records.Should().HaveCount(4); + var compAB = records.Where(r => r.Vertex == a || r.Vertex == b).Select(r => r.ComponentId).Distinct().Single(); + var compCD = records.Where(r => r.Vertex == c || r.Vertex == d).Select(r => r.ComponentId).Distinct().Single(); + compAB.Should().NotBe(compCD); + } + } +} diff --git a/PSGraph/cmdlets/graph/FindGraphCycle.cs b/PSGraph/cmdlets/graph/FindGraphCycle.cs new file mode 100644 index 0000000..9fefe6b --- /dev/null +++ b/PSGraph/cmdlets/graph/FindGraphCycle.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Management.Automation; +using QuikGraph.Algorithms.Search; +using PSGraph.Model; +using PSGraph.Common.Model; + +namespace PSGraph.Cmdlets +{ + [Cmdlet("Find", "GraphCycle")] + public class FindGraphCycle : PSCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNull] + public PsBidirectionalGraph Graph { get; set; } = null!; + + protected override void EndProcessing() + { + var dfs = new DepthFirstSearchAlgorithm(Graph); + var parents = new Dictionary(); + var cycles = new List(); + + dfs.TreeEdge += e => parents[e.Target] = e.Source; + dfs.BackEdge += e => + { + var cycle = new List { e.Target }; + var current = e.Source; + while (!EqualityComparer.Default.Equals(current, e.Target) && parents.TryGetValue(current, out var parent)) + { + cycle.Add(current); + current = parent; + } + cycle.Add(e.Target); + cycle.Reverse(); + cycles.Add(new PSCycleRecord { Vertices = cycle }); + }; + + dfs.Compute(); + + WriteObject(cycles, enumerateCollection: true); + } + } +} diff --git a/PSGraph/cmdlets/graph/GetGraphCentrality.cs b/PSGraph/cmdlets/graph/GetGraphCentrality.cs new file mode 100644 index 0000000..7086c8f --- /dev/null +++ b/PSGraph/cmdlets/graph/GetGraphCentrality.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using PSGraph.Common.Model; +using PSGraph.Model; + +namespace PSGraph.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "GraphCentrality")] + public class GetGraphCentrality : PSCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNull] + public PsBidirectionalGraph Graph { get; set; } = null!; + + protected override void EndProcessing() + { + var centrality = ComputeBetweennessCentrality(Graph); + var results = new List(); + foreach (var kvp in centrality) + { + results.Add(new PSCentralityRecord { Vertex = kvp.Key, Centrality = kvp.Value }); + } + WriteObject(results, enumerateCollection: true); + } + + private static Dictionary ComputeBetweennessCentrality(PsBidirectionalGraph graph) + { + var Cb = graph.Vertices.ToDictionary(v => v, v => 0.0); + foreach (var s in graph.Vertices) + { + var S = new Stack(); + var P = new Dictionary>(); + var sigma = new Dictionary(); + var dist = new Dictionary(); + + foreach (var v in graph.Vertices) + { + P[v] = new List(); + sigma[v] = 0.0; + dist[v] = -1; + } + sigma[s] = 1.0; + dist[s] = 0; + var Q = new Queue(); + Q.Enqueue(s); + while (Q.Count > 0) + { + var v = Q.Dequeue(); + S.Push(v); + foreach (var edge in graph.OutEdges(v)) + { + var w = edge.Target; + if (dist[w] < 0) + { + Q.Enqueue(w); + dist[w] = dist[v] + 1; + } + if (dist[w] == dist[v] + 1) + { + sigma[w] += sigma[v]; + P[w].Add(v); + } + } + } + var delta = graph.Vertices.ToDictionary(v => v, v => 0.0); + while (S.Count > 0) + { + var w = S.Pop(); + foreach (var v in P[w]) + { + delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w]); + } + if (!EqualityComparer.Default.Equals(w, s)) + Cb[w] += delta[w]; + } + } + return Cb; + } + } +} diff --git a/PSGraph/cmdlets/graph/GetGraphConnectedComponent.cs b/PSGraph/cmdlets/graph/GetGraphConnectedComponent.cs new file mode 100644 index 0000000..bb8d026 --- /dev/null +++ b/PSGraph/cmdlets/graph/GetGraphConnectedComponent.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Management.Automation; +using QuikGraph.Algorithms.ConnectedComponents; +using PSGraph.Model; +using PSGraph.Common.Model; + +namespace PSGraph.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "GraphConnectedComponent")] + public class GetGraphConnectedComponent : PSCmdlet + { + [Parameter(Mandatory = true)] + [ValidateNotNull] + public PsBidirectionalGraph Graph { get; set; } = null!; + + protected override void EndProcessing() + { + var algorithm = new StronglyConnectedComponentsAlgorithm(Graph); + algorithm.Compute(); + + var results = new List(); + foreach (var pair in algorithm.Components) + { + results.Add(new PSConnectedComponentRecord { Vertex = pair.Key, ComponentId = pair.Value }); + } + + WriteObject(results, enumerateCollection: true); + } + } +} diff --git a/PsGraph.Pester.Tests/PSGraph.Analysis.Tests.ps1 b/PsGraph.Pester.Tests/PSGraph.Analysis.Tests.ps1 new file mode 100644 index 0000000..4abf9b3 --- /dev/null +++ b/PsGraph.Pester.Tests/PSGraph.Analysis.Tests.ps1 @@ -0,0 +1,65 @@ +BeforeAll { + Import-Module "./PSGraph.Tests/bin/Debug/net9.0/PSQuickGraph.psd1" +} + +Describe 'Graph analysis cmdlets' { + Context 'Get-GraphConnectedComponent' { + It 'groups vertices into components' { + $graph = New-Graph + Add-Vertex -Graph $graph -Vertex 'A' + Add-Vertex -Graph $graph -Vertex 'B' + Add-Vertex -Graph $graph -Vertex 'C' + Add-Vertex -Graph $graph -Vertex 'D' + + Add-Edge -From 'A' -To 'B' -Graph $graph | Out-Null + Add-Edge -From 'B' -To 'A' -Graph $graph | Out-Null + Add-Edge -From 'C' -To 'D' -Graph $graph | Out-Null + Add-Edge -From 'D' -To 'C' -Graph $graph | Out-Null + + $components = Get-GraphConnectedComponent -Graph $graph + $components | Should -Not -BeNullOrEmpty + $compA = ($components | Where-Object { $_.Vertex.Name -eq 'A' }).ComponentId + $compB = ($components | Where-Object { $_.Vertex.Name -eq 'B' }).ComponentId + $compC = ($components | Where-Object { $_.Vertex.Name -eq 'C' }).ComponentId + $compA | Should -BeExactly $compB + $compA | Should -Not -BeExactly $compC + } + } + + Context 'Find-GraphCycle' { + It 'detects cycles in the graph' { + $graph = New-Graph + Add-Vertex -Graph $graph -Vertex 'A' + Add-Vertex -Graph $graph -Vertex 'B' + Add-Vertex -Graph $graph -Vertex 'C' + Add-Edge -From 'A' -To 'B' -Graph $graph | Out-Null + Add-Edge -From 'B' -To 'C' -Graph $graph | Out-Null + Add-Edge -From 'C' -To 'A' -Graph $graph | Out-Null + + $cycles = Find-GraphCycle -Graph $graph + $cycles | Should -Not -BeNullOrEmpty + ($cycles[0].Vertices | ForEach-Object Name) | Should -Contain 'A' + ($cycles[0].Vertices | ForEach-Object Name) | Should -Contain 'B' + ($cycles[0].Vertices | ForEach-Object Name) | Should -Contain 'C' + } + } + + Context 'Get-GraphCentrality' { + It 'computes betweenness centrality' { + $graph = New-Graph + Add-Vertex -Graph $graph -Vertex 'A' + Add-Vertex -Graph $graph -Vertex 'B' + Add-Vertex -Graph $graph -Vertex 'C' + Add-Edge -From 'A' -To 'B' -Graph $graph | Out-Null + Add-Edge -From 'B' -To 'C' -Graph $graph | Out-Null + + $centrality = Get-GraphCentrality -Graph $graph + $centrality | Should -Not -BeNullOrEmpty + $centA = ($centrality | Where-Object { $_.Vertex.Name -eq 'A' }).Centrality + $centB = ($centrality | Where-Object { $_.Vertex.Name -eq 'B' }).Centrality + $centC = ($centrality | Where-Object { $_.Vertex.Name -eq 'C' }).Centrality + $centB | Should -BeGreaterThan $centA + $centB | Should -BeGreaterThan $centC + } + } +}