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)
Reachabilityreachable(g, v)O(V+E)
DFS edge classificationdfs_events(g)O(V+E)
Topological sorttoposort(g)O(V+E)
Topological levelstopo_levels(g)O(V+E)
Cycle detectionhas_cycle(g)O(V+E)
SCC (Tarjan)tarjan_scc(g)O(V+E)
SCC (Kosaraju)g.scc()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)

Kosaraju SCC and condensation are AdjacencyMap-specific. reversed(g) requires the Predecessors trait. 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.

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

#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. 237 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 experiment/ Performance experiments (DenseGraph, visited sets) 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 {
iter(Self) -> Iter[Int]
successors(Self, Int) -> Iter[Int]
each_vertex(Self, (Int) -> Unit) -> Unit = _
each_successor(Self, Int, (Int) -> Unit) -> Unit = _
vertex_count(Self) -> Int = _
has_vertex(Self, Int) -> Bool = _
}

DirectedGraph — the observation layer

This trait defines the minimal interface for observing a directed graph. Implement iter and successors to get every algorithm in the library for free. each_vertex, each_successor, vertex_count, and has_vertex all have defaults derived from the two required methods.

Contract

iter must yield each vertex exactly once.

Design decisions

Fixed vertex type (Int): MoonBit traits cannot have type parameters or associated types, so we fix vertices to Int. If your vertices are strings or custom IDs, map them to Int indices.

Iter-based iteration: iter and successors return Iter[Int] — MoonBit's external iterator (struct Iter[X](fn() -> X?)). Pull-based: call .next() for one vertex at a time. Enables pause/resume (Tarjan SCC), early termination (has_vertex short-circuits via Iter::contains), and lazy composition.

Callback methods defaulted: each_vertex and each_successor delegate to Iter::each for push-style algorithms (DFS, BFS, toposort).

Reverse-direction queries: Types that also implement Predecessors can be wrapped with Reversed[G] for zero-cost reverse traversal. AdjacencyMap and DenseGraph both implement Predecessors via bidirectional adjacency storage.

Example: minimal implementation (2 methods)

struct MyGraph { edges : Array[Array[Int]] } impl DirectedGraph for MyGraph with iter(self) { (0).until(self.edges.length()) } impl DirectedGraph for MyGraph with successors(self, v) { self.edges[v].iter() } // vertex_count, has_vertex, each_vertex, each_successor all work via defaults.

#
GraphSym

pub(open) trait GraphSym {
empty() -> Self
vertex(Int) -> Self
overlay(Self, Self) -> Self
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 {
predecessors(Self, Int) -> Iter[Int]
}

Predecessors — reverse-direction observation capability

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.

#
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::has_vertex

fn AdjacencyMap::has_vertex(self : AdjacencyMap, 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.

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

#
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_count

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

#
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::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::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::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)

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 that implements DirectedGraph + Predecessors and swaps the direction of all edge queries. successors on the reversed graph returns predecessors on the original, and vice versa.

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.

#
bfs_fold

fn[G : DirectedGraph, 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, 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.

#
dfs_events

fn[G : DirectedGraph] 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 : DirectedGraph, 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, 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.

#
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] 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] 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.

#
outdegree

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

Number of outgoing edges from vertex v.

Returns 0 if v is not in the graph or has no successors. 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 : DirectedGraph] reachable(graph : G, start : Int) -> Array[Int]

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

#
reachable_multi

fn[G : DirectedGraph] 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] 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] 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] 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_subset

fn[G : DirectedGraph] 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).