diff --git a/Routers/Routers.Cli/Program.cs b/Routers/Routers.Cli/Program.cs
new file mode 100644
index 0000000..cc86dfe
--- /dev/null
+++ b/Routers/Routers.Cli/Program.cs
@@ -0,0 +1,62 @@
+using Routers;
+
+if (args.Length < 2)
+{
+ Console.WriteLine("""
+ Network topology optimizer
+ Usage:
+ dotnet run -- {inputFile} {outputFile} [-f|--force]
+
+ Arguments:
+ inputFile file path to read network topology from
+ outputFile file path to write optimized network topology to
+ -f or --force overwrite outputFile if it already exists
+ """);
+ return 0;
+}
+
+var inputFilePath = args[0];
+var outputFilePath = args[1];
+
+if (!File.Exists(inputFilePath))
+{
+ Console.Error.WriteLine($"error: input file '{inputFilePath}' does not exist");
+ return 1;
+}
+
+var force = args.Length >= 3 && args[2] is "-f" or "--force";
+
+if (!force && File.Exists(outputFilePath))
+{
+ Console.Write($"File '{outputFilePath}' already exists, overwrite? (y/n): ");
+ if (Console.ReadLine()?.Trim() != "y")
+ {
+ Console.WriteLine("Cancelled");
+ return 0;
+ }
+}
+
+Graph graph;
+using (var inputFile = File.OpenText(inputFilePath))
+{
+ try
+ {
+ graph = Graph.ReadGraph(inputFile);
+ }
+ catch (InvalidDataException)
+ {
+ Console.Error.WriteLine($"error: invalid input file format");
+ return 1;
+ }
+}
+
+if (!GraphOptimizer.Optimize(graph, out var optimized))
+{
+ Console.Error.WriteLine($"error: graph is disconected");
+ return 1;
+}
+
+using var outputFile = File.CreateText(outputFilePath);
+optimized.Write(outputFile);
+
+return 0;
diff --git a/Routers/Routers.Cli/Routers.Cli.csproj b/Routers/Routers.Cli/Routers.Cli.csproj
new file mode 100644
index 0000000..84a6cca
--- /dev/null
+++ b/Routers/Routers.Cli/Routers.Cli.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Routers/Routers.Tests/Routers.Tests.csproj b/Routers/Routers.Tests/Routers.Tests.csproj
new file mode 100644
index 0000000..0713f82
--- /dev/null
+++ b/Routers/Routers.Tests/Routers.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Routers/Routers.sln b/Routers/Routers.sln
new file mode 100644
index 0000000..a4f1661
--- /dev/null
+++ b/Routers/Routers.sln
@@ -0,0 +1,62 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Routers", "Routers\Routers.csproj", "{19D4C610-E237-45B9-A8D2-A52A9FCD9986}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Routers.Tests", "Routers.Tests\Routers.Tests.csproj", "{91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Routers.Cli", "Routers.Cli\Routers.Cli.csproj", "{1A5C197A-399A-4808-BC0C-C033CE664E19}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Debug|x64.Build.0 = Debug|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Debug|x86.Build.0 = Debug|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Release|Any CPU.Build.0 = Release|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Release|x64.ActiveCfg = Release|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Release|x64.Build.0 = Release|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Release|x86.ActiveCfg = Release|Any CPU
+ {19D4C610-E237-45B9-A8D2-A52A9FCD9986}.Release|x86.Build.0 = Release|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Debug|x64.Build.0 = Debug|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Debug|x86.Build.0 = Debug|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Release|x64.ActiveCfg = Release|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Release|x64.Build.0 = Release|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Release|x86.ActiveCfg = Release|Any CPU
+ {91AF46DD-D4BB-4CAF-A8CB-E06715C013FA}.Release|x86.Build.0 = Release|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Debug|x64.Build.0 = Debug|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Debug|x86.Build.0 = Debug|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Release|x64.ActiveCfg = Release|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Release|x64.Build.0 = Release|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Release|x86.ActiveCfg = Release|Any CPU
+ {1A5C197A-399A-4808-BC0C-C033CE664E19}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/Routers/Routers/Graph.cs b/Routers/Routers/Graph.cs
new file mode 100644
index 0000000..ca0c2f1
--- /dev/null
+++ b/Routers/Routers/Graph.cs
@@ -0,0 +1,160 @@
+namespace Routers;
+
+using System.Text.RegularExpressions;
+
+///
+/// Network graph.
+///
+public partial class Graph
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Nodes of the graph.
+ internal Graph(IEnumerable nodes)
+ {
+ Nodes = [.. nodes];
+ }
+
+ ///
+ /// Gets all nodes in graph.
+ ///
+ internal List Nodes { get; }
+
+ ///
+ /// Reads graph from .
+ ///
+ /// to read graph from.
+ /// Read graph.
+ public static Graph ReadGraph(TextReader reader)
+ {
+ var graph = new Graph([]);
+ var nodes = new Dictionary();
+
+ while (true)
+ {
+ var line = reader.ReadLine();
+ if (line == null)
+ {
+ break;
+ }
+
+ var nodeMatch = NodeRegex().Match(line);
+ if (!nodeMatch.Success || nodeMatch.Groups.Count != 3 ||
+ !int.TryParse(nodeMatch.Groups[1].ValueSpan, out int nodeIndex))
+ {
+ throw new InvalidDataException();
+ }
+
+ if (!nodes.TryGetValue(nodeIndex, out var node))
+ {
+ node = new([]);
+ nodes[nodeIndex] = node;
+ graph.Nodes.Add(node);
+ }
+
+ var rawConnections = nodeMatch.Groups[2].Value.Split(',');
+ var connections = new Dictionary();
+ foreach (var connection in rawConnections)
+ {
+ var connectionMatch = ConnectionRegex().Match(connection);
+ if (!connectionMatch.Success || connectionMatch.Groups.Count != 3)
+ {
+ throw new InvalidDataException();
+ }
+
+ if (!int.TryParse(connectionMatch.Groups[1].ValueSpan, out int neighborIndex) ||
+ !int.TryParse(connectionMatch.Groups[2].ValueSpan, out int bandwidth) ||
+ connections.ContainsKey(neighborIndex))
+ {
+ throw new InvalidDataException();
+ }
+
+ connections[neighborIndex] = bandwidth;
+ }
+
+ foreach (var (neighborIndex, bandwidth) in connections)
+ {
+ if (!nodes.TryGetValue(neighborIndex, out var neighbor))
+ {
+ neighbor = new([]);
+ nodes[neighborIndex] = neighbor;
+ graph.Nodes.Add(neighbor);
+ }
+
+ node.Neighbors[neighbor] = bandwidth;
+ neighbor.Neighbors[node] = bandwidth;
+ }
+ }
+
+ return graph;
+ }
+
+ ///
+ /// Writes graph to .
+ ///
+ /// to write graph to.
+ public void Write(TextWriter writer)
+ {
+ var wroteConnections = new HashSet<(Node, Node)>();
+ var nodeIndexLookup = new Dictionary();
+ int lastIndex = 0;
+
+ int GetIndex(Node node)
+ {
+ if (!nodeIndexLookup.TryGetValue(node, out int index))
+ {
+ nodeIndexLookup[node] = lastIndex;
+ index = lastIndex;
+ lastIndex++;
+ }
+
+ return index;
+ }
+
+ foreach (var node in Nodes)
+ {
+ int neighborCount = 0;
+ foreach (var (neighbor, bandwidth) in node.Neighbors)
+ {
+ if (wroteConnections.Contains((node, neighbor)) ||
+ wroteConnections.Contains((neighbor, node)))
+ {
+ continue;
+ }
+
+ wroteConnections.Add((node, neighbor));
+ wroteConnections.Add((neighbor, node));
+
+ if (neighborCount == 0)
+ {
+ writer.Write($"{GetIndex(node)}: ");
+ }
+ else if (neighborCount == 1)
+ {
+ writer.Write(", ");
+ }
+
+ writer.Write($"{GetIndex(neighbor)} ({bandwidth})");
+ neighborCount++;
+ }
+
+ if (neighborCount != 0)
+ {
+ writer.WriteLine();
+ }
+ }
+ }
+
+ [GeneratedRegex(@"^\s*(\d+)\s*:(.*)$")]
+ private static partial Regex NodeRegex();
+
+ [GeneratedRegex(@"\s*(\d+)\s*\(\s*(\d+)\s*\)\s*")]
+ private static partial Regex ConnectionRegex();
+
+ ///
+ /// Graph node.
+ ///
+ /// Neighbors of node, stored as pairs of and bandwidth.
+ internal record Node(Dictionary Neighbors);
+}
diff --git a/Routers/Routers/GraphOptimizer.cs b/Routers/Routers/GraphOptimizer.cs
new file mode 100644
index 0000000..785046a
--- /dev/null
+++ b/Routers/Routers/GraphOptimizer.cs
@@ -0,0 +1,68 @@
+namespace Routers;
+
+using System.Diagnostics.CodeAnalysis;
+
+///
+/// Utility class for optimizing network graphs.
+///
+public static class GraphOptimizer
+{
+ ///
+ /// Optimizes network graph.
+ ///
+ /// Graph to optimize.
+ /// When this method returns, contains optimized graph, if read successfully, otherwise.
+ /// if is connected, otherwise.
+ public static bool Optimize(Graph graph, [MaybeNullWhen(false)] out Graph optimized)
+ {
+ // use negative weights as workaround, so +1 is greater than any broadband
+ var broadbands = graph.Nodes.ToDictionary(x => x, x => 1);
+ var visited = new HashSet();
+
+ var queue = new PriorityQueue();
+ queue.Enqueue(new(graph.Nodes[0], graph.Nodes[0]), 0);
+
+ broadbands[graph.Nodes[0]] = 0;
+
+ var newNodes = new List();
+ var oldToNewMap = new Dictionary();
+ while (queue.TryDequeue(out Edge edge, out int broadband))
+ {
+ var node = edge.To;
+ visited.Add(node);
+
+ var newNode = new Graph.Node([]);
+ newNodes.Add(newNode);
+ oldToNewMap[node] = newNode;
+ if (edge.From != edge.To)
+ {
+ oldToNewMap[edge.From].Neighbors[edge.To] = -broadband;
+ }
+
+ foreach (var (neighbor, neighborBroadband) in node.Neighbors)
+ {
+ if (visited.Contains(neighbor))
+ {
+ continue;
+ }
+
+ if (neighborBroadband > broadbands[neighbor])
+ {
+ broadbands[neighbor] = neighborBroadband;
+ queue.Enqueue(new(node, neighbor), -neighborBroadband);
+ }
+ }
+ }
+
+ if (visited.Count != graph.Nodes.Count)
+ {
+ optimized = null;
+ return false;
+ }
+
+ optimized = new(newNodes);
+ return true;
+ }
+
+ private readonly record struct Edge(Graph.Node From, Graph.Node To);
+}
diff --git a/Routers/Routers/Routers.csproj b/Routers/Routers/Routers.csproj
new file mode 100644
index 0000000..59ee2e1
--- /dev/null
+++ b/Routers/Routers/Routers.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Library
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+