diff --git a/build.sbt b/build.sbt index 56c7f9b..fe4cf38 100644 --- a/build.sbt +++ b/build.sbt @@ -13,6 +13,10 @@ ThisBuild / licenses := Seq( "MIT" -> url("https://git.garrity.co/garrity-software/gs-graph/LICENSE") ) +val noPublishSettings = Seq( + publish := {} +) + val sharedSettings = Seq( scalaVersion := scala3, version := semVerSelected.value, @@ -21,6 +25,11 @@ val sharedSettings = Seq( ) val Deps = new { + + val Cats = new { + val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" + } + val Gs = new { val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" } @@ -37,10 +46,21 @@ lazy val testSettings = Seq( lazy val `gs-graph` = project .in(file(".")) + .aggregate(core, cats) .settings(sharedSettings) .settings(testSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") - .settings( - libraryDependencies ++= Seq( - ) - ) + +lazy val core = project + .in(file("modules/core")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-core-v${semVerMajor.value}") + +lazy val cats = project + .in(file("modules/cats")) + .dependsOn(core) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-cats-v${semVerMajor.value}") + .settings(libraryDependencies ++= Seq(Deps.Cats.Core)) diff --git a/modules/cats/src/main/scala/gs/graph/v0/cats/GraphTraversalCats.scala b/modules/cats/src/main/scala/gs/graph/v0/cats/GraphTraversalCats.scala new file mode 100644 index 0000000..3368cfb --- /dev/null +++ b/modules/cats/src/main/scala/gs/graph/v0/cats/GraphTraversalCats.scala @@ -0,0 +1,188 @@ +package gs.graph.v0.cats + +import cats.Monad +import cats.syntax.all.* +import gs.graph.v0.Graph +import gs.graph.v0.Vertex +import gs.graph.v0.data.DataDigraph +import scala.collection.mutable.ListBuffer +import scala.collection.mutable.Stack + +/** Graph traversal implementations (such as DFS and BFS) that operate in a + * monadic context, as defined by the `cats` library. + */ +object GraphTraversalCats: + + /** Depth-first search (DFS) where the result of visiting each node is a + * side-effecting computation. + * + * This implementation selects the first [[Vertex]] as an arbitrary starting + * point. + * + * @param graph + * The [[Graph]] to traverse. + * @param visit + * The visitor function. + * @return + * The computation that describes the traversal. + */ + def dfsUnit[F[_]: Monad]( + graph: Graph, + visit: Vertex => F[Unit] + ): F[Unit] = + val output = ListBuffer.empty[F[Unit]] + val s = Stack.empty[Vertex] + val discovered = Array.fill(graph.numberOfVertices.value)(false) + + val root = Vertex.Zero + s.push(root) + + while !s.isEmpty + do + val v = s.pop() + if !discovered(v.ordinal) then + output.addOne(visit(v)) + discovered(v.ordinal) = true + graph.neighbors(v).foreach(w => s.push(w)) + else () + + output.foldLeft(Monad[F].unit) { + ( + acc, + f + ) => acc.flatMap(_ => f) + } + + /** Depth-first search (DFS) where the result of visiting each node is some + * computed output. + * + * This implementation selects the first [[Vertex]] as an arbitrary starting + * point. + * + * @param graph + * The [[Graph]] to traverse. + * @param visit + * The visitor function. + * @return + * The computation that describes the traversal, producing a list of + * outputs. + */ + def dfs[F[_]: Monad, Output]( + graph: Graph, + visit: Vertex => F[Output] + ): F[List[Output]] = + val output = ListBuffer.empty[F[Output]] + val s = Stack.empty[Vertex] + val discovered = Array.fill(graph.numberOfVertices.value)(false) + + val root = Vertex.Zero + s.push(root) + + while !s.isEmpty + do + val v = s.pop() + if !discovered(v.ordinal) then + val _ = output.addOne(visit(v)) + discovered(v.ordinal) = true + graph.neighbors(v).foreach(w => s.push(w)) + else () + + output.foldLeft(Monad[F].pure(List.empty[Output])) { + ( + acc, + result + ) => + for + current <- acc + out <- result + yield out :: current + } + + /** Depth-first search (DFS) where the result of visiting each node is a + * side-effecting computation. + * + * This implementation performs DFS for _each root_ in the input + * [[DataDigraph]]. + * + * @param graph + * The [[DataDigraph]] to traverse. + * @param visit + * The visitor function. + * @return + * The computation that describes the traversal. + */ + def dfsUnit[F[_]: Monad, A]( + digraph: DataDigraph[A], + visit: (Vertex, A) => F[Unit] + ): F[Unit] = + val output = ListBuffer.empty[F[Unit]] + val s = Stack.empty[Vertex] + val discovered = Array.fill(digraph.numberOfVertices.value)(false) + + digraph.roots.foreach { root => + s.push(root) + + while !s.isEmpty + do + val v = s.pop() + if !discovered(v.ordinal) then + output.addOne(visit(v, digraph.data(v.ordinal))) + discovered(v.ordinal) = true + digraph.neighbors(v).foreach(w => s.push(w)) + else () + } + + output.foldLeft(Monad[F].unit) { + ( + acc, + f + ) => acc.flatMap(_ => f) + } + + /** Depth-first search (DFS) where the result of visiting each node is some + * computed output. + * + * This implementation performs DFS for _each root_ in the input + * [[DataDigraph]]. + * + * @param graph + * The [[DataDigraph]] to traverse. + * @param visit + * The visitor function. + * @return + * The computation that describes the traversal, producing a list of + * outputs. + */ + def dfs[F[_]: Monad, A, Output]( + digraph: DataDigraph[A], + visit: (Vertex, A) => F[Output] + ): F[List[Output]] = + val output = ListBuffer.empty[F[Output]] + val s = Stack.empty[Vertex] + val discovered = Array.fill(digraph.numberOfVertices.value)(false) + + digraph.roots.foreach { root => + s.push(root) + + while !s.isEmpty + do + val v = s.pop() + if !discovered(v.ordinal) then + val _ = output.addOne(visit(v, digraph.data(v.ordinal))) + discovered(v.ordinal) = true + digraph.neighbors(v).foreach(w => s.push(w)) + else () + } + + output.foldLeft(Monad[F].pure(List.empty[Output])) { + ( + acc, + result + ) => + for + current <- acc + out <- result + yield out :: current + } + +end GraphTraversalCats diff --git a/modules/cats/src/main/scala/gs/graph/v0/cats/syntax/extensions.scala b/modules/cats/src/main/scala/gs/graph/v0/cats/syntax/extensions.scala new file mode 100644 index 0000000..58eed06 --- /dev/null +++ b/modules/cats/src/main/scala/gs/graph/v0/cats/syntax/extensions.scala @@ -0,0 +1,24 @@ +package graph.gs.v0.cats.syntax + +import cats.Monad +import gs.graph.v0.Graph +import gs.graph.v0.Vertex +import gs.graph.v0.cats.GraphTraversalCats +import gs.graph.v0.data.DataDigraph + +extension (graph: Graph) + + def dfsUnit[F[_]: Monad](visit: Vertex => F[Unit]): F[Unit] = + GraphTraversalCats.dfsUnit[F](graph, visit) + + def dfs[F[_]: Monad, Output](visit: Vertex => F[Output]): F[List[Output]] = + GraphTraversalCats.dfs[F, Output](graph, visit) + +extension [A](graph: DataDigraph[A]) + + def dfsUnit[F[_]: Monad](visit: (Vertex, A) => F[Unit]): F[Unit] = + GraphTraversalCats.dfsUnit[F, A](graph, visit) + + def dfs[F[_]: Monad, Output](visit: (Vertex, A) => F[Output]) + : F[List[Output]] = + GraphTraversalCats.dfs[F, A, Output](graph, visit) diff --git a/src/main/scala/gs/graph/v0/Adjacency.scala b/modules/core/src/main/scala/gs/graph/v0/Adjacency.scala similarity index 100% rename from src/main/scala/gs/graph/v0/Adjacency.scala rename to modules/core/src/main/scala/gs/graph/v0/Adjacency.scala diff --git a/src/main/scala/gs/graph/v0/Edge.scala b/modules/core/src/main/scala/gs/graph/v0/Edge.scala similarity index 100% rename from src/main/scala/gs/graph/v0/Edge.scala rename to modules/core/src/main/scala/gs/graph/v0/Edge.scala diff --git a/src/main/scala/gs/graph/v0/Graph.scala b/modules/core/src/main/scala/gs/graph/v0/Graph.scala similarity index 100% rename from src/main/scala/gs/graph/v0/Graph.scala rename to modules/core/src/main/scala/gs/graph/v0/Graph.scala diff --git a/src/main/scala/gs/graph/v0/GraphDisposition.scala b/modules/core/src/main/scala/gs/graph/v0/GraphDisposition.scala similarity index 100% rename from src/main/scala/gs/graph/v0/GraphDisposition.scala rename to modules/core/src/main/scala/gs/graph/v0/GraphDisposition.scala diff --git a/src/main/scala/gs/graph/v0/GraphError.scala b/modules/core/src/main/scala/gs/graph/v0/GraphError.scala similarity index 100% rename from src/main/scala/gs/graph/v0/GraphError.scala rename to modules/core/src/main/scala/gs/graph/v0/GraphError.scala diff --git a/src/main/scala/gs/graph/v0/GraphException.scala b/modules/core/src/main/scala/gs/graph/v0/GraphException.scala similarity index 100% rename from src/main/scala/gs/graph/v0/GraphException.scala rename to modules/core/src/main/scala/gs/graph/v0/GraphException.scala diff --git a/src/main/scala/gs/graph/v0/DFS.scala b/modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala similarity index 97% rename from src/main/scala/gs/graph/v0/DFS.scala rename to modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala index 3afb6f8..9a8c0d4 100644 --- a/src/main/scala/gs/graph/v0/DFS.scala +++ b/modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala @@ -4,9 +4,9 @@ import gs.graph.v0.data.DataDigraph import scala.collection.mutable.ListBuffer import scala.collection.mutable.Stack -/** Depth-First Search (DFS) +/** Graph traversal algorithms including DFS and BFS. */ -object DFS: +object GraphTraversal: /** Depth-first search that executes a side-effecting function on each * [[Vertex]]. This function will operate on _any_ [[Graph]]. @@ -105,6 +105,8 @@ object DFS: else () } + () + /** Depth-first search that executes a function on each [[Vertex]] to produce * some output, accepting the data stored for that [[Vertex]] as input. * @@ -141,4 +143,4 @@ object DFS: output.toList -end DFS +end GraphTraversal diff --git a/src/main/scala/gs/graph/v0/Size.scala b/modules/core/src/main/scala/gs/graph/v0/Size.scala similarity index 100% rename from src/main/scala/gs/graph/v0/Size.scala rename to modules/core/src/main/scala/gs/graph/v0/Size.scala diff --git a/src/main/scala/gs/graph/v0/Vertex.scala b/modules/core/src/main/scala/gs/graph/v0/Vertex.scala similarity index 100% rename from src/main/scala/gs/graph/v0/Vertex.scala rename to modules/core/src/main/scala/gs/graph/v0/Vertex.scala diff --git a/modules/core/src/main/scala/gs/graph/v0/data/DataDag.scala b/modules/core/src/main/scala/gs/graph/v0/data/DataDag.scala new file mode 100644 index 0000000..70f8128 --- /dev/null +++ b/modules/core/src/main/scala/gs/graph/v0/data/DataDag.scala @@ -0,0 +1,83 @@ +package gs.graph.v0.data + +import gs.graph.v0.Adjacency +import gs.graph.v0.GraphException +import gs.graph.v0.Size +import gs.graph.v0.Vertex +import gs.graph.v0.directed.Dag + +/** Specialization of [[Dag]] that associates _data_ with _each [[Vertex]]_. + */ +final class DataDag[A] private ( + val data: Vector[A], + n: Size, + a: Adjacency, + r: Vector[Vertex] +) extends Dag(n, a, r): + + /** Retrieve the data associated with some [[Vertex]]. + * + * This implementation throws an exception if the input [[Vertex]] is out of + * range for this graph. + * + * @param vertex + * The input [[Vertex]]. + * @return + * The data associated with the input [[Vertex]]. + */ + def dataAtUnsafe(vertex: Vertex): A = + if vertex < n then data.apply(vertex.ordinal) + else throw GraphException.VertexExceedsGraphBounds(vertex, n) + + /** Retrieve the data associated with some [[Vertex]]. + * + * @param vertex + * The input [[Vertex]]. + * @return + * The data associated with the input [[Vertex]], or `None` if the + * [[Vertex]] is out of range for this graph. + */ + def dataAt(vertex: Vertex): Option[A] = + if vertex < n then Some(data.apply(vertex.ordinal)) else None + +object DataDag: + + /** @return + * An empty [[Dag]] with no data. + */ + def empty[A]: DataDag[A] = + new DataDag[A]( + Vector.empty, + Size.Zero, + Adjacency.Empty, + Vector.empty + ) + + /** Instantiate a new [[DataDag]] given an input [[Dag]] and some data. + * + * Throws an exception if the input data vector does not exactly match the + * number of nodes in the graph -- each node MUST have a data element. + * + * @param dag + * The input [[Dag]]. + * @param data + * The data to associate with the [[Dag]]. + * @return + * The new [[DataDag]] instance. + */ + def fromDag[A]( + dag: Dag, + data: Vector[A] + ): DataDag[A] = + if dag.numberOfVertices != Size.fromVector(data) then + throw GraphException.DataGraphMismatch( + dag.numberOfVertices, + data.length + ) + else + new DataDag( + data, + dag.numberOfVertices, + dag.adjacency, + dag.roots + ) diff --git a/src/main/scala/gs/graph/v0/data/DataDigraph.scala b/modules/core/src/main/scala/gs/graph/v0/data/DataDigraph.scala similarity index 72% rename from src/main/scala/gs/graph/v0/data/DataDigraph.scala rename to modules/core/src/main/scala/gs/graph/v0/data/DataDigraph.scala index 986007c..a138d4d 100644 --- a/src/main/scala/gs/graph/v0/data/DataDigraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/data/DataDigraph.scala @@ -6,7 +6,9 @@ import gs.graph.v0.Size import gs.graph.v0.Vertex import gs.graph.v0.directed.Digraph -final class DataDigraph[A]( +/** Specialization of [[Digraph]] that associates _data_ with _each [[Vertex]]_. + */ +final class DataDigraph[A] private ( val data: Vector[A], n: Size, a: Adjacency, @@ -40,6 +42,9 @@ final class DataDigraph[A]( object DataDigraph: + /** @return + * An empty [[Digraph]] with no data. + */ def empty[A]: DataDigraph[A] = new DataDigraph[A]( Vector.empty, @@ -48,6 +53,19 @@ object DataDigraph: Vector.empty ) + /** Instantiate a new [[DataDigraph]] given an input [[Digraph]] and some + * data. + * + * Throws an exception if the input data vector does not exactly match the + * number of nodes in the graph -- each node MUST have a data element. + * + * @param digraph + * The input [[Digraph]]. + * @param data + * The data to associate with the [[Digraph]]. + * @return + * The new [[DataDigraph]] instance. + */ def fromDigraph[A]( digraph: Digraph, data: Vector[A] diff --git a/src/main/scala/gs/graph/v0/dag/Dag.scala b/modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala similarity index 91% rename from src/main/scala/gs/graph/v0/dag/Dag.scala rename to modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala index 74fcad7..3a25bf0 100644 --- a/src/main/scala/gs/graph/v0/dag/Dag.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/Dag.scala @@ -1,17 +1,16 @@ -package gs.graph.v0.dag +package gs.graph.v0.directed import gs.graph.v0.Adjacency import gs.graph.v0.GraphError import gs.graph.v0.Size import gs.graph.v0.Vertex -import gs.graph.v0.directed.Digraph /** Directed Acyclic Graph (DAG): Represents a [[Digraph]] with no cycles. * * Instances of this class guarantee that property - it can only be * instantiated by validating some [[Digraph]]. */ -class Dag private ( +class Dag protected ( n: Size, a: Adjacency, r: Vector[Vertex] diff --git a/src/main/scala/gs/graph/v0/directed/Digraph.scala b/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala similarity index 99% rename from src/main/scala/gs/graph/v0/directed/Digraph.scala rename to modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala index ad7ceba..318e94d 100644 --- a/src/main/scala/gs/graph/v0/directed/Digraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala @@ -6,7 +6,6 @@ import gs.graph.v0.Graph import gs.graph.v0.GraphDisposition import gs.graph.v0.Size import gs.graph.v0.Vertex -import gs.graph.v0.dag.Dag /** Directed implementation of [[Graph]]. * diff --git a/src/main/scala/gs/graph/v0/directed/SCC.scala b/modules/core/src/main/scala/gs/graph/v0/directed/SCC.scala similarity index 100% rename from src/main/scala/gs/graph/v0/directed/SCC.scala rename to modules/core/src/main/scala/gs/graph/v0/directed/SCC.scala diff --git a/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala b/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala similarity index 100% rename from src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala rename to modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala