From 7b1c110c75e1d5b27adbf52827241db0a78043de Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Mon, 8 Dec 2025 22:26:20 -0600 Subject: [PATCH] Initial implementation. --- .forgejo/workflows/pull_request.yaml | 67 ++++++++ .forgejo/workflows/release.yaml | 84 ++++++++++ .gitignore | 5 + .pre-commit-config.yaml | 17 ++ .scalafmt.conf | 72 +++++++++ LICENSE | 9 ++ README.md | 29 ++++ build.sbt | 46 ++++++ project/build.properties | 1 + project/plugins.sbt | 33 ++++ src/main/scala/gs/graph/v0/Adjacency.scala | 103 ++++++++++++ src/main/scala/gs/graph/v0/DFS.scala | 144 +++++++++++++++++ src/main/scala/gs/graph/v0/Edge.scala | 50 ++++++ src/main/scala/gs/graph/v0/Graph.scala | 68 ++++++++ .../scala/gs/graph/v0/GraphDisposition.scala | 21 +++ src/main/scala/gs/graph/v0/GraphError.scala | 12 ++ .../scala/gs/graph/v0/GraphException.scala | 34 ++++ src/main/scala/gs/graph/v0/Size.scala | 111 +++++++++++++ src/main/scala/gs/graph/v0/Vertex.scala | 98 ++++++++++++ src/main/scala/gs/graph/v0/dag/Dag.scala | 36 +++++ .../scala/gs/graph/v0/data/DataDigraph.scala | 66 ++++++++ .../scala/gs/graph/v0/directed/Digraph.scala | 128 +++++++++++++++ src/main/scala/gs/graph/v0/directed/SCC.scala | 147 ++++++++++++++++++ .../v0/directed/SingleRootDirectedGraph.scala | 82 ++++++++++ 24 files changed, 1463 insertions(+) create mode 100644 .forgejo/workflows/pull_request.yaml create mode 100644 .forgejo/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .scalafmt.conf create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 src/main/scala/gs/graph/v0/Adjacency.scala create mode 100644 src/main/scala/gs/graph/v0/DFS.scala create mode 100644 src/main/scala/gs/graph/v0/Edge.scala create mode 100644 src/main/scala/gs/graph/v0/Graph.scala create mode 100644 src/main/scala/gs/graph/v0/GraphDisposition.scala create mode 100644 src/main/scala/gs/graph/v0/GraphError.scala create mode 100644 src/main/scala/gs/graph/v0/GraphException.scala create mode 100644 src/main/scala/gs/graph/v0/Size.scala create mode 100644 src/main/scala/gs/graph/v0/Vertex.scala create mode 100644 src/main/scala/gs/graph/v0/dag/Dag.scala create mode 100644 src/main/scala/gs/graph/v0/data/DataDigraph.scala create mode 100644 src/main/scala/gs/graph/v0/directed/Digraph.scala create mode 100644 src/main/scala/gs/graph/v0/directed/SCC.scala create mode 100644 src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala diff --git a/.forgejo/workflows/pull_request.yaml b/.forgejo/workflows/pull_request.yaml new file mode 100644 index 0000000..d331681 --- /dev/null +++ b/.forgejo/workflows/pull_request.yaml @@ -0,0 +1,67 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + +defaults: + run: + shell: bash + +jobs: + library_snapshot: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Test Library Snapshot' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message ($GS_RELEASE_TYPE) (SNAPSHOT)" + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + sbtn -Dsnapshot=true -Drelease="patch" semVerInfo + else + sbtn -Dsnapshot=true -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + - name: 'Publish Snapshot' + run: | + echo "Testing env var propagation = ${{ env.GS_RELEASE_TYPE }}" + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish due to GS_RELEASE_TYPE=norelease" + else + sbtn coverageOff + sbtn clean + sbtn publish + fi diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..72fac7d --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,84 @@ +on: + push: + branches: + - main + +defaults: + run: + shell: bash + +jobs: + library_release: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Release Library' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message" + echo "Selected Release Type: '$GS_RELEASE_TYPE'" + + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + echo "Skipping all versioning for 'norelease' commit." + else + sbtn -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping build/test for 'norelease' commit." + else + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + fi + - name: 'Publish Release' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish for 'norelease' commit." + else + sbtn coverageOff + sbtn clean + sbtn semVerWriteVersionToFile + sbtn publish + fi + - name: 'Create Git Tag' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping Git tag for 'norelease' commit." + else + selected_version="$(cat .version)" + git tag "$selected_version" + git push origin "$selected_version" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78eb111 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +project/target/ +project/project/ +.version +.scala-build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d3cafd8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: fix-byte-order-marker + - id: mixed-line-ending + args: ['--fix=lf'] + description: Enforces using only 'LF' line endings. + - id: trailing-whitespace + - id: check-yaml + - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala + rev: v1.0.1 + hooks: + - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..1d45dca --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,72 @@ +// See: https://github.com/scalameta/scalafmt/tags for the latest tags. +version = 3.10.2 +runner.dialect = scala3 +maxColumn = 80 + +rewrite { + rules = [RedundantBraces, RedundantParens, Imports, SortModifiers] + imports.expand = true + imports.sort = scalastyle + redundantBraces.ifElseExpressions = true + redundantBraces.stringInterpolation = true +} + +indent { + main = 2 + callSite = 2 + defnSite = 2 + extendSite = 4 + withSiteRelativeToExtends = 2 + commaSiteRelativeToExtends = 2 +} + +align { + preset = more + openParenCallSite = false + openParenDefnSite = false +} + +newlines { + implicitParamListModifierForce = [before,after] + topLevelStatementBlankLines = [ + { + blanks = 1 + } + ] + afterCurlyLambdaParams = squash +} + +danglingParentheses { + defnSite = true + callSite = true + ctrlSite = true + exclude = [] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 2 + newlineAfterOpenParen = true +} + +comments { + wrap = standalone +} + +docstrings { + style = "SpaceAsterisk" + oneline = unfold + wrap = yes + forceBlankLineBefore = true +} + +project { + excludePaths = [ + "glob:**target/**", + "glob:**.metals/**", + "glob:**.bloop/**", + "glob:**.bsp/**", + "glob:**metals.sbt", + "glob:**.git/**" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2b2735 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright Patrick Garrity + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..26e4e0c --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# gs-graph + +[GS Open Source](https://garrity.co/open-source.html) | +[License (MIT)](./LICENSE) + +Graph representations for Scala 3. + +- [Usage](#usage) + - [Dependency](#dependency) +- [Donate](#donate) + +## Usage + +### Dependency + +This artifact is available in the Garrity Software Maven repository. + +```scala +externalResolvers += + "Garrity Software Releases" at "https://maven.garrity.co/gs" + +val GsGraph: ModuleID = + "gs" %% "gs-graph-v0" % "$VERSION" +``` + +## Donate + +Enjoy this project or want to help me achieve my [goals](https://garrity.co)? +Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm). diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..56c7f9b --- /dev/null +++ b/build.sbt @@ -0,0 +1,46 @@ +val scala3: String = "3.7.4" + +ThisBuild / scalaVersion := scala3 +ThisBuild / versionScheme := Some("semver-spec") +ThisBuild / gsProjectName := "gs-graph" + +ThisBuild / externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +ThisBuild / licenses := Seq( + "MIT" -> url("https://git.garrity.co/garrity-software/gs-graph/LICENSE") +) + +val sharedSettings = Seq( + scalaVersion := scala3, + version := semVerSelected.value, + coverageFailOnMinimum := true + /* coverageMinimumStmtTotal := 100, coverageMinimumBranchTotal := 100 */ +) + +val Deps = new { + val Gs = new { + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" + } + + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1" +} + +lazy val testSettings = Seq( + libraryDependencies ++= Seq( + Deps.MUnit % Test, + Deps.Gs.Datagen % Test + ) +) + +lazy val `gs-graph` = project + .in(file(".")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") + .settings( + libraryDependencies ++= Seq( + ) + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..01a16ed --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.11.7 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..83e5dc1 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,33 @@ +def selectCredentials(): Credentials = + if ((Path.userHome / ".sbt" / ".credentials").exists()) + Credentials(Path.userHome / ".sbt" / ".credentials") + else + Credentials.apply( + realm = "Reposilite", + host = "maven.garrity.co", + userName = sys.env + .get("GS_MAVEN_USER") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable." + ) + ), + passwd = sys.env + .get("GS_MAVEN_TOKEN") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable." + ) + ) + ) + +credentials += selectCredentials() + +externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.1") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.6.0") +addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") diff --git a/src/main/scala/gs/graph/v0/Adjacency.scala b/src/main/scala/gs/graph/v0/Adjacency.scala new file mode 100644 index 0000000..98a4151 --- /dev/null +++ b/src/main/scala/gs/graph/v0/Adjacency.scala @@ -0,0 +1,103 @@ +package gs.graph.v0 + +import scala.collection.mutable.ListBuffer + +/** An immutable adjacency list representation. + * + * The index of the collection is the "from" [[Vertex]], and each element of + * the corresponding vector is a "to" [[Vertex]] -- there are edges _from_ some + * vertex _to_ another vertex. + */ +final class Adjacency(val neighbors: Vector[Vector[Vertex]]): + /** Get the vector of [[Vertex]] that receive a connection _from_ the input + * [[Vertex]]. + * + * @param vertex + * The [[Vertex]] to investigate. + * @return + * The vector of [[Vertex]] that receive a connection _from_ the input + * [[Vertex]]. + */ + def at(vertex: Vertex): Vector[Vertex] = neighbors(vertex.ordinal) + + /** Express this [[Adjacency]] as a vector of [[Edge]]. + * + * @return + * The vector of [[Edge]] derived from this [[Adjacency]]. + */ + def toEdges(): Vector[Edge] = + neighbors.zipWithIndex.flatMap { case (tos, index) => + val from = Vertex(index) + tos.map(to => Edge(from, to)).distinct + } + + /** @return + * The number of vertices represented by this adjacency list. + */ + def numberOfVertices: Size = Size.fromVector(neighbors) + + /** Perform a linear traversal for each [[gs.graph.v0.Vertex]] to calculate + * the total number of edges in this adjacency list. + * + * @return + * The number of edges in this adjacency list. + */ + def numberOfEdges: Size = Size(neighbors.map(_.length).reduce(_ + _)) + + def findRoots(): Vector[Vertex] = + val counts = Array.fill(neighbors.length)(0) + neighbors.foreach { ns => + // Each vertex listed here is receiving an inbound connection, if we + // assume the graph is directed. + ns.foreach(v => counts(v.ordinal) += 1) + } + counts.filter(_ == 0).map(Vertex(_)).toVector + + override def equals(that: Any): Boolean = + that match + case other: Adjacency => + neighbors.length == other.neighbors.length + && neighbors + .zip(other.neighbors) + .map { case (x, y) => x.sameElements(y) } + .forall(result => result) + case _ => false + +object Adjacency: + + /** Instantiate a new [[Adjacency]] list from a raw representation. + * + * @param adj + * The underlying vector of values. + * @return + * The new [[Adjacency]]. + */ + def apply(adj: Vector[Vector[Vertex]]): Adjacency = new Adjacency(adj) + + given CanEqual[Adjacency, Adjacency] = CanEqual.derived + + /** @return + * The empty [[Adjacency]] list. + */ + final val Empty: Adjacency = new Adjacency(Vector.empty) + + /** Calculate an [[Adjacency]] from some collection of [[Edge]]. + * + * @param numberOfVertices + * The number of [[Vertex]] (`N`) in this graph. + * @param edges + * The collection of [[Edge]] present in this graph. + * @return + * The calculated [[Adjacency]]. + */ + def fromDirectedEdges( + numberOfVertices: Size, + edges: Iterable[Edge] + ): Adjacency = + val buffs = Vector.fill(numberOfVertices.value)(ListBuffer.empty[Vertex]) + val _ = edges.foreach { edge => + val _ = buffs(edge.from.ordinal).addOne(edge.to) + } + new Adjacency(buffs.map(_.distinct.toVector)) + +end Adjacency diff --git a/src/main/scala/gs/graph/v0/DFS.scala b/src/main/scala/gs/graph/v0/DFS.scala new file mode 100644 index 0000000..3afb6f8 --- /dev/null +++ b/src/main/scala/gs/graph/v0/DFS.scala @@ -0,0 +1,144 @@ +package gs.graph.v0 + +import gs.graph.v0.data.DataDigraph +import scala.collection.mutable.ListBuffer +import scala.collection.mutable.Stack + +/** Depth-First Search (DFS) + */ +object DFS: + + /** Depth-first search that executes a side-effecting function on each + * [[Vertex]]. This function will operate on _any_ [[Graph]]. + * + * This implementation selects the first [[Vertex]] as an arbitrary starting + * point. + * + * @param graph + * The input [[Graph]] on which to run DFS. + * @param visit + * The visitor function. + */ + def dfs( + graph: Graph, + visit: Vertex => Unit + ): 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 + val _ = visit(v) + discovered(v.ordinal) = true + graph.neighbors(v).foreach(w => s.push(w)) + else () + () + + /** Depth-first search that executes a function on each [[Vertex]] to produce + * some output. This function will operate on _any_ [[Graph]]. + * + * This implementation selects the first [[Vertex]] as an arbitrary starting + * point. + * + * @param graph + * The input [[Graph]] on which to run DFS. + * @param visit + * The visitor function. + * @return + * List of output, assembled in the order that vertices were visited. + */ + def dfs[Out]( + graph: Graph, + visit: Vertex => Out + ): List[Out] = + val output = ListBuffer.empty[Out] + 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.toList + + /** 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]]. + * + * @param graph + * The input [[Graph]] on which to run DFS. + * @param visit + * The visitor function. + */ + def dfs[A]( + digraph: DataDigraph[A], + visit: (Vertex, A) => Unit + ): 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 + val _ = visit(v, digraph.data(v.ordinal)) + discovered(v.ordinal) = true + digraph.neighbors(v).foreach(w => s.push(w)) + else () + } + + /** Depth-first search that executes a function on each [[Vertex]] to produce + * some output, accepting the data stored for that [[Vertex]] as input. + * + * This implementation performs DFS for _each root_ in the input + * [[DataDigraph]]. + * + * @param graph + * The input [[Graph]] on which to run DFS. + * @param visit + * The visitor function. + * @return + * List of output, assembled in the order that vertices were visited. + */ + def dfs[A, Out]( + digraph: DataDigraph[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) + + 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.toList + +end DFS diff --git a/src/main/scala/gs/graph/v0/Edge.scala b/src/main/scala/gs/graph/v0/Edge.scala new file mode 100644 index 0000000..b77c316 --- /dev/null +++ b/src/main/scala/gs/graph/v0/Edge.scala @@ -0,0 +1,50 @@ +package gs.graph.v0 + +/** Represents a relationship between two [[Vertex]]. + * + * When used is a directed context, the edge goes _from_ `v1` _to_ `v2`. + * + * @param v1 + * The first [[Vertex]]. + * @param v2 + * The second [[Vertex]]. + */ +final class Edge( + val v1: Vertex, + val v2: Vertex +): + + /** When considering this edge as _directed_, this function returns the + * [[Vertex]] that is the beginning of the connection. + * + * @return + * The "from" [[Vertex]]. + */ + def from: Vertex = v1 + + /** When considering this edge as _directed_, this function returns the + * [[Vertex]] that is the destination of the connection. + * + * @return + * The "to" [[Vertex]]. + */ + def to: Vertex = v2 + + /** @inheritDocs + */ + override def toString(): String = s"($v1, $v2)" + + /** @inheritDocs + */ + override def equals(that: Any): Boolean = + that match + case other: Edge => v1 == other.v1 && v2 == other.v2 + case _ => false + +end Edge + +object Edge: + + given CanEqual[Edge, Edge] = CanEqual.derived + +end Edge diff --git a/src/main/scala/gs/graph/v0/Graph.scala b/src/main/scala/gs/graph/v0/Graph.scala new file mode 100644 index 0000000..52f1f40 --- /dev/null +++ b/src/main/scala/gs/graph/v0/Graph.scala @@ -0,0 +1,68 @@ +package gs.graph.v0 + +/** Graph representation based on an adjacency list. + * + * See: + * - [[gs.graph.v0.directed.DirectedGraph]] + * + * @param numberOfVertices + * The number of [[Vertex]] present in this Graph. + * @param adjacency + * The indexable [[Adjacency]] present in this Graph. + */ +trait Graph: + /** @return + * The [[Adjacency]] that describes this graph. + */ + def adjacency: Adjacency + + /** @return + * The disposition of this graph, for informational purposes. + */ + def disposition: GraphDisposition + + /** @return + * The number of [[Vertex]] present in this graph. + */ + def numberOfVertices: Size + + /** @return + * True if this graph has no vertices, false otherwise. + */ + def isEmpty: Boolean = numberOfVertices == Size.Zero + + /** Get the neighbors for any given vertex. + * + * @param vertex + * The vertex for which to retrieve neighbors. + * @return + * The vector of neighbors for the input vertex. + */ + def neighbors(vertex: Vertex): Vector[Vertex] = + if vertex < numberOfVertices then adjacency.at(vertex) + else Vector.empty + + /** @inheritDocs + */ + override def equals(that: Any): Boolean = + that match + case g: Graph => + numberOfVertices == g.numberOfVertices + && adjacency == g.adjacency + case _ => false + +end Graph + +object Graph: + + 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/src/main/scala/gs/graph/v0/GraphDisposition.scala b/src/main/scala/gs/graph/v0/GraphDisposition.scala new file mode 100644 index 0000000..d536194 --- /dev/null +++ b/src/main/scala/gs/graph/v0/GraphDisposition.scala @@ -0,0 +1,21 @@ +package gs.graph.v0 + +sealed abstract class GraphDisposition(val name: String): + + override def equals(that: Any): Boolean = + that match + case other: GraphDisposition => name == other.name + case _ => false + + override def hashCode(): Int = name.hashCode() + + override def toString(): String = name + +object GraphDisposition: + + given CanEqual[GraphDisposition, GraphDisposition] = CanEqual.derived + + case object Directed extends GraphDisposition("directed") + case object Undirected extends GraphDisposition("undirected") + +end GraphDisposition diff --git a/src/main/scala/gs/graph/v0/GraphError.scala b/src/main/scala/gs/graph/v0/GraphError.scala new file mode 100644 index 0000000..ad1d88a --- /dev/null +++ b/src/main/scala/gs/graph/v0/GraphError.scala @@ -0,0 +1,12 @@ +package gs.graph.v0 + +sealed abstract class GraphError(val name: String): + override def toString(): String = name + +object GraphError: + + /** Produced when validating a directed graph and no cycles are expected. + */ + case object CycleFound extends GraphError("cycle-found") + +end GraphError diff --git a/src/main/scala/gs/graph/v0/GraphException.scala b/src/main/scala/gs/graph/v0/GraphException.scala new file mode 100644 index 0000000..0f43543 --- /dev/null +++ b/src/main/scala/gs/graph/v0/GraphException.scala @@ -0,0 +1,34 @@ +package gs.graph.v0 + +sealed abstract class GraphException extends Throwable + +object GraphException: + + case class VertexExceedsGraphBounds( + vertex: Vertex, + bound: Size + ) extends GraphException: + + override def getMessage(): String = + s"Vertex '$vertex' exceeds graph bound of '$bound' vertices." + + case class DataGraphMismatch( + numberOfVertices: Size, + quantityOfData: Int + ) extends GraphException: + + override def getMessage(): String = + s"""Attempted to initialize a data graph with '$numberOfVertices' + vertices. '$quantityOfData' data values were provided.""".stripMargin + + case class RootOutOfBounds( + root: Vertex, + numberOfVertices: Size + ) extends GraphException: + + override def getMessage(): String = + s"""Attempted to instantiate a single-root digraph with root '$root', + but that root exceeds the graph bound of '$numberOfVertices' + vertices.""".stripMargin + +end GraphException diff --git a/src/main/scala/gs/graph/v0/Size.scala b/src/main/scala/gs/graph/v0/Size.scala new file mode 100644 index 0000000..fddb4c8 --- /dev/null +++ b/src/main/scala/gs/graph/v0/Size.scala @@ -0,0 +1,111 @@ +package gs.graph.v0 + +/** Represents a _size_ of 0 or more. + */ +final class Size private (val value: Int) extends Ordered[Size]: + + /** @inheritDocs + */ + override def compare(that: Size): Int = + value - that.value + + /** @inheritDocs + */ + override def equals(that: Any): Boolean = + that match + case other: Size => value == other.value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.toString() + + /** Is the value of this size less than some integer value? + * + * @param value + * The integer value. + * @return + * True if the value is less than the integer value. False otherwise. + */ + infix def <(value: Int): Boolean = this.value < value + + /** Is the value of this size greater than some integer value? + * + * @param value + * The integer value. + * @return + * True if the value is greater than the integer value. False otherwise. + */ + infix def >(value: Int): Boolean = this.value > value + + /** Is the value of this size less than some vertex ordinal? + * + * @param value + * The vertex ordinal. + * @return + * True if the value is less than the vertex ordinal. False otherwise. + */ + infix def <(vertex: Vertex): Boolean = value < vertex.ordinal + + /** Is the value of this size greater than some vertex ordinal? + * + * @param value + * The vertex ordinal. + * @return + * True if the value is greater than the vertex ordinal. False otherwise. + */ + infix def >(vertex: Vertex): Boolean = value > vertex.ordinal + +object Size: + + /** The constant size: 0. This is the lowest possible size value. + */ + final val Zero: Size = new Size(0) + + /** Instantiate a new [[Size]]. + * + * Throws an exception if a negative input is given. + * + * @param candidate + * The candidate [[Size]] value. + * @return + * The new [[Size]]. + */ + def apply(candidate: Int): Size = + if candidate >= 0 then new Size(candidate) + else throw new IllegalArgumentException("Size values must be 0 or greater.") + + given CanEqual[Size, Size] = CanEqual.derived + + given Ordering[Size] with + + /** @inheritDocs + */ + def compare( + x: Size, + y: Size + ): Int = x.value - y.value + + /** Instantiate the size of some array. + * + * @param arr + * The array. + * @return + * Size representing the length of the array. + */ + def fromArray(arr: Array[?]): Size = new Size(arr.length) + + /** Instantiate the size of some vector. + * + * @param vec + * The vector. + * @return + * Size representing the length of the vector. + */ + def fromVector(vec: Vector[?]): Size = new Size(vec.length) + +end Size diff --git a/src/main/scala/gs/graph/v0/Vertex.scala b/src/main/scala/gs/graph/v0/Vertex.scala new file mode 100644 index 0000000..ff65a11 --- /dev/null +++ b/src/main/scala/gs/graph/v0/Vertex.scala @@ -0,0 +1,98 @@ +package gs.graph.v0 + +/** Represents a single Vertex (aka Node) within a Graph. + * + * Vertexes are essentially array indices in this implementation and must be 0 + * or greater. They are arbitrary graph structure elements and do not directly + * reflect any stored data. + */ +final class Vertex private (val ordinal: Int) extends Ordered[Vertex]: + + /** @inheritDocs + */ + override def compare(that: Vertex): Int = + ordinal - that.ordinal + + /** @inheritDocs + */ + override def equals(that: Any): Boolean = + that match + case other: Vertex => ordinal == other.ordinal + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = ordinal.hashCode() + + /** @inheritDocs + */ + override def toString(): String = ordinal.toString() + + /** Is the ordinal of this vertex less than some integer value? + * + * @param value + * The integer value. + * @return + * True if the ordinal is less than the integer value. False otherwise. + */ + infix def <(value: Int): Boolean = ordinal < value + + /** Is the ordinal of this vertex greater than some integer value? + * + * @param value + * The integer value. + * @return + * True if the ordinal is greater than the integer value. False otherwise. + */ + infix def >(value: Int): Boolean = ordinal > value + + /** Is the ordinal of this vertex less than some [[Size]] value? + * + * @param value + * The [[Size]] value. + * @return + * True if the ordinal is less than the [[Size]] value. False otherwise. + */ + infix def <(size: Size): Boolean = ordinal < size.value + + /** Is the ordinal of this vertex greater than some [[Size]] value? + * + * @param value + * The [[Size]] value. + * @return + * True if the ordinal is greater than the [[Size]] value. False otherwise. + */ + infix def >(size: Size): Boolean = ordinal > size.value + +object Vertex: + + /** The fixed value 0 expressed as a [[Vertex]]. + */ + final val Zero: Vertex = new Vertex(0) + + /** Instantiate a new [[Vertex]]. + * + * Throws an `IllegalArgumentException` if the input is less than 0. + * + * @param candidate + * The candidate value. + * @return + * The new [[Vertex]]. + */ + def apply(candidate: Int): Vertex = + if candidate >= 0 then new Vertex(candidate) + else + throw new IllegalArgumentException("Vertex values must be 0 or greater.") + + given CanEqual[Vertex, Vertex] = CanEqual.derived + + given Ordering[Vertex] with + + /** @inheritDocs + */ + def compare( + x: Vertex, + y: Vertex + ): Int = x.ordinal - y.ordinal + +end Vertex diff --git a/src/main/scala/gs/graph/v0/dag/Dag.scala b/src/main/scala/gs/graph/v0/dag/Dag.scala new file mode 100644 index 0000000..74fcad7 --- /dev/null +++ b/src/main/scala/gs/graph/v0/dag/Dag.scala @@ -0,0 +1,36 @@ +package gs.graph.v0.dag + +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 ( + n: Size, + a: Adjacency, + r: Vector[Vertex] +) extends Digraph(n, a, r) + +object Dag: + + /** Validate that the input [[Digraph]] is a DAG. + * + * @param digraph + * The [[Digraph]] to validate. + * @return + * The DAG instance, or an error if the input contained a cycle. + */ + def validate(digraph: Digraph): Either[GraphError, Dag] = + if Digraph.hasCycle(digraph) then + Right( + new Dag(digraph.numberOfVertices, digraph.adjacency, digraph.roots) + ) + else Left(GraphError.CycleFound) + +end Dag diff --git a/src/main/scala/gs/graph/v0/data/DataDigraph.scala b/src/main/scala/gs/graph/v0/data/DataDigraph.scala new file mode 100644 index 0000000..986007c --- /dev/null +++ b/src/main/scala/gs/graph/v0/data/DataDigraph.scala @@ -0,0 +1,66 @@ +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.Digraph + +final class DataDigraph[A]( + val data: Vector[A], + n: Size, + a: Adjacency, + r: Vector[Vertex] +) extends Digraph(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 DataDigraph: + + def empty[A]: DataDigraph[A] = + new DataDigraph[A]( + Vector.empty, + Size.Zero, + Adjacency.Empty, + Vector.empty + ) + + def fromDigraph[A]( + digraph: Digraph, + data: Vector[A] + ): DataDigraph[A] = + if digraph.numberOfVertices != Size.fromVector(data) then + throw GraphException.DataGraphMismatch( + digraph.numberOfVertices, + data.length + ) + else + new DataDigraph( + data, + digraph.numberOfVertices, + digraph.adjacency, + digraph.roots + ) diff --git a/src/main/scala/gs/graph/v0/directed/Digraph.scala b/src/main/scala/gs/graph/v0/directed/Digraph.scala new file mode 100644 index 0000000..ad7ceba --- /dev/null +++ b/src/main/scala/gs/graph/v0/directed/Digraph.scala @@ -0,0 +1,128 @@ +package gs.graph.v0.directed + +import gs.graph.v0.Adjacency +import gs.graph.v0.Edge +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]]. + * + * @param numberOfVertices + * The number of [[Vertex]] present in this graph. + * @param adjacency + * The [[Adjacency]] that describes this graph. + * @param roots + * Complete collection of root vertices. + */ +class Digraph( + val numberOfVertices: Size, + val adjacency: Adjacency, + val roots: Vector[Vertex] +) extends Graph: + override val disposition: GraphDisposition = GraphDisposition.Directed + +object Digraph: + + def fromEdges( + numberOfVertices: Size, + edges: Iterable[Edge] + ): Digraph = + new Digraph( + numberOfVertices = numberOfVertices, + adjacency = Adjacency.fromDirectedEdges(numberOfVertices, edges), + roots = findRootsForDirectedEdges(numberOfVertices, edges) + ) + + /** Instantiate a new [[Graph]] from the given [[Adjacency]]. + * + * @param adjacency + * The [[Adjacency]] that represents the [[Graph]]. + * @return + * The new [[Graph]] instance. + */ + def fromAdjacency( + adjacency: Adjacency + ): Digraph = + new Digraph( + numberOfVertices = adjacency.numberOfVertices, + adjacency = adjacency, + roots = adjacency.findRoots() + ) + + /** Find all roots for the given collection of [[Edge]]. + * + * This function assumes directed edges. + * + * @param numberOfVertices + * The number of vertices in the graph represented by the given edges. + * @param edges + * The collection of edges to analyze. + * @return + * The vector of roots for the given edges. + */ + def findRootsForDirectedEdges( + numberOfVertices: Size, + edges: Iterable[Edge] + ): Vector[Vertex] = + // Count of incoming relationships. + val counts = Array.fill(numberOfVertices.value)(0) + edges.foreach(edge => counts(edge.to.ordinal) += 1) + + // If there are any vertices with no incoming connections, those are roots. + // Note that these may be completely disconnected. + counts.filter(_ == 0).map(Vertex(_)).toVector + + /** Determine whether the given [[Digraph]] has any cycles. + * + * See: [[Dag]] + * + * @param digraph + * The graph to check for cycles. + * @return + * True if the input graph has any cycles, false otherwise. + */ + def hasCycle(digraph: Digraph): Boolean = + digraph match + case _: Dag => false + case _ => new CycleDetector(digraph).hasCycle + + // Internal class for finding cycles. + // This implementation comes (roughly) from Algorithms (Fourth Edition) by + // Robert Sedgewick and Kevin Wayne. + final private class CycleDetector( + digraph: Digraph + ): + val N: Int = digraph.numberOfVertices.value + val marked: Array[Boolean] = Array.fill(N)(false) + val active: Array[Boolean] = Array.fill(N)(false) + var result: Boolean = false + + lazy val hasCycle: Boolean = { + (0 until N).foreach(v => dfs(digraph, Vertex(v))) + result + } + + private def dfs( + digraph: Digraph, + v: Vertex + ): Unit = + active(v.ordinal) = true + marked(v.ordinal) = true + + digraph.neighbors(v).foreach { w => + if result then () + else if !marked(w.ordinal) then + val _ = dfs(digraph, w) + else if active(w.ordinal) then result = true + else () + } + + active(v.ordinal) = false + () + + end CycleDetector + +end Digraph diff --git a/src/main/scala/gs/graph/v0/directed/SCC.scala b/src/main/scala/gs/graph/v0/directed/SCC.scala new file mode 100644 index 0000000..ce46957 --- /dev/null +++ b/src/main/scala/gs/graph/v0/directed/SCC.scala @@ -0,0 +1,147 @@ +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 + +/** Represents a _Strongly Connected Component_ (SCC). + */ +opaque type SCC = Vector[Vertex] + +object SCC: + + /** Instantiate a new SCC from the given collection of [[Vertex]]. + * + * This constructor ensures that each [[Vertex]] only occurs once. + * + * @param value + * The input collection. + * @return + * The new SCC. + */ + def apply(value: Seq[Vertex]): SCC = value.distinct.toVector + + given CanEqual[SCC, SCC] = CanEqual.derived + + extension (scc: SCC) + /** @return + * The underlying value of this SCC. + */ + def unwrap(): Vector[Vertex] = scc + + /** @return + * The vertices contained by this SCC. + */ + def vertices: Vector[Vertex] = scc + + /** @return + * The number of vertices contained by this SCC. + */ + def size: Int = scc.size + + /** @return + * True if this SCC contains a single vertex. + */ + def isSingle: Boolean = scc.size == 1 + + /** Implementation of Tarjan's Algorithm for finding all strongly connected + * components for some directed graph. + * + * @param g + * The directed graph to analyze. + * @return + * The complete list of [[SCC]] for the input graph. + */ + def findAll(g: Graph): List[SCC] = + val V = g.numberOfVertices.value + + // Vertex numbers. + val num = Array.fill(V)(-1) + + // Array holding the minimum reachable vertex numbers. `lowest(v)` is the + // minimum number of a vertex reachable from `v`. + val lowest = Array.fill(V)(-1) + + // For each vertex, indicates whether that vertex has been visited. + val visited = Array.fill(V)(false) + + // For each vertex, indicates whether that vertex has been processed. + val processed = Array.fill(V)(false) + + // Stack that holds the working set of vertices. S holds all vertices + // reachable from the starting vertex. When an SCC is found, the stack is + // unwound to get each element of that SCC. + val s = Stack.empty[Vertex] + + // Counter -- provides sequential numbers to the vertices. + val counter = new Counter(0) + + // List of output SCCs. + val sccs = ListBuffer.empty[SCC] + + (0 until V).foreach { v => + if !visited(v) then + tarjanDfs(g, v, num, lowest, visited, processed, s, counter, sccs) + () + else () + } + + sccs.toList + + private def tarjanDfs( + g: Graph, + v: Int, + num: Array[Int], + lowest: Array[Int], + visited: Array[Boolean], + processed: Array[Boolean], + s: Stack[Vertex], + counter: Counter, + output: ListBuffer[SCC] + ): Unit = + // Convenience integer representation for array access. + val V = Vertex(v) + + num(v) = counter.get() + lowest(v) = num(v) + val _ = counter.increment() + visited(v) = true + s.push(V) + + // Traverse the neighbors of the current vertex. + g.neighbors(V).foreach { neighbor => + // Convenience integer representation for array access. + val n = neighbor.ordinal + + if !visited(n) then + tarjanDfs(g, n, num, lowest, visited, processed, s, counter, output) + lowest(v) = Math.min(lowest(v), lowest(n)) + else if !processed(n) then lowest(v) = Math.min(lowest(v), num(n)) + else () + } + + // The current vertex is now processed. + processed(v) = true + + if lowest(v) == num(v) then + val scc = ListBuffer.empty[Vertex] + var sccVertex = s.pop() + + while sccVertex != V + do + scc.addOne(sccVertex) + sccVertex = s.pop() + + scc.addOne(sccVertex) + output.addOne(SCC(scc.toVector)) + else () + + final private class Counter(var i: Int = 0): + + def increment(): Unit = + i += 1 + + def get(): Int = i + +end SCC diff --git a/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala b/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala new file mode 100644 index 0000000..12b73ef --- /dev/null +++ b/src/main/scala/gs/graph/v0/directed/SingleRootDirectedGraph.scala @@ -0,0 +1,82 @@ +package gs.graph.v0.directed + +import gs.graph.v0.Adjacency +import gs.graph.v0.Edge +import gs.graph.v0.GraphException +import gs.graph.v0.Size +import gs.graph.v0.Vertex + +class SingleRootDirectedGraph( + n: Size, + a: Adjacency, + r: Vertex +) extends Digraph(n, a, Vector(r)) + +object SingleRootDirectedGraph: + + def fromDirectedGraph(dg: Digraph): Option[SingleRootDirectedGraph] = + if dg.roots.size == 1 then + Some( + new SingleRootDirectedGraph( + dg.numberOfVertices, + dg.adjacency, + dg.roots(0) + ) + ) + else None + + def fromEdgesUnsafe( + numberOfVertices: Size, + edges: Iterable[Edge], + root: Vertex + ): SingleRootDirectedGraph = + if root < numberOfVertices then + new SingleRootDirectedGraph( + numberOfVertices, + Adjacency.fromDirectedEdges(numberOfVertices, edges), + root + ) + else throw GraphException.RootOutOfBounds(root, numberOfVertices) + + def fromEdges( + numberOfVertices: Size, + edges: Iterable[Edge] + ): Option[SingleRootDirectedGraph] = + val roots = Digraph.findRootsForDirectedEdges(numberOfVertices, edges) + if roots.size == 1 then + Some( + new SingleRootDirectedGraph( + numberOfVertices, + Adjacency.fromDirectedEdges(numberOfVertices, edges), + roots(0) + ) + ) + else None + + def fromAdjacencyUnsafe( + adjacency: Adjacency, + root: Vertex + ): SingleRootDirectedGraph = + if root < adjacency.numberOfVertices then + new SingleRootDirectedGraph( + adjacency.numberOfVertices, + adjacency, + root + ) + else throw GraphException.RootOutOfBounds(root, adjacency.numberOfVertices) + + def fromAdjacency( + adjacency: Adjacency + ): Option[SingleRootDirectedGraph] = + val roots = adjacency.findRoots() + if roots.size == 1 then + Some( + new SingleRootDirectedGraph( + adjacency.numberOfVertices, + adjacency, + roots(0) + ) + ) + else None + +end SingleRootDirectedGraph