alga

Algebraic graphs for MoonBit — directed graph trait and algorithm library inspired by Haskell's alga

graph
directed-graph
algebraic-graphs
algorithm
trait
moon add dowdiness/alga@0.4.0
Download zip
Author
Version
0.4.0
License
Apache-2.0
Last updated
last month
Downloads
36K

Dependencies

README

#alga

A directed graph library for MoonBit with trait-generic algorithms and algebraic construction. Inspired by Haskell's algebraic-graphs.

Implement two methods (iter, successors) on your data structure and get DFS, BFS, topological sort, cycle detection, strongly connected components, edge classification, and reverse traversal — all with O(V+E) complexity.

#Quick start

moon add dowdiness/alga

// Build a graph: 1 → 2 → 3 → 4
let g = @alga.AdjacencyMap::from_edges([(1, 2), (2, 3), (3, 4)])

// Traverse
let r = @alga.reachable(g, 1) // [1, 2, 3, 4]
let order = @alga.toposort(g) // Some([1, 2, 3, 4])
let cyclic = @alga.has_cycle(g) // false

// Strongly connected components
let sccs = @alga.tarjan_scc(g) // [[4], [3], [2], [1]]

// DFS with edge classification
for event in @alga.dfs_events(g) {
match event {
@alga.BackEdge(u, v) => println("cycle: \{u}\{v}")
_ => ()
}
}

// Reverse traversal: "what can reach vertex 4?"
let ancestors = @alga.reachable(@alga.reversed(g), 4) // [4, 3, 2, 1]

// Algebraic construction
let expr = @alga.Graph::path([1, 2, 3])
let am = expr.to_adjacency_map()

#Why algebraic graphs?

Most graph libraries make you manage mutable adjacency lists by hand. Alga takes a different approach based on Mokhov (2017): four operations — empty, vertex, overlay, connect — form an algebra with eight axioms that guarantee well-formed graphs by construction. You can't create a dangling edge or an inconsistent state.

The library separates observing a graph (the DirectedGraph trait) from building one (the GraphSym trait and Graph enum). Algorithms are written against the observation trait, so they work on any data structure that implements it — including your own.

#How it works

Observation layer Construction layer (DirectedGraph + Predecessors) (GraphSym + Graph enum) ┌─────────────────┐ ┌──────────────────┐ │ DirectedGraph │ │ GraphSym │ │ iter │ │ empty, vertex │ │ successors │ │ overlay, connect│ │ (+ 4 defaults) │ └──────┬───────────┘ ├─────────────────┤ │ │ Predecessors │ ┌────┴────┐ │ predecessors │ bridge: │ Graph │ └──────┬──────────┘ foldg │ (enum) │ │ └──────────┘ ┌─────┴─────┐ ┌────────────┐ │AdjacencyMap│ │ DenseGraph │ └────────────┘ └────────────┘

Observation layer. The DirectedGraph trait requires two methods: iter() returns all vertices, successors(v) returns outgoing neighbors. Four more methods (each_vertex, each_successor, vertex_count, has_vertex) are defaulted. Every generic algorithm in the library is bounded by this trait.

The Predecessors trait adds one method — predecessors(v) — for types that store reverse adjacency. Both built-in representations implement it, enabling the zero-cost Reversed[G] adaptor for reverse-direction traversal.

Construction layer. The Graph enum represents graph expressions as a syntax tree (Empty | Vertex | Overlay | Connect). You can transform the tree (gmap, bind, induce) before evaluating it into an AdjacencyMap via foldg.

Two representations:

  • AdjacencyMapMap[Int, Array[Int]] with bidirectional storage. Handles sparse, non-contiguous vertex IDs. Supports algebraic operations. O(log V) successor lookup.
  • DenseGraphArray[Array[Int]] with bidirectional storage. Requires vertex IDs in 0..n-1. O(1) successor lookup. 8–23x faster than AdjacencyMap for traversals.

Both store forward and reverse adjacency lists, making transpose() an O(1) field swap.

#Algorithms

Every algorithm below is generic over DirectedGraph unless noted otherwise.

AlgorithmFunctionTime
DFS folddfs_fold(g, start, init, f)O(V+E)
BFS foldbfs_fold(g, start, init, f)O(V+E)
Multi-source DFS/BFSdfs_fold_multi, bfs_fold_multiO(V+E)
Reachability (all)reachable(g, v)O(V+E)
Reachability (query)is_reachable(g, from, to)O(V'+E') early-exit
DFS edge classificationdfs_events(g)O(V+E)
Topological sorttoposort(g)O(V+E)
Topological sort + cycle witnesstoposort_or_cycle(g)O(V+E)
Topological levelstopo_levels(g)O(V+E)
Cycle detectionhas_cycle(g)O(V+E)
Cycle extractionfind_cycle(g)O(V+E)
Cycle predictionwould_create_cycle(g, u, v)O(V'+E') early-exit
SCC (Tarjan)tarjan_scc(g)O(V+E)
SCC (Kosaraju)kosaraju_scc(g)O(V+E)
Condensationg.condensation()O(V+E)
Reversed viewreversed(g)O(1)
Degree queriesoutdegree(g, v), indegree(g, v)O(deg) / O(V+E)
Trait conformance checkcheck_conformance(g), check_predecessors_conformance(g)O(V+E)

kosaraju_scc and reversed require the Predecessors trait; condensation is AdjacencyMap-specific because it constructs a new graph. dfs_events returns a lazy Iter[DfsEvent] that classifies each edge as tree, back, or cross/forward — useful for cycle detection, post-order traversal, and scope analysis. toposort_or_cycle returns Result[order, cycle] so callers can diagnose the cycle, not just detect one. would_create_cycle(g, u, v) predicts whether adding edge u→v would create a cycle without constructing the hypothetical graph.

#Using your own graph type

Implement two methods and every algorithm works:

struct MyGraph { edges : Array[Array[Int]] }

impl @alga.DirectedGraph for MyGraph with iter(self) {
(0).until(self.edges.length())
}

impl @alga.DirectedGraph for MyGraph with successors(self, v) {
self.edges[v].iter()
}

// Now available: toposort(g), tarjan_scc(g), reachable(g, 0),
// has_cycle(g), dfs_events(g), dfs_fold(g, ...), bfs_fold(g, ...), etc.

Override vertex_count and has_vertex for O(1) if your type supports it. Implement Predecessors to enable reversed(g) and kosaraju_scc(g).

Verify your impl with the conformance kit in your tests:

test "MyGraph conforms" {
let g : MyGraph = make_test_graph()
assert_eq(@alga.check_conformance(g), [])
// assert_eq(@alga.check_predecessors_conformance(g), []) // if MyGraph impls Predecessors
}

The kit catches contract violations (iter() duplicates, dangling successors, has_vertex inconsistency, predecessor/successor asymmetry) that algorithms would otherwise silently misbehave on.

#Graph construction

Build graphs algebraically using the Graph enum:

CombinatorResult
Graph::vertices([1, 2, 3])Three isolated vertices
Graph::edges([(1,2), (3,4)])Edges 1→2, 3→4
Graph::path([1, 2, 3])Chain 1→2→3
Graph::circuit([1, 2, 3])Loop 1→2→3→1
Graph::clique([1, 2, 3])Complete: 1→2, 1→3, 2→3
Graph::star(1, [2, 3, 4])Hub: 1→2, 1→3, 1→4

Evaluate with expr.to_adjacency_map() when ready to run algorithms.

#Design notes

Fixed vertex type. Vertices are Int. MoonBit traits don't support type parameters or associated types, so the vertex type is fixed. Map your domain IDs to Int at the boundary.

Pull-based iteration. iter and successors return Iter[Int] — MoonBit's external iterator. This enables pause/resume (Tarjan SCC suspends successor iteration mid-traversal), early termination (has_vertex short-circuits), and lazy composition.

Iterative algorithms. DFS and SCC use explicit stacks, not recursion. Tested up to 10,000+ vertices without stack overflow.

Property-tested laws. All 8 algebraic graph axioms from Mokhov (2017) are verified with moonbitlang/quickcheck — 100 random graph expressions per law with automatic shrinking. 272 tests total.

#Repository layout

src/ traits.mbt DirectedGraph, Predecessors traits adjacency_map.mbt AdjacencyMap — sparse representation dense_graph.mbt DenseGraph — dense, high-performance representation reversed.mbt Reversed[G] — zero-cost reverse-direction adaptor graph_expr.mbt Graph enum — algebraic expression tree + foldg graph_sym.mbt GraphSym trait — algebraic construction interface dfs.mbt DFS fold, DFS events (edge classification) bfs.mbt BFS fold toposort.mbt Topological sort, topo levels, cycle detection scc.mbt Kosaraju SCC, Tarjan SCC, condensation degree.mbt Indegree, outdegree docs/ philosophy.md Why algebraic graphs, the axiom system, design principles architecture.md Two-layer design, trait system, algorithm details TODO.md Active backlog

#Further reading

#License

Apache-2.0

#
DirectedGraph

pub(open) trait DirectedGraph : VertexSet + Successors {
}

Convenience trait: VertexSet + Successors.

Use this bound when an algorithm needs both vertex enumeration AND edge traversal. Most algorithms (toposort, SCC, dfs_events) require DirectedGraph. Local reachability and BFS/DFS from a known start only need Successors.

Implementors: implement VertexSet and Successors separately, then add impl DirectedGraph for YourType (no methods needed).

#
GraphSym

pub(open) trait GraphSym {
fn empty() -> Self
fn vertex(Int) -> Self
fn overlay(Self, Self) -> Self
fn connect(Self, Self) -> Self
}

GraphSym — the construction layer (Finally Tagless style)

While DirectedGraph lets you observe a graph (query vertices, traverse edges), GraphSym lets you construct a graph using four algebraic operations. This is the "Finally Tagless" encoding — instead of building a syntax tree and interpreting it, you write graph expressions that are polymorphic over the representation.

The four operations

  • empty() — the graph with no vertices and no edges
  • vertex(v) — a single isolated vertex
  • overlay(a, b) — union of vertices and edges from both graphs
  • connect(a, b) — overlay + edges from every a-vertex to every b-vertex

Algebraic laws (from Mokhov 2017)

These operations satisfy the axioms of an algebraic graph:

  • overlay is commutative: overlay(a, b) = overlay(b, a)
  • overlay is associative: overlay(overlay(a, b), c) = overlay(a, overlay(b, c))
  • overlay has identity: overlay(empty, a) = a
  • connect is associative: connect(connect(a, b), c) = connect(a, connect(b, c))
  • connect has identity: connect(empty, a) = a
  • connect distributes over overlay (left and right)
  • connect(a, b) implies overlay(a, b) (decomposition)

Why "Finally Tagless"?

In the "initial" (tagged) encoding, you build a Graph enum (see graph_expr.mbt) and interpret it later. In the "final" (tagless) encoding, the graph expression IS the interpretation — the trait methods directly produce the result type.

Both AdjacencyMap and Graph implement GraphSym, but they produce different things: AdjacencyMap produces an efficient representation directly, while Graph produces a syntax tree that can be transformed before interpretation.

Reference

Andrey Mokhov, "Algebraic Graphs with Class" (Haskell Symposium, 2017) https://dl.acm.org/doi/10.1145/3122955.3122956

#
Predecessors

pub(open) trait Predecessors {
fn predecessors(Self, Int) -> Iter[Int]
}

Types that can efficiently answer "which vertices point to v?" implement this trait alongside DirectedGraph. No default implementation is provided — an O(V+E) scan default would be a performance trap.

Used by Reversed[G] to swap successors and predecessors.

#
Successors

pub(open) trait Successors {
fn successors(Self, Int) -> Iter[Int]
fn[Acc] dfs_fold(Self, Int, Acc, (Acc, Int) -> (Acc, Bool)) -> Acc = _
fn[Acc] bfs_fold(Self, Int, Acc, (Acc, Int) -> (Acc, Bool)) -> Acc = _
fn each_successor(Self, Int, (Int) -> Unit) -> Unit = _
}

Types that can follow edges from a vertex.

Required method: successors(v) -> Iter[Int]. dfs_fold and bfs_fold have defaults using each_successor with a Map[Int, Bool] visited set; override for faster visited-set tracking (e.g. FixedArray[Bool] for dense IDs).

#
VertexSet

pub(open) trait VertexSet {
fn iter(Self) -> Iter[Int]
fn each_vertex(Self, (Int) -> Unit) -> Unit = _
fn vertex_count(Self) -> Int = _
fn has_vertex(Self, Int) -> Bool = _
}

Types that can enumerate their vertices.

Required method: iter() -> Iter[Int]. All others have O(V) defaults derived from iter.

#
AdjacencyMap

pub struct AdjacencyMap {
adjacency : Map[Int, Array[Int]]
predecessors : Map[Int, Array[Int]]
}

AdjacencyMap — the canonical graph representation

Stores a directed graph as Map[Int, Array[Int]] where each key is a vertex and its value is the list of successor vertices. This is the standard "adjacency list" representation used in most graph textbooks.

Key invariant

Every vertex that appears as an edge target also exists as a key in the map (with an empty successor list if it has no outgoing edges). This means adjacency.keys() == vertex_set — you never have a "dangling" edge pointing to a vertex that doesn't exist in the map.

Algebraic graph operations

AdjacencyMap supports the four algebraic graph operations from Mokhov's "Algebraic Graphs with Class" (2017):

  • empty() — the empty graph (no vertices, no edges)
  • vertex(v) — a single vertex with no edges
  • overlay(g1, g2) — union of vertices and edges
  • connect(g1, g2) — overlay + all edges from g1's vertices to g2's

These satisfy the algebraic graph axioms (overlay is commutative and associative, connect is associative, connect distributes over overlay).

Performance characteristics

OperationTime complexity
from_edgesO(E) amortized
has_vertexO(1)
has_edgeO(degree)
overlayO(V + E)
connectO(V1 * V2 + E)
transposeO(1)
vertex_countO(1)
edge_countO(V)
impl Eq for AdjacencyMap

#
AdjacencyMap::condensation

fn AdjacencyMap::condensation(self : AdjacencyMap) -> (AdjacencyMap, Map[Int, Int])

Condensation — DAG of strongly connected components

Condensation collapses each strongly connected component (SCC) into a single vertex, producing a directed acyclic graph (DAG). The result is always a DAG because any cycle in the condensed graph would imply that the involved components should have been merged into a single SCC.

Returns

A tuple (condensed_dag, vertex_to_component):

  • condensed_dag: An AdjacencyMap whose vertices are component IDs 0..k-1, where k is the number of SCCs. There is an edge from component i to component j if and only if some vertex in component i has an edge to some vertex in component j in the original graph.

  • vertex_to_component: A Map[Int, Int] mapping each original vertex to its component ID. Component IDs correspond to the indices of the arrays returned by scc() (Kosaraju's reverse-finish order).

Use cases

Condensation reduces cyclic graphs to DAGs, enabling topological analysis (toposort, longest path, dependency ordering) on graphs that would otherwise contain cycles. For example, in a module dependency graph, mutually-recursive modules form SCCs that can be collapsed to reason about the overall build order.

Time: O(V + E). Space: O(V + E).

#
AdjacencyMap::connect

fn AdjacencyMap::connect(self : AdjacencyMap, other : AdjacencyMap) -> AdjacencyMap

Connect: overlay + all cross-edges from self's vertices to other's.

connect(G1, G2) contains everything from overlay(G1, G2) plus a directed edge from every vertex in G1 to every vertex in G2. Connect is associative but NOT commutative.

This is the key operation that makes algebraic graph construction possible — connect(vertex(1), vertex(2)) creates the edge 1 -> 2.

Uses HashSet internally for O(1) duplicate edge detection.

#
AdjacencyMap::edge

fn AdjacencyMap::edge(u : Int, v : Int) -> AdjacencyMap

A graph with a single directed edge u -> v. Both u and v are added as vertices (maintaining the key invariant).

#
AdjacencyMap::edge_count

fn AdjacencyMap::edge_count(self : AdjacencyMap) -> Int

Count all edges. O(V) — sums successor list lengths.

#
AdjacencyMap::edge_list

fn AdjacencyMap::edge_list(self : AdjacencyMap) -> Array[(Int, Int)]

All edges as (source, target) pairs.

#
AdjacencyMap::empty

fn AdjacencyMap::empty() -> AdjacencyMap

The empty graph: no vertices, no edges. Identity element for both overlay and connect.

#
AdjacencyMap::from_edges

fn AdjacencyMap::from_edges(edges : Array[(Int, Int)]) -> AdjacencyMap

Build a graph from an array of directed edges. Duplicate edges are silently ignored. All endpoints are registered as vertices (maintaining the key invariant).

Uses HashSet internally for O(1) duplicate edge detection.

#
AdjacencyMap::has_edge

fn AdjacencyMap::has_edge(self : AdjacencyMap, u : Int, v : Int) -> Bool

#
AdjacencyMap::overlay

fn AdjacencyMap::overlay(self : AdjacencyMap, other : AdjacencyMap) -> AdjacencyMap

Overlay: union of two graphs' vertices and edges.

overlay(G1, G2) contains every vertex and edge from both G1 and G2. This is the graph analogue of set union. Overlay is commutative and associative: overlay(a, b) == overlay(b, a).

Uses HashSet internally for O(1) duplicate edge detection.

#
AdjacencyMap::remove_self_loops

fn AdjacencyMap::remove_self_loops(self : AdjacencyMap) -> AdjacencyMap

Remove all self-loops (edges v -> v) from the graph. Returns a new graph with the same vertex set but no self-loops.

#
AdjacencyMap::scc

fn AdjacencyMap::scc(self : AdjacencyMap) -> Array[Array[Int]]

Compute all strongly connected components.

Thin wrapper over the generic kosaraju_scc — see its doc comment for ordering and complexity guarantees.

#
AdjacencyMap::successor_list

fn AdjacencyMap::successor_list(self : AdjacencyMap, v : Int) -> Array[Int]

Successor vertices of v (vertices reachable by one edge from v).

#
AdjacencyMap::transpose

fn AdjacencyMap::transpose(self : AdjacencyMap) -> AdjacencyMap

Reverse all edge directions. O(1) — swaps forward and reverse maps.

The original and transposed graph share underlying map data (shallow copy). This is safe because AdjacencyMap is treated as immutable after construction.

#
AdjacencyMap::vertex

fn AdjacencyMap::vertex(v : Int) -> AdjacencyMap

A graph with a single vertex and no edges.

#
AdjacencyMap::vertex_list

fn AdjacencyMap::vertex_list(self : AdjacencyMap) -> Array[Int]

All vertices as an array.

#
DenseGraph

pub struct DenseGraph {
successors : Array[Array[Int]]
predecessors : Array[Array[Int]]
}

DenseGraph — flat adjacency representation for dense vertex IDs

Stores a directed graph as Array[Array[Int]] where vertex v's successors are at successors[v]. Requires dense vertex IDs in range 0..vertex_count-1.

When to use

Use DenseGraph when:
  • Vertex IDs are integers in 0..n-1 (common for internal algorithms)
  • You need maximum traversal performance (8–23x faster than AdjacencyMap)
  • You're running algorithms repeatedly on the same graph

Use AdjacencyMap when:
  • Vertex IDs are sparse or non-contiguous
  • You need algebraic graph operations (overlay, connect)
  • You're building graphs via the GraphSym construction layer

Conversion

AdjacencyMapDenseGraph via DenseGraph::from_adjacency_map (requires dense vertex IDs starting from 0).

Performance characteristics

OperationAdjacencyMapDenseGraph
successor lookupO(log V) (Map)O(1) (Array)
transposeO(1)O(1)
DFS reachable~94 µs (1000V)~7 µs (1000V)
SCC~460 µs (1000V)~50 µs (1000V)
impl Show for DenseGraph
impl Debug for DenseGraph

#
DenseGraph::dfs_events

fn DenseGraph::dfs_events(self : DenseGraph) -> Iter[DfsEvent]

DFS event iterator — classifies every edge and emits vertex enter/exit events.

Uses FixedArray[Int] (0=white, 1=gray, 2=black) for O(1) vertex state lookups instead of the generic Map[Int, Bool] (O(log V)). Same event ordering as @alga.dfs_events for any DirectedGraph.

Returns a lazy Iter[DfsEvent]. Each .next() call advances the DFS by one step and returns the next event. Handles disconnected components by iterating all vertices 0..n-1.

Time: O(V + E). Space: O(V).

#
DenseGraph::from_adjacency_map

fn DenseGraph::from_adjacency_map(am : AdjacencyMap) -> DenseGraph

Convert from AdjacencyMap.

Aborts if the AdjacencyMap contains vertex IDs outside 0..n-1 (where n is the vertex count). Use only with AdjacencyMaps that have dense, zero-based vertex IDs.

#
DenseGraph::from_edges

fn DenseGraph::from_edges(vertex_count : Int, edges : Array[(Int, Int)]) -> DenseGraph

Build from edge list. Duplicate edges are silently ignored.

Aborts if any vertex in the edge list is outside 0..vertex_count-1.

#
DenseGraph::has_cycle

fn DenseGraph::has_cycle(self : DenseGraph) -> Bool

#
DenseGraph::is_reachable_gen

fn DenseGraph::is_reachable_gen(self : DenseGraph, from : Int, to : Int, marks : FixedArray[Int], gen : Int) -> Bool

Early-exit reachability query using a reusable generation-counter buffer.

Like @alga.is_reachable but eliminates per-call FixedArray[Bool] allocation + zeroing by reusing marks: FixedArray[Int] across calls. Each call consumes one generation token — increment gen between calls.

Stops as soon as to is discovered without materializing the full reachable set.

Contract

  • marks.length() must be ≥ the graph's vertex count. Aborts otherwise.
  • gen must be ≠ 0 (0 is the initial blank state). Start at gen = 1 and increment between calls.
  • Caller is responsible for resetting the buffer before gen wraps (see reachable_gen doc for overflow guidance).

Returns false if from is not a valid vertex.

Time: O(V' + E') where V'/E' are visited before encountering to. ~1.2× faster than repeated DenseGraph::reachable on deep graphs.

#
DenseGraph::reachable

fn DenseGraph::reachable(self : DenseGraph, start : Int) -> Array[Int]

DFS reachable with direct array access. No trait dispatch, no callbacks, no per-vertex allocation. Allocates a fresh visited set per call.

Returns empty array if start is not a valid vertex.

#
DenseGraph::reachable_gen

fn DenseGraph::reachable_gen(self : DenseGraph, start : Int, marks : FixedArray[Int], gen : Int) -> Array[Int]

All vertices reachable from start using a reusable generation-counter buffer.

Like DenseGraph::reachable but eliminates per-call FixedArray[Bool] allocation + zeroing by reusing marks: FixedArray[Int] across calls. Each call consumes one generation token — increment gen between calls.

Contract

  • marks.length() must be ≥ the graph's vertex count. Aborts otherwise.
  • gen must be ≠ 0 (0 is the initial blank state). Start at gen = 1 and increment between calls.
  • No entry in marks[0..<vertex_count] may equal gen before the call. The caller ensures this by monotonically incrementing gen.

Generation overflow

In wasm32, Int wraps at 2^31. At 1M calls/second, wrap occurs in ~35 minutes. Long-running callers should periodically reset:

gen = 1 for i = 0; i < n; i = i + 1 { marks[i] = 0 }

Returns empty array if start is not a valid vertex.

Time: O(V + E) reachable subgraph. ~1.2× faster than repeated DenseGraph::reachable on deep graphs (chain_1000, wasm-gc --release).

#
DenseGraph::scc

fn DenseGraph::scc(self : DenseGraph) -> Array[Array[Int]]

Strongly connected components via Kosaraju's algorithm. Both DFS passes and transpose use direct array access.

#
DenseGraph::toposort

fn DenseGraph::toposort(self : DenseGraph) -> Array[Int]?

Topological sort with direct array access.

#
DenseGraph::transpose

fn DenseGraph::transpose(self : DenseGraph) -> DenseGraph

Reverse all edge directions. O(1) — swaps successor and predecessor arrays.

#
DfsEvent

pub(all) enum DfsEvent {
Discover(Int)
Finish(Int)
TreeEdge(Int, Int)
BackEdge(Int, Int)
CrossForwardEdge(Int, Int)
} derive(Eq,
Debug
)

DFS Edge Classification

Events emitted during a depth-first traversal. Every edge in the graph is classified into exactly one of three types (tree, back, cross/forward), and every vertex emits Discover (pre-order) and Finish (post-order).

Vertex coloring

  • White (undiscovered): not yet seen by the DFS
  • Gray (in progress): discovered but not finished — on the DFS stack
  • Black (finished): all descendants fully explored

Edge classification follows from the target vertex's color when the edge is examined: white → TreeEdge, gray → BackEdge, black → CrossForwardEdge.

#
Graph

pub(all) enum Graph {
Empty
Vertex(Int)
Overlay(Graph, Graph)
Connect(Graph, Graph)
}

Graph — the free algebra for directed graphs

Graph is the "initial" (tagged) encoding of algebraic graphs. It represents graph expressions as a syntax tree that can be inspected, transformed, and eventually interpreted.

The four constructors correspond exactly to the four GraphSym operations:

  • Empty — the empty graph
  • Vertex(v) — a single vertex
  • Overlay(a, b) — union of two graphs
  • Connect(a, b) — overlay + cross-edges from a to b

Why a syntax tree?

Unlike AdjacencyMap which eagerly computes the adjacency structure, Graph preserves the construction history. This enables:

  • Transformations before evaluation: gmap, bind, induce work on the expression tree without materializing intermediate graphs
  • Multiple interpretations: the same Graph can be folded into an AdjacencyMap, a vertex count, an edge list, or a visualization
  • Deferred evaluation: build the expression cheaply, evaluate once

The catamorphism: foldg

foldg is the universal interpreter — it replaces each constructor with a user-supplied function and recursively evaluates the tree. Every derived operation (to_adjacency_map, gmap, bind, induce) is implemented via foldg.

In category theory, foldg is the unique homomorphism from the free algebra (Graph) to any other algebra satisfying the graph axioms.

Performance note

foldg on a deeply nested expression (e.g., path([1..1000])) creates O(n) intermediate results. For large graphs, build an AdjacencyMap directly with from_edges instead of going through Graph expressions.
impl GraphSym for Graph
impl Show for Graph
impl Debug for Graph
impl Arbitrary for Graph
impl Shrink for Graph

#
Graph::bind

fn Graph::bind(self : Graph, f : (Int) -> Graph) -> Graph

Monadic bind: replace each vertex with a subgraph.

bind(f) substitutes every Vertex(v) with f(v) (which returns a Graph). This is the graph analogue of flatMap / >>=.

Example: edge(1, 2).bind(fn(v) { path([v*10, v*10+1]) }) expands vertex 1 into path(10, 11) and vertex 2 into path(20, 21), preserving the connect structure between them.

#
Graph::circuit

fn Graph::circuit(vs : Array[Int]) -> Graph

Circuit: path that loops back — v1 → v2 → ... → vn → v1.

#
Graph::clique

fn Graph::clique(vs : Array[Int]) -> Graph

Complete directed graph: every vertex has an edge to every later vertex.

clique([1, 2, 3]) = connect(connect(vertex(1), vertex(2)), vertex(3)) = edges [(1,2), (1,3), (2,3)]

#
Graph::edges

fn Graph::edges(es : Array[(Int, Int)]) -> Graph

Graph from explicit edge list.

#
Graph::gmap

fn Graph::gmap(self : Graph, f : (Int) -> Int) -> Graph

Transform every vertex label. Structure is preserved.

gmap(f) applies f to every Vertex(v), producing Vertex(f(v)). Overlay/Connect structure is unchanged.

#
Graph::induce

fn Graph::induce(self : Graph, pred : (Int) -> Bool) -> Graph

Subgraph induced by a predicate: keep only vertices where pred(v) is true. Edges between removed vertices are also removed.

#
Graph::path

fn Graph::path(vs : Array[Int]) -> Graph

Path: v1 → v2 → ... → vn (linear chain).

#
Graph::remove_vertex

fn Graph::remove_vertex(self : Graph, target : Int) -> Graph

Remove a single vertex (and all its edges).

#
Graph::star

fn Graph::star(center : Int, satellites : Array[Int]) -> Graph

Star graph: center → each satellite. No edges between satellites.

#
Graph::to_adjacency_map

fn Graph::to_adjacency_map(self : Graph) -> AdjacencyMap

Convert a Graph expression to AdjacencyMap.

This is THE bridge between the construction layer (Graph/GraphSym) and the observation layer (DirectedGraph). Once converted, you can run any algorithm (toposort, DFS, SCC, etc.) on the result.

Uses direct edge collection via foldg_iter: walks the expression tree iteratively (stack-safe for arbitrarily deep expressions) to collect all vertices and edges, then builds the AdjacencyMap once.

For path and star expressions this is dramatically faster than pairwise AdjacencyMap merging (77–127x measured). The vertex-set arrays still grow via append during the fold, so total work is O(E + n * avg_vertex_set_size) where n is the expression tree size. For typical expressions (path, star, edges) this is effectively linear; for pathological cases (deeply nested cliques) the cross-product in Connect dominates.

#
Graph::vertices

fn Graph::vertices(vs : Array[Int]) -> Graph

Overlay of isolated vertices: {v1} + {v2} + ... (no edges).

#
Reversed

pub struct Reversed[G] {
graph : G
}

Reversed — zero-cost reverse-direction graph view

Wraps any graph and swaps the direction of all edge queries. successors on the reversed graph returns predecessors on the original, and vice versa.

Capabilities provided

  • VertexSet for Reversed[G] when G : VertexSet
  • Successors for Reversed[G] when G : Predecessors
  • Predecessors for Reversed[G] when G : Successors
  • DirectedGraph for Reversed[G] when G : VertexSet + Predecessors

Properties

  • Lightweight: holds the original graph value. For alga's types (AdjacencyMap, DenseGraph), this is a shallow handle copy.
  • Involution: Reversed(Reversed(g)) produces the same traversal as g.
  • Composable: implements DirectedGraph + Predecessors, works with all algorithms: dfs_events, reachable, toposort, tarjan_scc, etc.
impl Successors for Reversed[G]
impl VertexSet for Reversed[G]

#
bfs_fold

fn[G : Successors, Acc] bfs_fold(graph : G, start : Int, init : Acc, f : (Acc, Int) -> (Acc, Bool)) -> Acc

BFS fold over vertices reachable from start.

Same interface as dfs_fold but visits vertices in breadth-first (level) order. Vertices at distance d from start are all visited before any vertex at distance d+1.

Time: O(V + E) where V and E are the reachable vertices and edges.

#
bfs_fold_multi

fn[G : DirectedGraph + VertexSet + Successors, Acc] bfs_fold_multi(graph : G, starts : Array[Int], init : Acc, f : (Acc, Int) -> (Acc, Bool)) -> Acc

Multi-source BFS fold over vertices reachable from any vertex in starts.

Seeds the BFS queue with all start vertices instead of one, enabling frontier-based traversal. This is the natural fit for problems where traversal begins from a SET of vertices rather than a single root — for example, CRDT event-graph-walkers that walk forward from a set of frontier LVs, or multi-root dependency resolution.

Why multi-source instead of looping single-source?

Calling bfs_fold in a loop for each start would revisit vertices reachable from multiple starts. Multi-source BFS visits each vertex at most once across ALL starts, giving true O(V+E) for the union.

Seeding

  • Invalid starts (not in the graph) are silently skipped via has_vertex
  • Duplicate starts are deduplicated before traversal begins
  • Starts are enqueued in input order — starts[0] is visited first

Time: O(V + E) where V and E are the reachable vertices and edges. Seed validation calls has_vertex — override it for O(1) on custom types.

#
check_conformance

fn[G : DirectedGraph + VertexSet + Successors] check_conformance(graph : G) -> Array[String]

Check that a graph conforms to the DirectedGraph trait contract.

Returns a list of human-readable violation messages; an empty list means the graph is conformant. See the module doc for the laws checked.

#
check_predecessors_conformance

fn[G : DirectedGraph + Predecessors + VertexSet + Successors] check_predecessors_conformance(graph : G) -> Array[String]

Check Predecessors conformance in addition to DirectedGraph laws.

Runs check_conformance first, then adds:

  • G. Symmetry: u ∈ predecessors(v) iff v ∈ successors(u) for all vertices u, v in iter().
  • E'. predecessors(v) contains no duplicates for each v.

#
dfs_events

fn[G : DirectedGraph + VertexSet + Successors] dfs_events(graph : G) -> Iter[DfsEvent]

DFS event iterator — classifies every edge and emits vertex enter/exit events.

Returns a lazy Iter[DfsEvent]. Each .next() call advances the DFS by one step and returns the next event. Handles disconnected components by iterating all vertices from graph.iter().

State machine

The closure captures:
  • enter_pending: vertex awaiting Discover (checked first each call)
  • frames: stack of (vertex, Iter[Int]) for successor processing
  • state: Map[Int, Bool] — absent = white, true = gray, false = black
  • roots: Iter[Int] from graph.iter() for finding unvisited roots

Event ordering

TreeEdge(u, v) and Discover(v) are separate events on separate .next() calls. enter_pending is checked before the stack, so Discover(v) fires before u's remaining successors — correct DFS order.

Time: O(V + E). Space: O(V). Benchmark: ~155µs chain, ~179µs cyclic, ~257µs diamond (1000 vertices, AdjacencyMap).

#
dfs_fold

fn[G : Successors, Acc] dfs_fold(graph : G, start : Int, init : Acc, f : (Acc, Int) -> (Acc, Bool)) -> Acc

DFS fold over vertices reachable from start.

Visits each vertex exactly once. f receives the current accumulator and the visited vertex, and returns (new_acc, should_continue). When should_continue is false, traversal halts immediately.

Time: O(V + E) where V and E are the reachable vertices and edges.

#
dfs_fold_multi

fn[G : DirectedGraph + VertexSet + Successors, Acc] dfs_fold_multi(graph : G, starts : Array[Int], init : Acc, f : (Acc, Int) -> (Acc, Bool)) -> Acc

Multi-source DFS fold over vertices reachable from any vertex in starts.

Seeds the DFS stack with all start vertices instead of one, enabling frontier-based traversal. Same motivation as bfs_fold_multi — see its doc comment for the multi-source vs looping-single-source rationale.

Ordering

Start vertices are pushed in reverse order so starts[0] is on top of the stack and processed first. When source sub-trees are disjoint, starts[0] is fully explored before starts[1]. When sub-trees overlap, vertices reachable from multiple starts are visited on first encounter (from whichever source reaches them first in DFS order).

Seeding

  • Invalid starts (not in the graph) are silently skipped via has_vertex
  • Duplicate starts are deduplicated before traversal begins

Time: O(V + E) where V and E are the reachable vertices and edges. Seed validation calls has_vertex — override it for O(1) on custom types.

#
find_cycle

fn[G : DirectedGraph + VertexSet + Successors] find_cycle(graph : G) -> Array[Int]?

Find a directed cycle, if any.

Returns Some(path) where path = [v0, v1, ..., vk] and there is an edge vk -> v0 closing the cycle, or None if the graph is acyclic. Self-loops are returned as [v] (single vertex; the closing edge is v -> v).

Algorithm

Iterative three-color DFS (white = unseen, gray = on current path, black = finished). When a gray successor is found, the cycle runs from that ancestor down through the current DFS frame stack. The closing edge is from the last element back to the first.

The returned path is one witness — there may be others. No ordering guarantee across graphs, but for a given graph traversal the output is deterministic (controlled by iter and successors order).

Time: O(V + E) worst case (acyclic graph). Early-exits on first cycle. Space: O(V).

#
foldg

fn[B] foldg(graph : Graph, empty : B, vertex : (Int) -> B, overlay : (B, B) -> B, connect : (B, B) -> B) -> B

Catamorphism: fold a Graph expression into any type B.

Replaces Empty with empty, Vertex(v) with vertex(v), Overlay(a, b) with overlay(fold(a), fold(b)), and Connect(a, b) with connect(fold(a), fold(b)).

This is the universal evaluator — every graph interpretation (to_adjacency_map, vertex_count, gmap, etc.) can be expressed as a single call to foldg with appropriate replacement functions.

Performance warning: When folding into AdjacencyMap, each Overlay or Connect node creates an intermediate AdjacencyMap and merges it, resulting in O(n^2) total work for a chain of n operations (e.g., path([1..1000])). For large graphs, prefer AdjacencyMap::from_edges which builds the representation directly in O(E) time.

#
foldg_iter

fn[B] foldg_iter(graph : Graph, empty : B, vertex : (Int) -> B, overlay : (B, B) -> B, connect : (B, B) -> B) -> B

Iterative catamorphism over Graph expressions. Same semantics as recursive foldg, but uses an explicit stack so it handles arbitrarily deep expressions without stack overflow.

For lightweight folds (B = Int), this is ~2.8x slower than recursive foldg due to work-item enum allocation. For heavyweight folds (B = AdjacencyMap), the overhead is negligible. Use this when the expression tree may be deeper than the call stack allows (~10K on WASM).

#
has_cycle

fn[G : DirectedGraph + VertexSet + Successors] has_cycle(graph : G) -> Bool

Returns true if the graph contains a directed cycle. A self-loop (v → v) counts as a cycle.

#
indegree

fn[G : DirectedGraph + VertexSet + Successors] indegree(graph : G, v : Int) -> Int

Number of incoming edges to vertex v.

Returns 0 if v has no predecessors (or is not in the graph). Self-loops count: if v→v exists, it contributes 1 to indegree.

Time: O(V + E) — must scan every vertex's successor list because the DirectedGraph trait only exposes forward adjacency. For bulk indegree computation, prefer a single-pass array approach.

#
is_reachable

fn[G : DirectedGraph + VertexSet + Successors] is_reachable(graph : G, from : Int, to : Int) -> Bool

Early-exit reachability query: "is to reachable from from?"

Runs DFS from from and stops as soon as to is discovered, without materializing the full reachable set. Reflexive: is_reachable(g, v, v) is true (zero-length path), matching the behaviour of reachable(g, v) which includes v in its output.

Returns false if from is not a vertex in the graph.

Time: O(V' + E') where V' and E' are the vertices and edges visited before encountering to (bounded by the reachable subgraph from from).

#
kosaraju_scc

fn[G : DirectedGraph + Predecessors + VertexSet + Successors] kosaraju_scc(graph : G) -> Array[Array[Int]]

Compute all strongly connected components via Kosaraju's algorithm.

Generic over DirectedGraph + Predecessors — the second DFS pass walks predecessors directly instead of materializing a transposed graph, saving the O(V+E) transpose allocation.

Returns components in reverse topological order of the component DAG (if A's component has an edge to B's component, A's component appears first in the result). This matches the classical Kosaraju ordering and is the opposite of tarjan_scc.

When to use which SCC

  • kosaraju_scc: when Predecessors is available and you want reverse topological order, or benefit from the two-pass structure (e.g. you already needed finish order for something else).
  • tarjan_scc: when you lack Predecessors, or want forward topological order.

Time: O(V + E). Space: O(V) — no transpose allocation.

#
outdegree

fn[G : Successors] outdegree(graph : G, v : Int) -> Int

Number of outgoing edges from vertex v.

Returns 0 if v has no successors (including if v is not a vertex in the graph — successors returns Iter::empty() for absent vertices). Self-loops count: if v→v exists, it contributes 1 to outdegree.

Time: O(degree(v)) — counts via Iter::count on successors.

#
reachable

fn[G : Successors] reachable(graph : G, start : Int) -> Array[Int]

All vertices reachable from start, in DFS pre-order.

#
reachable_multi

fn[G : DirectedGraph + VertexSet + Successors] reachable_multi(graph : G, starts : Array[Int]) -> Array[Int]

All vertices reachable from any vertex in starts, in DFS pre-order.

Convenience wrapper around dfs_fold_multi — collects all reachable vertices into an array. Same relationship as reachable to dfs_fold.

#
reversed

fn[G] reversed(graph : G) -> Reversed[G]

#
tarjan_scc

fn[G : DirectedGraph + VertexSet + Successors] tarjan_scc(graph : G) -> Array[Array[Int]]

Strongly Connected Components (Tarjan's Algorithm)

Generic over DirectedGraph — works on any graph type without requiring transpose. Single DFS pass using lowlink values.

Algorithm: Tarjan (1972)

Performs one DFS, maintaining for each vertex:
  • index: discovery time (monotonically increasing)
  • lowlink: smallest index reachable from the subtree rooted at this vertex

When all successors of vertex v are processed, if lowlink[v] == index[v], then v is the root of an SCC — pop the SCC stack until v to extract it.

Implementation: fully iterative

Uses Iter[Int].next() for pause/resume of successor iteration. Each stack frame stores (vertex, Iter[Int]) — the iterator carries its position via closure state. This avoids recursion (safe for 10K+ vertices in WASM) and avoids collecting successors into temp arrays.

Output ordering

Produces SCCs in forward topological order: if component A has an edge to component B, B appears before A in the result. This is the opposite of Kosaraju's scc() which produces reverse topological order.

Time: O(V + E). Space: O(V) — no transpose allocation.

#
topo_levels

fn[G : DirectedGraph + VertexSet + Successors] topo_levels(graph : G) -> Map[Int, Int]?

Topological Levels

Computes the length of the longest path from any source (zero-in-degree vertex) to each vertex in a DAG. Sources get level 0, their successors get level 1, and so on.

Why longest path, not shortest?

Shortest-path levels are insufficient for glitch-free scheduling. Consider: if vertex C has predecessors A (level 0) and B (level 1), shortest-path would assign C level 1, allowing C to fire before B updates — a glitch. Longest-path assigns C level 2, guaranteeing both A and B fire before C.

This is exactly the level-sorted BFS schedule used by reactive frameworks like incr for glitch-free push propagation: process vertices in ascending level order and every node sees all its dependencies before it fires.

Cycle detection

Like toposort, this is a modified Kahn's algorithm. If not all vertices are processed, a cycle exists and the function returns None. Self-loops (v → v) count as cycles.

Return value

Map[Int, Int]? mapping each vertex to its level, or None if the graph has a cycle. The map preserves the original vertex IDs (works for both sparse AdjacencyMap and dense DenseGraph IDs).

Time: O(V + E). Space: O(V).

#
toposort

fn[G : DirectedGraph + VertexSet + Successors] toposort(graph : G) -> Array[Int]?

Topological sort via Kahn's algorithm. Returns Some(ordering) for DAGs, None if the graph has a cycle.

Warning: Self-loops (edges v -> v) are treated as cycles and cause this function to return None. If your graph may contain self-loops that you want to ignore, call remove_self_loops() first.

#
toposort_or_cycle

fn[G : DirectedGraph + VertexSet + Successors] toposort_or_cycle(graph : G) -> Result[Array[Int], Array[Int]]

Topological sort that returns the witnessing cycle on failure.

Ok(ordering) for DAGs, Err(cycle) where cycle = [v0, ..., vk] with a closing edge vk -> v0 when the graph has a cycle. Strictly more informative than toposort — use this when the caller wants to diagnose or report the cycle (reactive-graph cycle paths, movable-tree conflict diagnostics, build-system dependency errors).

Runs toposort first (O(V+E), Kahn's algorithm) and falls back to find_cycle on failure — so the acyclic path pays the same cost as plain toposort.

#
toposort_subset

fn[G : DirectedGraph + VertexSet + Successors] toposort_subset(graph : G, vertices : Array[Int]) -> Array[Int]?

Topological sort over the induced subgraph of vertices.

Only edges where both endpoints are in vertices are considered. Returns Some(ordering) if the induced subgraph is a DAG, None if it contains a cycle. Returns Some([]) for an empty vertex set. Duplicate vertices in the input are handled correctly.

Input validation: Invalid vertex IDs (not present in the graph) are silently filtered out rather than causing errors or panics. This makes toposort_subset composable — callers don't need to pre-validate their vertex list against the graph, which matters when the list may lag behind graph mutations. The filtering cost is O(k) with O(1) has_vertex overrides (AdjacencyMap, DenseGraph), or O(k * V) for types using the O(V) default.

Time: O(V_sub + E_sub). Space: O(V_sub).

#
would_create_cycle

fn[G : DirectedGraph + VertexSet + Successors] would_create_cycle(graph : G, u : Int, v : Int) -> Bool

Would adding edge u -> v introduce a cycle?

A new edge u -> v closes a cycle iff there already exists a path v -> ... -> u in the graph (then u -> v -> ... -> u is cyclic). Self-loops (u == v) always create a cycle.

Useful for movable-tree CRDTs and reactive-graph libraries that must reject cycle-creating edges before committing them. Strictly cheaper than has_cycle on the hypothetical graph: early-exits as soon as the closing path is found.

Does not require u or v to already be in the graph — a missing endpoint cannot participate in any existing path, so no cycle is possible unless u == v.

Time: O(V' + E') where V' and E' are the vertices and edges visited searching from v for u (bounded by v's reachable subgraph).