Initial implementation.
All checks were successful
/ Build and Release Library (push) Successful in 1m19s

This commit is contained in:
Pat Garrity 2025-12-08 22:26:20 -06:00
commit 7b1c110c75
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
24 changed files with 1463 additions and 0 deletions

View file

@ -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

View file

@ -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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
target/
project/target/
project/project/
.version
.scala-build/

17
.pre-commit-config.yaml Normal file
View file

@ -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

72
.scalafmt.conf Normal file
View file

@ -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/**"
]
}

9
LICENSE Normal file
View file

@ -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.

29
README.md Normal file
View file

@ -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).

46
build.sbt Normal file
View file

@ -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(
)
)

1
project/build.properties Normal file
View file

@ -0,0 +1 @@
sbt.version=1.11.7

33
project/plugins.sbt Normal file
View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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

View file

@ -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