Functional inductive graphs for Elixir researchers and learners.
This package provides an inductive graph representation based on Martin Erwig's Functional Graph Library (FGL). Unlike the traditional adjacency-list approach used in Yog.Graph, these graphs are defined recursively, enabling algorithms to be written as pure, recursive functions without explicit "visited" sets.
Philosophy: The Inductive Principle
An inductive graph is defined by two constructors:
- Empty: A graph with no nodes.
- Embed: Formed by adding a node context to an existing graph. This is the inverse of
match/2.
The Inductive Duality: Match and Embed
- Match (
Decompose): Extracts a node and its context. The resulting remaining graph is the new "shrunken" world view. The node and all its incident edges are gone, so the graph is structurally smaller. - Embed (
Compose): The inverse. It takes a context and a graph to produce a new (larger) graph.
alias Yog.Functional.Model
# Match node 1
{:ok, context, shrunken_graph} = Model.match(graph, 1)
# Embed it back (potentially into a transformed graph)
new_graph = Model.embed(shrunken_graph, context)Pattern Matching Friendly
Just as lists are defined as [head | tail], these graphs are defined as context & remaining. Every recursive step handles these as "head" and "tail", allowing for code that reads like pattern matching:
- Base case:
Model.empty()(the empty graph). - Recursive case:
match(graph, id)gives you the context (head) and the shrunken world view (tail).
Every time you recurse with the shrunken_graph, you are operating on a self-contained "sub-universe" where the earlier nodes simply do not exist.
By working with remaining_graph, algorithms naturally prevent revisits: once a node is matched, it and all its edges are gone from the perspective of the recursion. The algorithm terminates when Model.empty?(graph) is true.
Data Example: Shrinking the Graph
Consider a simple directed graph: 1 -> 2 -> 3.
- Initial Graph: Represents the full structure
{1, 2, 3}. - Match 1:
- Context:
id: 1, label: "A", out_edges: %{2 => nil} - Remaining: A graph containing only
2 -> 3. Node 1 and the edge1 -> 2have been structurally removed.
- Context:
- Match 2 (on the remaining graph):
- Context:
id: 2, label: "B", out_edges: %{3 => nil} - Remaining: A graph containing only the isolated node
3.
- Context:
- Match 3:
- Context:
id: 3, label: "C" - Remaining:
Model.empty().
- Context:
The Build/Burn Pattern: Context as State
The fundamental pattern for working with inductive graphs is what we call build/burn — alternating between deconstructing (burning) and reconstructing (building) the graph. The context is your handle on the current "focus" of the graph.
Deconstruction (Burn): match/2
When you match a node, you receive:
- The Context — the node's ID, label, and all its incident edges
- The Remaining Graph — the graph with this node burned away, edges and all
alias Yog.Functional.Model
graph = Model.empty()
|> Model.put_node(1, "Alice")
|> Model.put_node(2, "Bob")
|> Model.put_node(3, "Carol")
|> Model.add_edge!(1, 2, :follows)
|> Model.add_edge!(2, 3, :follows)
# Burn node 1 — extract it from the graph
{:ok, ctx, remaining} = Model.match(graph, 1)
# ctx.id == 1
# ctx.label == "Alice"
# ctx.out_edges == %{2 => :follows}
# ctx.in_edges == %{} (no one follows Alice in this graph)
# remaining contains nodes 2 and 3, but NO edge from 1->2
# because node 1 and all its edges have been burned awayConstruction (Build): embed/2
The inverse operation restores a context into a graph. This enables transform-then-rebuild workflows:
# Transform the context (e.g., increment a counter in the label)
new_ctx = %{ctx | label: %{ctx.label | visits: ctx.label.visits + 1}}
# Build it back into a (possibly different) graph
restored_graph = Model.embed(new_ctx, remaining)Traversal Example: Finding All Nodes with a Property
Here's how you traverse by burning through the graph, collecting matches:
def find_influencers(graph, min_followers, acc \\ []) do
# Try to match any remaining node
case Model.match_any(graph) do
{:error, :empty} ->
# Base case: graph is burned away completely
Enum.reverse(acc)
{:ok, ctx, remaining} ->
# ctx is our "current state" — we have full info about this node
follower_count = map_size(ctx.in_edges)
# Decide based on context, then continue burning the rest
new_acc = if follower_count >= min_followers do
[{ctx.id, ctx.label, follower_count} | acc]
else
acc
end
# Recurse on the BURNED graph — ctx.id is gone, so we can't revisit
find_influencers(remaining, min_followers, new_acc)
end
end
# Usage:
# find_influencers(social_graph, 1000, [])
# => [{2, "Bob", 1500}, {5, "Eve", 2300}]Key Insight: The Context is Your Iterator
In traditional graphs, you iterate over node IDs and look up data. In FGL:
- The context bundles identity, data, AND connectivity — you have everything needed to decide "what next" without additional lookups
- The remaining graph IS your visited set — burned nodes simply don't exist anymore
- No back-edges possible — once you
matcha node, it cannot be reached from any future recursive call
This makes algorithms like DFS trivially correct without explicit cycle detection:
def dfs(graph, []), do: []
def dfs(graph, [v | vs]) do
case Model.match(graph, v) do
{:ok, ctx, remaining} ->
# ctx.out_edges tells us where to go next
# remaining ensures we never come back to ctx.id
neighbors = Map.keys(ctx.out_edges)
[ctx | dfs(remaining, neighbors ++ vs)]
{:error, :not_found} ->
# Already burned — skip
dfs(graph, vs)
end
endFeatures
- Memory Efficiency: Leverages Elixir's persistent data structures (Maps) to represent the recursive decomposition efficiently.
- Algorithm Suite: Includes Topological Sort, SCC (Kosaraju), Dijkstra, Prim's MST, and Dominators.
- Interop: Convert to and from the adjacency-based
Yog.Graphmodel usingModel.from_adjacency_graph/1.
References
- Martin Erwig (2001): Inductive Graphs and Functional Graph Algorithms - The foundational paper.
- Haskell FGL: hackage.haskell.org/package/fgl - The industry-standard functional graph library.
- Programming in Haskell (Graham Hutton): Chapter on functional graph algorithms.
Note: This module is primarily for research and educational purposes. For high-performance production workloads involving millions of edges, the ephemeral Yog.Graph is generally recommended.