From 86c067f3fa608bf8fda149fdb385757dcd5b51f9 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 17 Dec 2025 22:26:12 -0600 Subject: [PATCH] WIP, now with streaming DFS --- build.sbt | 14 ++- .../gs/graph/v0/cats/syntax/extensions.scala | 12 +-- .../src/main/scala/gs/graph/v0/Edge.scala | 7 ++ .../src/main/scala/gs/graph/v0/Graph.scala | 22 ++--- .../scala/gs/graph/v0/GraphTraversal.scala | 74 ++++++++++++--- .../src/main/scala/gs/graph/v0/Size.scala | 4 + .../scala/gs/graph/v0/UndirectedGraph.scala | 6 ++ .../gs/graph/v0/data/AnyGraphWithData.scala | 21 +---- .../scala/gs/graph/v0/directed/Digraph.scala | 13 +++ ...tedGraph.scala => SingleRootDigraph.scala} | 26 +++--- .../scala/gs/graph/v0/syntax/extensions.scala | 92 +++++++++++++++++++ .../scala/gs/graph/v0/directed/DagTests.scala | 59 ++++++++++++ .../gs/graph/v0/fs2/GraphTraversalFs2.scala | 61 ++++++++++++ 13 files changed, 338 insertions(+), 73 deletions(-) rename modules/core/src/main/scala/gs/graph/v0/directed/{SingleRootDirectedGraph.scala => SingleRootDigraph.scala} (76%) create mode 100644 modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala create mode 100644 modules/fs2/src/main/scala/gs/graph/v0/fs2/GraphTraversalFs2.scala diff --git a/build.sbt b/build.sbt index fe4cf38..d477c9f 100644 --- a/build.sbt +++ b/build.sbt @@ -30,6 +30,10 @@ val Deps = new { 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 Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" } @@ -46,7 +50,7 @@ lazy val testSettings = Seq( lazy val `gs-graph` = project .in(file(".")) - .aggregate(core, cats) + .aggregate(core, cats, fs2) .settings(sharedSettings) .settings(testSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") @@ -64,3 +68,11 @@ lazy val cats = project .settings(testSettings) .settings(name := s"${gsProjectName.value}-cats-v${semVerMajor.value}") .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)) 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 index 58eed06..226fe8d 100644 --- 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 @@ -1,10 +1,9 @@ -package graph.gs.v0.cats.syntax +package gs.graph.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) @@ -13,12 +12,3 @@ extension (graph: Graph) 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/modules/core/src/main/scala/gs/graph/v0/Edge.scala b/modules/core/src/main/scala/gs/graph/v0/Edge.scala index b77c316..bf47e76 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Edge.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Edge.scala @@ -47,4 +47,11 @@ object Edge: 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 diff --git a/modules/core/src/main/scala/gs/graph/v0/Graph.scala b/modules/core/src/main/scala/gs/graph/v0/Graph.scala index 5522598..497f34d 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Graph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Graph.scala @@ -4,6 +4,7 @@ package gs.graph.v0 * * See: * - [[gs.graph.v0.directed.DirectedGraph]] + * - [[gs.graph.v0.UndirectedGraph]] * * @param numberOfVertices * The number of [[Vertex]] present in this Graph. @@ -31,6 +32,11 @@ trait Graph: */ 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. * * @param vertex @@ -52,19 +58,3 @@ trait Graph: case _ => false 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 diff --git a/modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala b/modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala index 9a8c0d4..717be39 100644 --- a/modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala +++ b/modules/core/src/main/scala/gs/graph/v0/GraphTraversal.scala @@ -1,6 +1,6 @@ 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.Stack @@ -74,11 +74,35 @@ object GraphTraversal: 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 * [[Vertex]], accepting the data stored for that [[Vertex]] as input. * * This implementation performs DFS for _each root_ in the input - * [[DataDigraph]]. + * [[AnyGraphWithData]]. * * @param graph * The input [[Graph]] on which to run DFS. @@ -86,22 +110,22 @@ object GraphTraversal: * The visitor function. */ def dfs[A]( - digraph: DataDigraph[A], + graph: AnyGraphWithData[A], visit: (Vertex, A) => Unit ): Unit = 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) while !s.isEmpty do val v = s.pop() if !discovered(v.ordinal) then - val _ = visit(v, digraph.data(v.ordinal)) + val _ = visit(v, graph.data(v.ordinal)) discovered(v.ordinal) = true - digraph.neighbors(v).foreach(w => s.push(w)) + graph.neighbors(v).foreach(w => s.push(w)) else () } @@ -111,7 +135,7 @@ object GraphTraversal: * some output, accepting the data stored for that [[Vertex]] as input. * * This implementation performs DFS for _each root_ in the input - * [[DataDigraph]]. + * [[AnyGraphWithData]]. * * @param graph * 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. */ def dfs[A, Out]( - digraph: DataDigraph[A], + graph: AnyGraphWithData[A], visit: (Vertex, A) => Out ): List[Out] = val output = ListBuffer.empty[Out] 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) while !s.isEmpty do val v = s.pop() 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 - digraph.neighbors(v).foreach(w => s.push(w)) + graph.neighbors(v).foreach(w => s.push(w)) else () } 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 diff --git a/modules/core/src/main/scala/gs/graph/v0/Size.scala b/modules/core/src/main/scala/gs/graph/v0/Size.scala index fddb4c8..e98c192 100644 --- a/modules/core/src/main/scala/gs/graph/v0/Size.scala +++ b/modules/core/src/main/scala/gs/graph/v0/Size.scala @@ -66,6 +66,10 @@ object Size: */ final val Zero: Size = new Size(0) + /** The constant size: 1. + */ + final val One: Size = new Size(1) + /** Instantiate a new [[Size]]. * * Throws an exception if a negative input is given. diff --git a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala index f0cf3c7..cc96214 100644 --- a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala @@ -14,6 +14,12 @@ class UndirectedGraph( /** @inheritDocs */ 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 object UndirectedGraph: diff --git a/modules/core/src/main/scala/gs/graph/v0/data/AnyGraphWithData.scala b/modules/core/src/main/scala/gs/graph/v0/data/AnyGraphWithData.scala index bfb6839..30292b1 100644 --- a/modules/core/src/main/scala/gs/graph/v0/data/AnyGraphWithData.scala +++ b/modules/core/src/main/scala/gs/graph/v0/data/AnyGraphWithData.scala @@ -1,35 +1,18 @@ package gs.graph.v0.data -import gs.graph.v0.Adjacency -import gs.graph.v0.GraphDisposition +import gs.graph.v0.Graph import gs.graph.v0.GraphException -import gs.graph.v0.Size import gs.graph.v0.Vertex /** Trait that describes _any_ graph (undirected, directed, or other * specializations of those such as DAGs) that is correlated with data. */ -trait AnyGraphWithData[A]: +trait AnyGraphWithData[A] extends Graph: /** @return * The data stored by each [[gs.graph.v0.Vertex]] in this graph. */ 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]]. * * This implementation throws an exception if the input [[Vertex]] is out of diff --git a/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala b/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala index 9ef9cd2..980b20b 100644 --- a/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/Digraph.scala @@ -25,6 +25,10 @@ class Digraph( */ override val disposition: GraphDisposition = GraphDisposition.Directed + /** @inheritDocs + */ + override def selectRoots(): Vector[Vertex] = roots + override def equals(that: Any): Boolean = that match case other: Digraph => @@ -39,8 +43,17 @@ object Digraph: given CanEqual[Digraph, Digraph] = CanEqual.derived + /** @return + * An empty [[Digraph]]. + */ 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( numberOfVertices: Size, edges: Iterable[Edge] diff --git a/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala b/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala similarity index 76% rename from modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala rename to modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala index 12b73ef..48661ad 100644 --- a/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/directed/SingleRootDigraph.scala @@ -6,18 +6,18 @@ import gs.graph.v0.GraphException import gs.graph.v0.Size import gs.graph.v0.Vertex -class SingleRootDirectedGraph( +class SingleRootDigraph( n: Size, a: Adjacency, r: Vertex ) 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 Some( - new SingleRootDirectedGraph( + new SingleRootDigraph( dg.numberOfVertices, dg.adjacency, dg.roots(0) @@ -29,9 +29,9 @@ object SingleRootDirectedGraph: numberOfVertices: Size, edges: Iterable[Edge], root: Vertex - ): SingleRootDirectedGraph = + ): SingleRootDigraph = if root < numberOfVertices then - new SingleRootDirectedGraph( + new SingleRootDigraph( numberOfVertices, Adjacency.fromDirectedEdges(numberOfVertices, edges), root @@ -41,11 +41,11 @@ object SingleRootDirectedGraph: def fromEdges( numberOfVertices: Size, edges: Iterable[Edge] - ): Option[SingleRootDirectedGraph] = + ): Option[SingleRootDigraph] = val roots = Digraph.findRootsForDirectedEdges(numberOfVertices, edges) if roots.size == 1 then Some( - new SingleRootDirectedGraph( + new SingleRootDigraph( numberOfVertices, Adjacency.fromDirectedEdges(numberOfVertices, edges), roots(0) @@ -56,9 +56,9 @@ object SingleRootDirectedGraph: def fromAdjacencyUnsafe( adjacency: Adjacency, root: Vertex - ): SingleRootDirectedGraph = + ): SingleRootDigraph = if root < adjacency.numberOfVertices then - new SingleRootDirectedGraph( + new SingleRootDigraph( adjacency.numberOfVertices, adjacency, root @@ -67,11 +67,11 @@ object SingleRootDirectedGraph: def fromAdjacency( adjacency: Adjacency - ): Option[SingleRootDirectedGraph] = + ): Option[SingleRootDigraph] = val roots = adjacency.findRoots() if roots.size == 1 then Some( - new SingleRootDirectedGraph( + new SingleRootDigraph( adjacency.numberOfVertices, adjacency, roots(0) @@ -79,4 +79,4 @@ object SingleRootDirectedGraph: ) else None -end SingleRootDirectedGraph +end SingleRootDigraph diff --git a/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala b/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala new file mode 100644 index 0000000..69c68e9 --- /dev/null +++ b/modules/core/src/main/scala/gs/graph/v0/syntax/extensions.scala @@ -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) diff --git a/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala b/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala index 115e998..a1adc93 100644 --- a/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala +++ b/modules/core/src/test/scala/gs/graph/v0/directed/DagTests.scala @@ -1,5 +1,9 @@ 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.* class DagTests extends FunSuite: @@ -9,3 +13,58 @@ class DagTests extends FunSuite: case Right(dag) => assertEquals(dag, Digraph.empty()) 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 _ => () + } diff --git a/modules/fs2/src/main/scala/gs/graph/v0/fs2/GraphTraversalFs2.scala b/modules/fs2/src/main/scala/gs/graph/v0/fs2/GraphTraversalFs2.scala new file mode 100644 index 0000000..e0473bd --- /dev/null +++ b/modules/fs2/src/main/scala/gs/graph/v0/fs2/GraphTraversalFs2.scala @@ -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