(minor) WIP continuing to fix, refine, refactor, test

This commit is contained in:
Pat Garrity 2026-05-28 23:11:40 -05:00
parent 95e9ec5e08
commit 117bff5242
Signed by: pfm
GPG key ID: 0DC16BCA24B270C4
15 changed files with 467 additions and 74 deletions

View file

@ -35,6 +35,12 @@ val Deps = new {
}
val Gs = new {
val Std = new {
private val Version: String = "0.1.3"
val Core: ModuleID = "gs" %% "gs-std-core-v0" % Version
}
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1"
}
@ -61,6 +67,7 @@ lazy val core = project
.settings(sharedSettings)
.settings(testSettings)
.settings(name := s"${gsProjectName.value}-core-v${semVerMajor.value}")
.settings(libraryDependencies ++= Seq(Deps.Gs.Std.Core))
lazy val cats = project
.in(file("modules/cats"))

View file

@ -133,10 +133,10 @@ object Adjacency:
): Adjacency =
val buffs = Vector.fill(numberOfVertices.value)(ListBuffer.empty[Vertex])
val _ = edges.foreach { edge =>
if edge.v1 >= numberOfVertices || edge.v2 >= numberOfVertices then
throw new IllegalArgumentException(
s"Edge (${edge.v1}, ${edge.v2}) is out of bounds. Maximum vertex value is ${numberOfVertices.value - 1}"
)
if edge.v1 >= numberOfVertices then
throw GraphException.VertexExceedsGraphBounds(edge.v1, numberOfVertices)
else if edge.v2 >= numberOfVertices then
throw GraphException.VertexExceedsGraphBounds(edge.v2, numberOfVertices)
else
val _ = buffs(edge.from.ordinal).addOne(edge.to)
}

View file

@ -0,0 +1,135 @@
package gs.graph.v0
import gs.graph.v0.Vertex
import scala.collection.mutable.ListBuffer
import scala.collection.mutable.Queue
/** Represents a _ Connected Component_.
*
* TODO: ADD EDGES!
*/
final class ConnectedComponent private (val value: Set[Vertex]):
/** @inheritDocs
*/
override def equals(obj: Any): Boolean =
obj match
case other: ConnectedComponent => value.equals(other.value)
case _ => false
/** @inheritDocs
*/
override def hashCode(): Int = value.hashCode()
/** @inheritDocs
*/
override def toString(): String =
val sb = StringBuilder()
sb.append("[")
sb.append(value.mkString(","))
sb.append("]")
sb.toString()
/** @return
* The number of vertices contained by this component.
*/
def size: Int = value.size
/** @return
* True if this component contains a single vertex.
*/
def isSingle: Boolean = value.size == 1
/** @return
* The set of vertices in the Connected Component.
*/
def vertices: Set[Vertex] = value
/** Assign an arbitrary vertex as the root vertex.
*/
lazy val root: Vertex = value.toList.head
end ConnectedComponent
object ConnectedComponent:
/** Instantiate a new Connected Component from the given collection of
* [[Vertex]].
*
* This constructor ensures that each [[Vertex]] only occurs once.
*
* @param value
* The input collection.
* @return
* The new Connected Component.
*/
def apply(value: Vertex*): ConnectedComponent =
if value.isEmpty then
throw new IllegalArgumentException(
"Empty connected components are not allowed."
)
else new ConnectedComponent(value.toSet)
def of(value: Int*): ConnectedComponent =
if value.isEmpty then
throw new IllegalArgumentException(
"Empty connected components are not allowed."
)
else new ConnectedComponent(value.map(Vertex.apply).toSet)
/** Instantiate a new Connected Component that contains a single [[Vertex]].
*
* @param value
* The input vertex.
* @return
* The new Connected Component.
*/
def single(value: Vertex): ConnectedComponent =
new ConnectedComponent(Set(value))
given CanEqual[ConnectedComponent, ConnectedComponent] = CanEqual.derived
def findAll(g: Graph): List[ConnectedComponent] =
if g.isEmpty then Nil
else
val tracker = Tracker.initialize(g)
val ccs = ListBuffer.empty[ConnectedComponent]
(0 until g.numberOfVertices.value).foreach { ordinal =>
val v = Vertex(ordinal)
if !tracker.isVisited(v) then ccs.addOne(bfsFromVertex(g, v, tracker))
else ()
}
ccs.toList
private def bfsFromVertex(
g: Graph,
v: Vertex,
t: Tracker
): ConnectedComponent =
val q = Queue.empty[Vertex]
val b = ListBuffer.empty[Vertex]
q.enqueue(v)
while !q.isEmpty
do
val current = q.dequeue()
t.visit(current)
b.addOne(current)
g.neighbors(current).foreach { neighbor =>
if !t.isVisited(neighbor) then q.enqueue(neighbor)
else ()
}
new ConnectedComponent(b.toSet)
private class Tracker(val data: Array[Boolean]):
def visit(v: Vertex): Unit = data(v.ordinal) = true
def isVisited(v: Vertex): Boolean = data(v.ordinal)
private object Tracker:
def initialize(g: Graph): Tracker =
new Tracker(Array.fill(g.numberOfVertices.value)(false))
end ConnectedComponent

View file

@ -1,5 +1,7 @@
package gs.graph.v0
import gs.graph.v0.directed.Digraph
/** Graph representation based on an adjacency list.
*
* See:
@ -32,6 +34,11 @@ trait Graph:
*/
def isEmpty: Boolean = numberOfVertices == Size.Zero
/** @return
* True if this graph has one vertex, false otherwise.
*/
def isSingle: Boolean = numberOfVertices == Size.One
/** @return
* The roots that should be used for traversal operations.
*/
@ -95,4 +102,22 @@ object Graph:
adj
)
/** Construct a new [[Digraph]] from the given [[Edge]].
*
* @param numberOfVertices
* The number of vertices in this [[Graph]].
* @param edges
* The [[Edge]] in this graph.
* @return
* The new [[Digraph]].
*/
def directed(
numberOfVertices: Size,
edges: (Int, Int)*
): Digraph =
Digraph.fromEdges(
numberOfVertices,
edges.map(pair => Edge(pair._1, pair._2))
)
end Graph

View file

@ -19,22 +19,20 @@ class UndirectedGraph(
*/
override def selectRoots(): Vector[Vertex] = roots
/** The roots of an undirected graph are identified by arbitrarily selecting
* the first listed vertex from each strongly connected component in the
* graph.
/** The lazily calculated roots of this graph.
*
* In an undirected graph, the roots are calculated by selecting an arbitrary
* vertex from each connected component in the graph.
*/
lazy val roots: Vector[Vertex] =
calculateRoots()
private def calculateRoots(): Vector[Vertex] =
if numberOfVertices == Size.Zero then Vector.empty
else oneRootFromEachSCC(calculateSCCs()).toVector
else oneFromEachConnectedComponent().toVector
private def calculateSCCs(): List[SCC] =
SCC.findAll(this)
private def oneRootFromEachSCC(sccs: List[SCC]): List[Vertex] =
sccs.map(_.root)
private def oneFromEachConnectedComponent(): List[Vertex] =
ConnectedComponent.findAll(this).map(_.root)
end UndirectedGraph

View file

@ -130,6 +130,24 @@ object Vertex:
else
throw new IllegalArgumentException("Vertex values must be 0 or greater.")
/** Instantiate an immutable sequence of vertices.
*
* Throws an `IllegalArgumentException` if any input value is less than 0.
*
* @param values
* The integer values to validate.
* @return
* The new vertices.
*/
def of(values: Int*): Vector[Vertex] =
values.map(apply).toVector
def list(values: Int*): List[Vertex] =
values.map(apply).toList
def set(values: Int*): Set[Vertex] =
values.map(apply).toSet
given CanEqual[Vertex, Vertex] = CanEqual.derived
given Ordering[Vertex] with

View file

@ -18,6 +18,16 @@ class Dag protected (
object Dag:
/** The empty DAG.
*/
final val Empty: Dag =
new Dag(Size.Zero, Adjacency.Empty, Vector.empty)
/** DAG with a single vertex.
*/
final val Single: Dag =
new Dag(Size.One, Adjacency.Single, Vector(Vertex.Zero))
given CanEqual[Dag, Dag] = CanEqual.derived
given CanEqual[Dag, Digraph] = CanEqual.derived
given CanEqual[Digraph, Dag] = CanEqual.derived

View file

@ -36,9 +36,12 @@ class Digraph(
case other: Digraph =>
numberOfVertices == other.numberOfVertices
&& adjacency == other.adjacency
&& roots.sameElements(other.roots)
case _ => false
/** `True` if this directed graph has a cycle, `false` otherwise.
*/
lazy val hasCycle: Boolean = Digraph.hasCycle(this)
end Digraph
object Digraph:
@ -121,9 +124,10 @@ object Digraph:
*/
def hasCycle(digraph: Digraph): Boolean =
digraph match
case _ if digraph.isEmpty => false
case _: Dag => false
case _ => new CycleDetector(digraph).hasCycle
case _ if digraph.isEmpty => false
case _ if digraph.isSingle => false
case _: Dag => false
case _ => new CycleDetector(digraph).hasCycle
// Internal class for finding cycles.
// This implementation comes (roughly) from Algorithms (Fourth Edition) by

View file

@ -1,6 +1,5 @@
package gs.graph.v0
package gs.graph.v0.directed
import gs.graph.v0.Graph
import gs.graph.v0.Vertex
import scala.collection.mutable.ListBuffer
import scala.collection.mutable.Stack
@ -8,15 +7,20 @@ import scala.collection.mutable.Stack
/** Represents a _Strongly Connected Component_ (SCC).
*
* Each SCC is guaranteed to be non-empty.
*
* This class is used with [[Digraph]]. See [[gs.graph.v0.ConnectedComponent]]
* for general connected components.
*
* TODO: ADD EDGES
*/
final class SCC private (val value: Set[Vertex]):
final class StronglyConnectedComponent private (val value: Set[Vertex]):
/** @inheritDocs
*/
override def equals(obj: Any): Boolean =
obj match
case other: SCC => value.equals(other.value)
case _ => false
case other: StronglyConnectedComponent => value.equals(other.value)
case _ => false
/** @inheritDocs
*/
@ -50,9 +54,9 @@ final class SCC private (val value: Set[Vertex]):
*/
lazy val root: Vertex = value.toList.head
end SCC
end StronglyConnectedComponent
object SCC:
object StronglyConnectedComponent:
/** Instantiate a new SCC from the given collection of [[Vertex]].
*
@ -63,19 +67,19 @@ object SCC:
* @return
* The new SCC.
*/
def apply(value: Vertex*): SCC =
def apply(value: Vertex*): StronglyConnectedComponent =
if value.isEmpty then
throw new IllegalArgumentException(
"Empty strongly connected components (SCC) are not allowed."
)
else new SCC(value.toSet)
else new StronglyConnectedComponent(value.toSet)
def of(value: Int*): SCC =
def of(value: Int*): StronglyConnectedComponent =
if value.isEmpty then
throw new IllegalArgumentException(
"Empty strongly connected components (SCC) are not allowed."
)
else new SCC(value.map(Vertex.apply).toSet)
else new StronglyConnectedComponent(value.map(Vertex.apply).toSet)
/** Instantiate a new SCC that contains a single [[Vertex]].
*
@ -84,9 +88,11 @@ object SCC:
* @return
* The new SCC.
*/
def single(value: Vertex): SCC = new SCC(Set(value))
def single(value: Vertex): StronglyConnectedComponent =
new StronglyConnectedComponent(Set(value))
given CanEqual[SCC, SCC] = CanEqual.derived
given CanEqual[StronglyConnectedComponent, StronglyConnectedComponent] =
CanEqual.derived
/** Alias for `findAll`.
*
@ -101,7 +107,7 @@ object SCC:
* @return
* The complete list of [[SCC]] for the input graph.
*/
def tarjan(g: Graph): List[SCC] = findAll(g)
def tarjan(g: Digraph): List[StronglyConnectedComponent] = findAll(g)
/** Implementation of Tarjan's Algorithm for finding all strongly connected
* components for some graph.
@ -114,7 +120,7 @@ object SCC:
* @return
* The complete list of [[SCC]] for the input graph.
*/
def findAll(g: Graph): List[SCC] =
def findAll(g: Digraph): List[StronglyConnectedComponent] =
val V = g.numberOfVertices.value
// Vertex numbers.
@ -139,7 +145,7 @@ object SCC:
val counter = new Counter(0)
// List of output SCCs.
val sccs = ListBuffer.empty[SCC]
val sccs = ListBuffer.empty[StronglyConnectedComponent]
(0 until V).foreach { v =>
if !visited(v) then
@ -151,7 +157,7 @@ object SCC:
sccs.toList
private def tarjanDfs(
g: Graph,
g: Digraph,
v: Int,
num: Array[Int],
lowest: Array[Int],
@ -159,7 +165,7 @@ object SCC:
processed: Array[Boolean],
s: Stack[Vertex],
counter: Counter,
output: ListBuffer[SCC]
output: ListBuffer[StronglyConnectedComponent]
): Unit =
// Convenience integer representation for array access.
val V = Vertex(v)
@ -195,7 +201,8 @@ object SCC:
sccVertex = s.pop()
scc.addOne(sccVertex)
if !scc.isEmpty then output.addOne(new SCC(scc.toSet))
if !scc.isEmpty then
output.addOne(new StronglyConnectedComponent(scc.toSet))
else println("ERROR")
else ()
@ -213,4 +220,4 @@ object SCC:
end Counter
end SCC
end StronglyConnectedComponent

View file

@ -1,10 +1,26 @@
package gs.graph.v0.syntax
import gs.graph.v0.Edge
import gs.graph.v0.Graph
import gs.graph.v0.GraphTraversal
import gs.graph.v0.Vertex
import gs.graph.v0.data.AnyGraphWithData
/** Construction helper for [[Vertex]] values.
*
* @param value
* The value of the vertex.
* @return
* The new [[Vertex]] instance.
*/
def V(value: Int): Vertex = Vertex(value)
extension (value: (Int, Int))
/** @return
* The [[Edge]] represented by this tuple.
*/
def edge: Edge = Edge(value._1, value._2)
extension (graph: Graph)
/** Visit vertex node in this graph, using Depth-First Search (DFS) as the

View file

@ -1,12 +1,12 @@
package gs.graph.v0
import gs.graph.v0.syntax.V
import scala.util.Failure
import scala.util.Try
class AdjacencyTests extends munit.FunSuite:
private val size = Size(7)
private val vs = (0 until size.value).map(Vertex(_)).toArray
private val edges = Edge.list(
0 -> 1,
@ -32,13 +32,13 @@ class AdjacencyTests extends munit.FunSuite:
}
test("should provide incoming connections") {
assertEquals(adj.incoming(vs(0)), Vector.empty)
assertEquals(adj.incoming(vs(1)), Vector(vs(0)))
assertEquals(adj.incoming(vs(2)), Vector(vs(0)))
assertEquals(adj.incoming(vs(3)), Vector(vs(0)))
assertEquals(adj.incoming(vs(4)), Vector(vs(1), vs(2), vs(3)))
assertEquals(adj.incoming(vs(5)), Vector(vs(3)))
assertEquals(adj.incoming(vs(6)), Vector(vs(4)))
assertEquals(adj.incoming(V(0)), Vector.empty)
assertEquals(adj.incoming(V(1)), Vertex.of(0))
assertEquals(adj.incoming(V(2)), Vertex.of(0))
assertEquals(adj.incoming(V(3)), Vertex.of(0))
assertEquals(adj.incoming(V(4)), Vertex.of(1, 2, 3))
assertEquals(adj.incoming(V(5)), Vertex.of(3))
assertEquals(adj.incoming(V(6)), Vertex.of(4))
}
test("should show no incoming connections for a vertex not in the graph") {
@ -66,19 +66,32 @@ class AdjacencyTests extends munit.FunSuite:
}
test(
"should guard against out of bound values when building from edges with an explicit bound"
"should guard against out of bound values when building from edges with an explicit bound (first vertex)"
) {
val n = Size(2)
val es = Edge.list(3 -> 0)
Try(Adjacency.fromEdges(n, es)) match
case Failure(ex: GraphException.VertexExceedsGraphBounds) =>
assertEquals(ex.vertex, Vertex(3))
assertEquals(ex.bound, n)
case _ =>
fail(
"Expected adjacency construction to fail with a GraphException."
)
}
test(
"should guard against out of bound values when building from edges with an explicit bound (second vertex)"
) {
val n = Size(2)
val es = Edge.list(0 -> 3)
Try(Adjacency.fromEdges(n, es)) match
case Failure(ex: IllegalArgumentException) =>
assertEquals(
ex.getMessage(),
"Edge (0, 3) is out of bounds. Maximum vertex value is 1"
)
case Failure(ex: GraphException.VertexExceedsGraphBounds) =>
assertEquals(ex.vertex, Vertex(3))
assertEquals(ex.bound, n)
case _ =>
fail(
"Expected adjacency construction to fail with an IllegalArgumentException."
"Expected adjacency construction to fail with a GraphException."
)
}

View file

@ -12,8 +12,48 @@ class UndirectedGraphTests extends FunSuite:
assertEquals(g.selectRoots(), Vector.empty)
}
test(
"should calculate roots based on an arbitrary vertex from each connected component"
) {
// TODO: implement - current logic is wrong.
test("should construct a graph based on # of edges and edge list") {
val N = Size(3)
val g = Graph.undirected(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2,
0 -> 2
)
assertEquals(g.numberOfVertices, N)
assertEquals(g.neighbors(Vertex(0)), Vector(Vertex(1), Vertex(2)))
assertEquals(g.neighbors(Vertex(4)), Vector.empty)
assertEquals(g.selectRoots(), Vector(Vertex.Zero))
}
test("should construct a graph based on edge list") {
val N = Size(3)
val g = Graph.undirected(
edges = 0 -> 1,
1 -> 2,
0 -> 2
)
assertEquals(g.numberOfVertices, N)
assertEquals(g.neighbors(Vertex(0)), Vector(Vertex(1), Vertex(2)))
assertEquals(g.neighbors(Vertex(4)), Vector.empty)
assertEquals(g.selectRoots(), Vector(Vertex.Zero))
}
test("should calculate roots for each connected component") {
val N = Size(10)
val g = Graph.undirected(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2,
2 -> 3,
4 -> 5,
5 -> 6,
4 -> 6,
7 -> 8
)
val expectedRoots = Vertex.of(0, 4, 7, 9)
assertEquals(g.roots, expectedRoots)
}

View file

@ -20,6 +20,15 @@ class DagTests extends FunSuite:
fail("Expected a graph with one vertex to be validated as a DAG.")
}
test("should not have cycles by definition") {
Dag.validate(Digraph.Single) match
case Right(dag) =>
assertEquals(Digraph.hasCycle(dag), false)
assertEquals(dag.hasCycle, false)
case _ =>
fail("Expected a graph with one vertex to be validated as a DAG.")
}
test("should validate a single-root graph") {
val size = Size(8)
val digraph: Digraph = Digraph.fromAdjacency(
@ -40,7 +49,10 @@ class DagTests extends FunSuite:
)
Dag.validate(digraph) match
case Right(dag) => assertEquals(dag, digraph)
case Right(dag) =>
assertEquals(dag, digraph)
assertEquals(Digraph.hasCycle(dag), false)
assertEquals(dag.hasCycle, false)
case _ => fail("Expected a single-root graph to be validated as a DAG.")
}
@ -65,3 +77,8 @@ class DagTests extends FunSuite:
)
case _ => ()
}
test("should provide empty and single graphs") {
assertEquals(Dag.Empty, Digraph.Empty)
assertEquals(Dag.Single, Digraph.Single)
}

View file

@ -0,0 +1,92 @@
package gs.graph.v0.directed
import gs.graph.v0.Graph
import gs.graph.v0.Size
import gs.graph.v0.Vertex
import munit.*
class DigraphTests extends FunSuite:
test("should provide an empty graph") {
val dg = Digraph.Empty
assertEquals(dg.numberOfVertices, Size.Zero)
assertEquals(dg.isSingle, false)
assertEquals(dg.roots, Vector.empty)
assertEquals(dg.isEmpty, true)
assertEquals(dg.neighbors(Vertex.Zero), Vector.empty)
assertEquals(dg.hasCycle, false)
}
test("should provide a single-node graph") {
val dg = Digraph.Single
assertEquals(dg.numberOfVertices, Size.One)
assertEquals(dg.isSingle, true)
assertEquals(dg.roots, Vertex.of(0))
assertEquals(dg.isEmpty, false)
assertEquals(dg.neighbors(Vertex.Zero), Vector.empty)
assertEquals(dg.hasCycle, false)
}
test("should not equal non-digraph types") {
val dg = Digraph.Empty
assertEquals(dg.equals(null), false)
assertEquals(dg.equals("foo"), false)
assertEquals(dg.equals(1), false)
}
test("should not equal a digraph of a different size") {
assertNotEquals(Digraph.Empty, Digraph.Single)
}
test("should not equal a digraph with different edges") {
val N = Size(3)
val d1 = Graph.directed(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2
)
val d2 = Graph.directed(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2,
0 -> 2
)
assertNotEquals(d1, d2)
}
test(
"should detect cycles where a single component has a cycle back to the root"
) {
val N = Size(3)
val dg = Graph.directed(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2,
2 -> 0
)
assertEquals(dg.hasCycle, true)
}
test("should detect cycles where one component does not have a cycle") {
val N = Size(6)
val dg = Graph.directed(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2,
3 -> 4,
4 -> 5,
5 -> 3
)
assertEquals(dg.hasCycle, true)
}
test("should detect cycles within a subset of a component") {
val N = Size(3)
val dg = Graph.directed(
numberOfVertices = N,
edges = 0 -> 1,
1 -> 2,
2 -> 1
)
assertEquals(dg.hasCycle, true)
}

View file

@ -1,6 +1,10 @@
package gs.graph.v0
package gs.graph.v0.directed
import gs.graph.v0.directed.Digraph
import gs.graph.v0.Adjacency
import gs.graph.v0.Edge
import gs.graph.v0.Graph
import gs.graph.v0.Size
import gs.graph.v0.Vertex
import munit.*
class TarjansTests extends FunSuite:
@ -9,9 +13,9 @@ class TarjansTests extends FunSuite:
val v0 = Vertex.Zero
val v1 = Vertex(1)
val v2 = Vertex(2)
val s1 = SCC(v0)
val s2 = SCC(v0, v1, v2)
val s3 = SCC(v0)
val s1 = StronglyConnectedComponent(v0)
val s2 = StronglyConnectedComponent(v0, v1, v2)
val s3 = StronglyConnectedComponent(v0)
assertEquals(s1, s3)
assertNotEquals(s1, s2)
@ -23,9 +27,9 @@ class TarjansTests extends FunSuite:
}
test("should produce an empty list of SCCs for an empty graph") {
val g = UndirectedGraph.Empty
val sccs = SCC.tarjan(g)
val alt = SCC.findAll(g)
val g = Digraph.Empty
val sccs = StronglyConnectedComponent.tarjan(g)
val alt = StronglyConnectedComponent.findAll(g)
assertEquals(sccs, Nil)
assertEquals(alt, Nil)
}
@ -33,9 +37,9 @@ class TarjansTests extends FunSuite:
test(
"should produce a list of once SCC with one vertex for a graph with one vertex"
) {
val g = Graph.undirected(Size.One)
val sccs = SCC.findAll(g)
val expected = SCC.single(Vertex.Zero)
val g = Graph.directed(Size.One)
val sccs = StronglyConnectedComponent.findAll(g)
val expected = StronglyConnectedComponent.single(Vertex.Zero)
assertEquals(sccs, List(expected))
}
@ -55,9 +59,16 @@ class TarjansTests extends FunSuite:
)
)
val sccs = SCC.findAll(g)
val sccs = StronglyConnectedComponent.findAll(g)
assertEquals(g.roots, Vector(Vertex.Zero))
assertEquals(sccs.toSet, Set(SCC.of(0), SCC.of(1), SCC.of(2)))
assertEquals(
sccs.toSet,
Set(
StronglyConnectedComponent.of(0),
StronglyConnectedComponent.of(1),
StronglyConnectedComponent.of(2)
)
)
}
test("should handle a graph with multiple SCCs") {
@ -87,14 +98,14 @@ class TarjansTests extends FunSuite:
)
)
val sccs = SCC.findAll(g)
val sccs = StronglyConnectedComponent.findAll(g)
assertEquals(g.roots, Vector.empty)
assertEquals(
sccs.toSet,
Set(
SCC.of(0, 1, 2),
SCC.of(3, 4, 5),
SCC.of(6, 7, 8)
StronglyConnectedComponent.of(0, 1, 2),
StronglyConnectedComponent.of(3, 4, 5),
StronglyConnectedComponent.of(6, 7, 8)
)
)
}