WIP, now with streaming DFS
All checks were successful
/ Build and Release Library (push) Successful in 1m22s

This commit is contained in:
Pat Garrity 2025-12-17 22:26:12 -06:00
parent f185bed5f5
commit 86c067f3fa
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
13 changed files with 338 additions and 73 deletions

View file

@ -30,6 +30,10 @@ val Deps = new {
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0"
} }
val Fs2 = new {
val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.12.2"
}
val Gs = new { val Gs = new {
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3"
} }
@ -46,7 +50,7 @@ lazy val testSettings = Seq(
lazy val `gs-graph` = project lazy val `gs-graph` = project
.in(file(".")) .in(file("."))
.aggregate(core, cats) .aggregate(core, cats, fs2)
.settings(sharedSettings) .settings(sharedSettings)
.settings(testSettings) .settings(testSettings)
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
@ -64,3 +68,11 @@ lazy val cats = project
.settings(testSettings) .settings(testSettings)
.settings(name := s"${gsProjectName.value}-cats-v${semVerMajor.value}") .settings(name := s"${gsProjectName.value}-cats-v${semVerMajor.value}")
.settings(libraryDependencies ++= Seq(Deps.Cats.Core)) .settings(libraryDependencies ++= Seq(Deps.Cats.Core))
lazy val fs2 = project
.in(file("modules/fs2"))
.dependsOn(core)
.settings(sharedSettings)
.settings(testSettings)
.settings(name := s"${gsProjectName.value}-fs2-v${semVerMajor.value}")
.settings(libraryDependencies ++= Seq(Deps.Fs2.Core))

View file

@ -1,10 +1,9 @@
package graph.gs.v0.cats.syntax package gs.graph.v0.cats.syntax
import cats.Monad import cats.Monad
import gs.graph.v0.Graph import gs.graph.v0.Graph
import gs.graph.v0.Vertex import gs.graph.v0.Vertex
import gs.graph.v0.cats.GraphTraversalCats import gs.graph.v0.cats.GraphTraversalCats
import gs.graph.v0.data.DataDigraph
extension (graph: Graph) extension (graph: Graph)
@ -13,12 +12,3 @@ extension (graph: Graph)
def dfs[F[_]: Monad, Output](visit: Vertex => F[Output]): F[List[Output]] = def dfs[F[_]: Monad, Output](visit: Vertex => F[Output]): F[List[Output]] =
GraphTraversalCats.dfs[F, Output](graph, visit) 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)

View file

@ -47,4 +47,11 @@ object Edge:
given CanEqual[Edge, Edge] = CanEqual.derived given CanEqual[Edge, Edge] = CanEqual.derived
def apply(
v1: Vertex,
v2: Vertex
): Edge = new Edge(v1, v2)
def apply(vs: (Vertex, Vertex)): Edge = new Edge(vs._1, vs._2)
end Edge end Edge

View file

@ -4,6 +4,7 @@ package gs.graph.v0
* *
* See: * See:
* - [[gs.graph.v0.directed.DirectedGraph]] * - [[gs.graph.v0.directed.DirectedGraph]]
* - [[gs.graph.v0.UndirectedGraph]]
* *
* @param numberOfVertices * @param numberOfVertices
* The number of [[Vertex]] present in this Graph. * The number of [[Vertex]] present in this Graph.
@ -31,6 +32,11 @@ trait Graph:
*/ */
def isEmpty: Boolean = numberOfVertices == Size.Zero def isEmpty: Boolean = numberOfVertices == Size.Zero
/** @return
* The roots that should be used for traversal operations.
*/
def selectRoots(): Vector[Vertex]
/** Get the neighbors for any given vertex. /** Get the neighbors for any given vertex.
* *
* @param vertex * @param vertex
@ -52,19 +58,3 @@ trait Graph:
case _ => false case _ => false
end Graph end Graph
object Graph:
/** The empty graph. Contains no vertices and no edges.
*/
object Empty extends Graph:
override def numberOfVertices: Size = Size.Zero
override def adjacency: Adjacency = Adjacency.Empty
override def disposition: GraphDisposition = GraphDisposition.Undirected
end Empty
end Graph

View file

@ -1,6 +1,6 @@
package gs.graph.v0 package gs.graph.v0
import gs.graph.v0.data.DataDigraph import gs.graph.v0.data.AnyGraphWithData
import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer
import scala.collection.mutable.Stack import scala.collection.mutable.Stack
@ -74,11 +74,35 @@ object GraphTraversal:
output.toList output.toList
def dfsFold[Acc](
graph: Graph,
initial: Acc,
f: (Acc, Vertex) => Acc
): Acc =
var acc = initial
val s = Stack.empty[Vertex]
val discovered = Array.fill(graph.numberOfVertices.value)(false)
graph.selectRoots().foreach { root =>
s.push(root)
while !s.isEmpty
do
val v = s.pop()
if !discovered(v.ordinal) then
acc = f(acc, v)
discovered(v.ordinal) = true
graph.neighbors(v).foreach(w => s.push(w))
else ()
}
acc
/** Depth-first search that executes a side-effecting function on each /** Depth-first search that executes a side-effecting function on each
* [[Vertex]], accepting the data stored for that [[Vertex]] as input. * [[Vertex]], accepting the data stored for that [[Vertex]] as input.
* *
* This implementation performs DFS for _each root_ in the input * This implementation performs DFS for _each root_ in the input
* [[DataDigraph]]. * [[AnyGraphWithData]].
* *
* @param graph * @param graph
* The input [[Graph]] on which to run DFS. * The input [[Graph]] on which to run DFS.
@ -86,22 +110,22 @@ object GraphTraversal:
* The visitor function. * The visitor function.
*/ */
def dfs[A]( def dfs[A](
digraph: DataDigraph[A], graph: AnyGraphWithData[A],
visit: (Vertex, A) => Unit visit: (Vertex, A) => Unit
): Unit = ): Unit =
val s = Stack.empty[Vertex] val s = Stack.empty[Vertex]
val discovered = Array.fill(digraph.numberOfVertices.value)(false) val discovered = Array.fill(graph.numberOfVertices.value)(false)
digraph.roots.foreach { root => graph.selectRoots().foreach { root =>
s.push(root) s.push(root)
while !s.isEmpty while !s.isEmpty
do do
val v = s.pop() val v = s.pop()
if !discovered(v.ordinal) then if !discovered(v.ordinal) then
val _ = visit(v, digraph.data(v.ordinal)) val _ = visit(v, graph.data(v.ordinal))
discovered(v.ordinal) = true discovered(v.ordinal) = true
digraph.neighbors(v).foreach(w => s.push(w)) graph.neighbors(v).foreach(w => s.push(w))
else () else ()
} }
@ -111,7 +135,7 @@ object GraphTraversal:
* some output, accepting the data stored for that [[Vertex]] as input. * some output, accepting the data stored for that [[Vertex]] as input.
* *
* This implementation performs DFS for _each root_ in the input * This implementation performs DFS for _each root_ in the input
* [[DataDigraph]]. * [[AnyGraphWithData]].
* *
* @param graph * @param graph
* The input [[Graph]] on which to run DFS. * The input [[Graph]] on which to run DFS.
@ -121,26 +145,50 @@ object GraphTraversal:
* List of output, assembled in the order that vertices were visited. * List of output, assembled in the order that vertices were visited.
*/ */
def dfs[A, Out]( def dfs[A, Out](
digraph: DataDigraph[A], graph: AnyGraphWithData[A],
visit: (Vertex, A) => Out visit: (Vertex, A) => Out
): List[Out] = ): List[Out] =
val output = ListBuffer.empty[Out] val output = ListBuffer.empty[Out]
val s = Stack.empty[Vertex] val s = Stack.empty[Vertex]
val discovered = Array.fill(digraph.numberOfVertices.value)(false) val discovered = Array.fill(graph.numberOfVertices.value)(false)
digraph.roots.foreach { root => graph.selectRoots().foreach { root =>
s.push(root) s.push(root)
while !s.isEmpty while !s.isEmpty
do do
val v = s.pop() val v = s.pop()
if !discovered(v.ordinal) then if !discovered(v.ordinal) then
val _ = output.addOne(visit(v, digraph.data(v.ordinal))) val _ = output.addOne(visit(v, graph.data(v.ordinal)))
discovered(v.ordinal) = true discovered(v.ordinal) = true
digraph.neighbors(v).foreach(w => s.push(w)) graph.neighbors(v).foreach(w => s.push(w))
else () else ()
} }
output.toList output.toList
def dfsFold[A, Acc](
graph: AnyGraphWithData[A],
initial: Acc,
f: (Acc, A) => Acc
): Acc =
var acc = initial
val s = Stack.empty[Vertex]
val discovered = Array.fill(graph.numberOfVertices.value)(false)
graph.selectRoots().foreach { root =>
s.push(root)
while !s.isEmpty
do
val v = s.pop()
if !discovered(v.ordinal) then
acc = f(acc, graph.data(v.ordinal))
discovered(v.ordinal) = true
graph.neighbors(v).foreach(w => s.push(w))
else ()
}
acc
end GraphTraversal end GraphTraversal

View file

@ -66,6 +66,10 @@ object Size:
*/ */
final val Zero: Size = new Size(0) final val Zero: Size = new Size(0)
/** The constant size: 1.
*/
final val One: Size = new Size(1)
/** Instantiate a new [[Size]]. /** Instantiate a new [[Size]].
* *
* Throws an exception if a negative input is given. * Throws an exception if a negative input is given.

View file

@ -14,6 +14,12 @@ class UndirectedGraph(
/** @inheritDocs /** @inheritDocs
*/ */
final override val disposition: GraphDisposition = GraphDisposition.Undirected final override val disposition: GraphDisposition = GraphDisposition.Undirected
/** @inheritDocs
*/
override def selectRoots(): Vector[Vertex] =
if numberOfVertices == Size.Zero then Vector.empty else Vector(Vertex.Zero)
end UndirectedGraph end UndirectedGraph
object UndirectedGraph: object UndirectedGraph:

View file

@ -1,35 +1,18 @@
package gs.graph.v0.data package gs.graph.v0.data
import gs.graph.v0.Adjacency import gs.graph.v0.Graph
import gs.graph.v0.GraphDisposition
import gs.graph.v0.GraphException import gs.graph.v0.GraphException
import gs.graph.v0.Size
import gs.graph.v0.Vertex import gs.graph.v0.Vertex
/** Trait that describes _any_ graph (undirected, directed, or other /** Trait that describes _any_ graph (undirected, directed, or other
* specializations of those such as DAGs) that is correlated with data. * specializations of those such as DAGs) that is correlated with data.
*/ */
trait AnyGraphWithData[A]: trait AnyGraphWithData[A] extends Graph:
/** @return /** @return
* The data stored by each [[gs.graph.v0.Vertex]] in this graph. * The data stored by each [[gs.graph.v0.Vertex]] in this graph.
*/ */
def data: Vector[A] def data: Vector[A]
/** @return
* The number of vertices present in this graph.
*/
def numberOfVertices: Size
/** @return
* The [[Adjacency]] that describes this graph.
*/
def adjacency: Adjacency
/** @return
* The underlying disposition of this graph.
*/
def disposition: GraphDisposition
/** Retrieve the data associated with some [[Vertex]]. /** Retrieve the data associated with some [[Vertex]].
* *
* This implementation throws an exception if the input [[Vertex]] is out of * This implementation throws an exception if the input [[Vertex]] is out of

View file

@ -25,6 +25,10 @@ class Digraph(
*/ */
override val disposition: GraphDisposition = GraphDisposition.Directed override val disposition: GraphDisposition = GraphDisposition.Directed
/** @inheritDocs
*/
override def selectRoots(): Vector[Vertex] = roots
override def equals(that: Any): Boolean = override def equals(that: Any): Boolean =
that match that match
case other: Digraph => case other: Digraph =>
@ -39,8 +43,17 @@ object Digraph:
given CanEqual[Digraph, Digraph] = CanEqual.derived given CanEqual[Digraph, Digraph] = CanEqual.derived
/** @return
* An empty [[Digraph]].
*/
def empty(): Digraph = new Digraph(Size.Zero, Adjacency.Empty, Vector.empty) def empty(): Digraph = new Digraph(Size.Zero, Adjacency.Empty, Vector.empty)
/** @return
* A [[Digraph]] with a single [[Vertex]] and no edges.
*/
def single(): Digraph =
new Digraph(Size.One, Adjacency(Vector(Vector.empty)), Vector(Vertex.Zero))
def fromEdges( def fromEdges(
numberOfVertices: Size, numberOfVertices: Size,
edges: Iterable[Edge] edges: Iterable[Edge]

View file

@ -6,18 +6,18 @@ import gs.graph.v0.GraphException
import gs.graph.v0.Size import gs.graph.v0.Size
import gs.graph.v0.Vertex import gs.graph.v0.Vertex
class SingleRootDirectedGraph( class SingleRootDigraph(
n: Size, n: Size,
a: Adjacency, a: Adjacency,
r: Vertex r: Vertex
) extends Digraph(n, a, Vector(r)) ) extends Digraph(n, a, Vector(r))
object SingleRootDirectedGraph: object SingleRootDigraph:
def fromDirectedGraph(dg: Digraph): Option[SingleRootDirectedGraph] = def fromDirectedGraph(dg: Digraph): Option[SingleRootDigraph] =
if dg.roots.size == 1 then if dg.roots.size == 1 then
Some( Some(
new SingleRootDirectedGraph( new SingleRootDigraph(
dg.numberOfVertices, dg.numberOfVertices,
dg.adjacency, dg.adjacency,
dg.roots(0) dg.roots(0)
@ -29,9 +29,9 @@ object SingleRootDirectedGraph:
numberOfVertices: Size, numberOfVertices: Size,
edges: Iterable[Edge], edges: Iterable[Edge],
root: Vertex root: Vertex
): SingleRootDirectedGraph = ): SingleRootDigraph =
if root < numberOfVertices then if root < numberOfVertices then
new SingleRootDirectedGraph( new SingleRootDigraph(
numberOfVertices, numberOfVertices,
Adjacency.fromDirectedEdges(numberOfVertices, edges), Adjacency.fromDirectedEdges(numberOfVertices, edges),
root root
@ -41,11 +41,11 @@ object SingleRootDirectedGraph:
def fromEdges( def fromEdges(
numberOfVertices: Size, numberOfVertices: Size,
edges: Iterable[Edge] edges: Iterable[Edge]
): Option[SingleRootDirectedGraph] = ): Option[SingleRootDigraph] =
val roots = Digraph.findRootsForDirectedEdges(numberOfVertices, edges) val roots = Digraph.findRootsForDirectedEdges(numberOfVertices, edges)
if roots.size == 1 then if roots.size == 1 then
Some( Some(
new SingleRootDirectedGraph( new SingleRootDigraph(
numberOfVertices, numberOfVertices,
Adjacency.fromDirectedEdges(numberOfVertices, edges), Adjacency.fromDirectedEdges(numberOfVertices, edges),
roots(0) roots(0)
@ -56,9 +56,9 @@ object SingleRootDirectedGraph:
def fromAdjacencyUnsafe( def fromAdjacencyUnsafe(
adjacency: Adjacency, adjacency: Adjacency,
root: Vertex root: Vertex
): SingleRootDirectedGraph = ): SingleRootDigraph =
if root < adjacency.numberOfVertices then if root < adjacency.numberOfVertices then
new SingleRootDirectedGraph( new SingleRootDigraph(
adjacency.numberOfVertices, adjacency.numberOfVertices,
adjacency, adjacency,
root root
@ -67,11 +67,11 @@ object SingleRootDirectedGraph:
def fromAdjacency( def fromAdjacency(
adjacency: Adjacency adjacency: Adjacency
): Option[SingleRootDirectedGraph] = ): Option[SingleRootDigraph] =
val roots = adjacency.findRoots() val roots = adjacency.findRoots()
if roots.size == 1 then if roots.size == 1 then
Some( Some(
new SingleRootDirectedGraph( new SingleRootDigraph(
adjacency.numberOfVertices, adjacency.numberOfVertices,
adjacency, adjacency,
roots(0) roots(0)
@ -79,4 +79,4 @@ object SingleRootDirectedGraph:
) )
else None else None
end SingleRootDirectedGraph end SingleRootDigraph

View file

@ -0,0 +1,92 @@
package gs.graph.v0.syntax
import gs.graph.v0.Graph
import gs.graph.v0.GraphTraversal
import gs.graph.v0.Vertex
import gs.graph.v0.data.AnyGraphWithData
extension (graph: Graph)
/** Visit vertex node in this graph, using Depth-First Search (DFS) as the
* traversal method. For each vertex, execute the given visitor function
* against the vertex.
*
* In this variant, the visitor is a side-effecting function that does not
* return any value.
*
* @param visit
* Side-effecting function to execute for each graph vertex.
*/
def visitDfs(visit: Vertex => Unit): Unit =
GraphTraversal.dfs(graph, visit)
/** Visit vertex node in this graph, using Depth-First Search (DFS) as the
* traversal method. For each vertex, execute the given visitor function
* against the vertex.
*
* In this variant, the visitor produces some output, and the list of all
* such outputs is collected and returned.
*
* @param visit
* Function to execute for each graph vertex.
* @return
* The list of produced values.
*/
def visitDfs[Out](visit: Vertex => Out): List[Out] =
GraphTraversal.dfs[Out](graph, visit)
/** Perform a fold on this graph, using Depth-First Search (DFS) as the
* traversal method.
*
* @param initial
* The initial value for the accumulator.
* @param f
* The function to execute on each vertex in the graph.
* @return
* The final calculated value.
*/
def foldDfs[Acc](initial: Acc)(f: (Acc, Vertex) => Acc): Acc =
GraphTraversal.dfsFold[Acc](graph, initial, f)
extension [A](graph: AnyGraphWithData[A])
/** Visit vertex node in this graph, using Depth-First Search (DFS) as the
* traversal method. For each vertex, execute the given visitor function
* against the vertex and the data it stores.
*
* In this variant, the visitor is a side-effecting function that does not
* return any value.
*
* @param visit
* Side-effecting function to execute for each graph vertex.
*/
def visitDfs(visit: (Vertex, A) => Unit): Unit =
GraphTraversal.dfs[A](graph, visit)
/** Visit vertex node in this graph, using Depth-First Search (DFS) as the
* traversal method. For each vertex, execute the given visitor function
* against the vertex and the data it stores.
*
* In this variant, the visitor produces some output, and the list of all
* such outputs is collected and returned.
*
* @param visit
* Function to execute for each graph vertex.
* @return
* The list of produced values.
*/
def visitDfs[Out](visit: (Vertex, A) => Out): List[Out] =
GraphTraversal.dfs[A, Out](graph, visit)
/** Perform a fold on this graph, using Depth-First Search (DFS) as the
* traversal method.
*
* @param initial
* The initial value for the accumulator.
* @param f
* The function to execute on each data point in the graph.
* @return
* The final calculated value.
*/
def foldDfs[Acc](initial: Acc)(f: (Acc, A) => Acc): Acc =
GraphTraversal.dfsFold[A, Acc](graph, initial, f)

View file

@ -1,5 +1,9 @@
package gs.graph.v0.directed package gs.graph.v0.directed
import gs.graph.v0.Adjacency
import gs.graph.v0.Edge
import gs.graph.v0.Size
import gs.graph.v0.Vertex
import munit.* import munit.*
class DagTests extends FunSuite: class DagTests extends FunSuite:
@ -9,3 +13,58 @@ class DagTests extends FunSuite:
case Right(dag) => assertEquals(dag, Digraph.empty()) case Right(dag) => assertEquals(dag, Digraph.empty())
case _ => fail("Expected the empty graph to be validated as a DAG.") case _ => fail("Expected the empty graph to be validated as a DAG.")
} }
test("should validate a graph with one node") {
Dag.validate(Digraph.single()) match
case Right(dag) => assertEquals(dag, Digraph.single())
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 vs = (0 until size.value).map(Vertex(_))
val digraph: Digraph = Digraph.fromAdjacency(
Adjacency.fromDirectedEdges(
numberOfVertices = size,
edges = Seq(
Edge(vs(0) -> vs(1)),
Edge(vs(0) -> vs(2)),
Edge(vs(0) -> vs(3)),
Edge(vs(1) -> vs(4)),
Edge(vs(2) -> vs(4)),
Edge(vs(3) -> vs(5)),
Edge(vs(4) -> vs(6)),
Edge(vs(5) -> vs(6)),
Edge(vs(6) -> vs(7))
)
)
)
Dag.validate(digraph) match
case Right(dag) => assertEquals(dag, digraph)
case _ => fail("Expected a single-root graph to be validated as a DAG.")
}
test("should NOT validate a single-root digraph with a cycle") {
val size = Size(4)
val vs = (0 until size.value).map(Vertex(_))
val digraph: Digraph = Digraph.fromAdjacency(
Adjacency.fromDirectedEdges(
numberOfVertices = size,
edges = Seq(
Edge(vs(0) -> vs(1)),
Edge(vs(1) -> vs(2)),
Edge(vs(2) -> vs(3)),
Edge(vs(3) -> vs(1))
)
)
)
Dag.validate(digraph) match
case Right(_) =>
fail(
"Expected a single-root digraph with a cycle to fail DAG validation."
)
case _ => ()
}

View file

@ -0,0 +1,61 @@
package gs.graph.v0.fs2
import cats.effect.Sync
import fs2.Pull
import fs2.Stream
import gs.graph.v0.Graph
import gs.graph.v0.Size
import gs.graph.v0.Vertex
import scala.collection.mutable.Stack
object GraphTraversalFs2:
def dfs[F[_]: Sync, Out](
graph: Graph,
visit: Vertex => F[Out]
): Stream[F, Out] =
val state = new DfsState(graph.numberOfVertices)
graph
.selectRoots()
.map(root => pull(graph, state, visit, root).stream.unNoneTerminate)
.reduce(_ ++ _)
private def pull[F[_]: Sync, Out](
graph: Graph,
state: DfsState,
visit: Vertex => F[Out],
current: Vertex
): Pull[F, Option[Out], Unit] =
Pull.eval(Sync[F].delay(state.isDiscovered(current))).flatMap { discovered =>
if discovered then Pull.output1(None) >> Pull.done
else
Pull.eval(Sync[F].delay(state.discover(current)))
>> Pull.eval(visit(current)).flatMap(out => Pull.output1(Some(out)))
>> graph
.neighbors(current)
.foldLeft(Pull.done: Pull[F, Option[Out], Unit]) {
(
acc,
neighbor
) => acc >> pull(graph, state, visit, neighbor)
}
}
final private class DfsState(n: Size):
val stack: Stack[Vertex] = Stack.empty
val discovered: Array[Boolean] = Array.fill(n.value)(false)
def push(vertex: Vertex): Unit = stack.push(vertex)
def pop(): Vertex = stack.pop()
def isDiscovered(vertex: Vertex): Boolean =
discovered(vertex.ordinal)
def discover(vertex: Vertex): Unit =
discovered(vertex.ordinal) = true
end DfsState
end GraphTraversalFs2