Current section

Files

Jump to
yog_ex lib yog functional analysis.ex
Raw

lib/yog/functional/analysis.ex

defmodule Yog.Functional.Analysis do
@moduledoc """
Structural analysis for inductive graphs — components, bridges, and articulation points.
This module analyzes connectivity and vulnerability in graphs using the inductive
`match/2` operation for component extraction and Tarjan's DFS for bridge/cut-vertex
detection.
## Available Analyses
| Analysis | Function | Description |
|----------|----------|-------------|
| Connected Components | `connected_components/1` | Find all connected components |
| Bridges & Articulation Points | `analyze_connectivity/1` | Single-pass Tarjan DFS |
| Transitive Closure | `transitive_closure/1` | Compute complete reachability |
| Biconnected Components | `biconnected_components/1` | Find maximal non-separable subgraphs |
| Dominators | `dominators/2` | Compute node dominance for flow graphs |
## Key Concepts
- **Bridge** (cut-edge): An edge whose removal disconnects the graph
- **Articulation Point** (cut-vertex): A node whose removal disconnects the graph
- Components are extracted inductively via `match/2`, naturally preventing
revisits without an explicit visited set
## References
- [Wikipedia: Bridge (Graph Theory)](https://en.wikipedia.org/wiki/Bridge_(graph_theory))
- [Wikipedia: Biconnected Component](https://en.wikipedia.org/wiki/Biconnected_component)
"""
alias Yog.Functional.{Model, Traversal}
@type bridge :: {Model.node_id(), Model.node_id()}
@doc """
Finds all connected components in an undirected graph.
Returns a list of lists of node IDs.
## Examples
iex> alias Yog.Functional.{Model, Analysis}
iex> graph = Model.new(:undirected)
...> |> Model.put_node(1, "A")
...> |> Model.put_node(2, "B")
...> |> Model.put_node(3, "C")
...> |> Model.add_edge!(1, 2)
iex> components = Analysis.connected_components(graph)
iex> Enum.map(components, &Enum.sort/1) |> Enum.sort()
[[1, 2], [3]]
"""
@spec connected_components(Model.t()) :: [[Model.node_id()]]
def connected_components(graph) do
do_find_components(graph, [])
end
defp do_find_components(graph, acc) do
if Model.empty?(graph) do
Enum.reverse(acc)
else
[start_id | _] = Model.node_ids(graph)
{component, remaining_graph} = extract_component(graph, [start_id], [])
do_find_components(remaining_graph, [component | acc])
end
end
@doc """
Identifies bridges (cut-edges) and articulation points (cut-vertices)
in an undirected graph using a single-pass DFS.
## Examples
iex> alias Yog.Functional.{Model, Analysis}
iex> graph = Model.new(:undirected)
...> |> Model.put_node(1, "A") |> Model.put_node(2, "B") |> Model.put_node(3, "C")
...> |> Model.add_edge!(1, 2) |> Model.add_edge!(2, 3)
iex> result = Analysis.analyze_connectivity(graph)
iex> result.bridges |> Enum.sort()
[{1, 2}, {2, 3}]
iex> result.points |> Enum.sort()
[2]
"""
@spec analyze_connectivity(Model.t()) ::
%{bridges: [bridge()], points: [Model.node_id()]}
def analyze_connectivity(graph) do
initial_state = %{
tin: %{},
low: %{},
timer: 0,
bridges: [],
points: MapSet.new(),
visited: MapSet.new()
}
final_state =
Enum.reduce(Model.node_ids(graph), initial_state, fn id, acc ->
if MapSet.member?(acc.visited, id) do
acc
else
tarjan_dfs(graph, id, nil, acc) |> elem(0)
end
end)
%{bridges: final_state.bridges, points: MapSet.to_list(final_state.points)}
end
defp tarjan_dfs(graph, v, parent, state) do
tin = Map.put(state.tin, v, state.timer)
low = Map.put(state.low, v, state.timer)
visited = MapSet.put(state.visited, v)
timer = state.timer + 1
base_state = %{state | tin: tin, low: low, visited: visited, timer: timer}
{:ok, ctx} = Model.get_node(graph, v)
neighbors = Map.keys(ctx.out_edges)
{reduce_state, children_count} =
Enum.reduce(neighbors, {base_state, 0}, fn to, {acc_state, children} ->
process_neighbor(graph, v, to, parent, acc_state, children)
end)
final_state =
if parent == nil and children_count > 1 do
%{reduce_state | points: MapSet.put(reduce_state.points, v)}
else
reduce_state
end
{final_state, children_count}
end
defp process_neighbor(graph, v, to, parent, acc_state, children) do
cond do
to == parent ->
{acc_state, children}
MapSet.member?(acc_state.visited, to) ->
new_low = min(acc_state.low[v], acc_state.tin[to])
{%{acc_state | low: Map.put(acc_state.low, v, new_low)}, children}
true ->
{post_dfs_state, _} = tarjan_dfs(graph, to, v, acc_state)
new_v_low = min(post_dfs_state.low[v], post_dfs_state.low[to])
new_bridges =
if post_dfs_state.low[to] > post_dfs_state.tin[v] do
[{min(v, to), max(v, to)} | post_dfs_state.bridges]
else
post_dfs_state.bridges
end
new_points =
if parent != nil and post_dfs_state.low[to] >= post_dfs_state.tin[v] do
MapSet.put(post_dfs_state.points, v)
else
post_dfs_state.points
end
{%{
post_dfs_state
| low: Map.put(post_dfs_state.low, v, new_v_low),
bridges: new_bridges,
points: new_points
}, children + 1}
end
end
defp extract_component(graph, [], acc), do: {acc, graph}
defp extract_component(graph, [id | stack], acc) do
case Model.match(graph, id) do
{:error, :not_found} ->
extract_component(graph, stack, acc)
{:ok, ctx, remaining} ->
neighbors = Map.keys(ctx.out_edges)
extract_component(remaining, neighbors ++ stack, [id | acc])
end
end
@doc """
Computes the transitive closure of the graph as a map of node reachability.
Returns `%{node_id => [reachable_node_ids]}`.
## Examples
iex> alias Yog.Functional.{Model, Analysis}
iex> graph = Model.empty() |> Model.put_node(1, "A") |> Model.put_node(2, "B")
...> |> Model.add_edge!(1, 2)
iex> tc = Analysis.transitive_closure(graph)
iex> tc[1] |> Enum.sort()
[1, 2]
"""
@spec transitive_closure(Model.t()) :: %{Model.node_id() => [Model.node_id()]}
def transitive_closure(graph) do
ids = Model.node_ids(graph)
Enum.reduce(ids, %{}, fn id, acc ->
reachable = Traversal.reachable(graph, id)
Map.put(acc, id, reachable)
end)
end
@doc """
Finds the biconnected components of an undirected graph.
Each component is represented as a list of edge tuples `{u, v}`.
## Examples
iex> alias Yog.Functional.{Model, Analysis}
iex> graph = Model.new(:undirected)
...> |> Model.put_node(1, "A") |> Model.put_node(2, "B")
...> |> Model.put_node(3, "C") |> Model.add_edge!(1, 2)
...> |> Model.add_edge!(2, 3)
iex> bccs = Analysis.biconnected_components(graph)
iex> length(bccs)
2
"""
@spec biconnected_components(Model.t()) :: [[{Model.node_id(), Model.node_id()}]]
def biconnected_components(graph) do
# This uses a variation of Tarjan's DFS tracking edges in a stack
initial_state = %{
tin: %{},
low: %{},
timer: 0,
edge_stack: [],
components: [],
visited: MapSet.new()
}
final_state =
Enum.reduce(Model.node_ids(graph), initial_state, fn id, acc ->
if MapSet.member?(acc.visited, id) do
acc
else
{state, _} = bcc_dfs(graph, id, nil, acc)
state
end
end)
final_state.components
end
defp bcc_dfs(graph, v, parent, state) do
tin = Map.put(state.tin, v, state.timer)
low = Map.put(state.low, v, state.timer)
visited = MapSet.put(state.visited, v)
timer = state.timer + 1
base_state = %{state | tin: tin, low: low, visited: visited, timer: timer}
{:ok, ctx} = Model.get_node(graph, v)
neighbors = Map.keys(ctx.out_edges)
Enum.reduce(neighbors, {base_state, 0}, fn to, {acc_state, children} ->
cond do
to == parent ->
{acc_state, children}
Map.has_key?(acc_state.tin, to) ->
# Back-edge
new_stack =
if acc_state.tin[to] < acc_state.tin[v] do
[{v, to} | acc_state.edge_stack]
else
acc_state.edge_stack
end
new_low = min(acc_state.low[v], acc_state.tin[to])
{%{acc_state | low: Map.put(acc_state.low, v, new_low), edge_stack: new_stack},
children}
true ->
# Tree-edge
state_with_edge = %{acc_state | edge_stack: [{v, to} | acc_state.edge_stack]}
{post_dfs_state, _} = bcc_dfs(graph, to, v, state_with_edge)
new_v_low = min(post_dfs_state.low[v], post_dfs_state.low[to])
{final_state, popped_stack} =
if post_dfs_state.low[to] >= post_dfs_state.tin[v] do
{comp, remaining_stack} = pop_bcc_stack(post_dfs_state.edge_stack, {v, to}, [])
{%{post_dfs_state | components: [comp | post_dfs_state.components]},
remaining_stack}
else
{post_dfs_state, post_dfs_state.edge_stack}
end
{%{final_state | low: Map.put(final_state.low, v, new_v_low), edge_stack: popped_stack},
children + 1}
end
end)
end
defp pop_bcc_stack([{u, v} | rest], target, acc) when {u, v} == target do
{[{u, v} | acc], rest}
end
defp pop_bcc_stack([edge | rest], target, acc) do
pop_bcc_stack(rest, target, [edge | acc])
end
defp pop_bcc_stack([], _, acc), do: {acc, []}
@doc """
Finds immediate dominators of all reachable nodes from a start node.
Returns `%{node_id => idom_id}`.
Uses a recursive fixed-point implementation suitable for functional graphs.
The start node dominates itself.
"""
@spec dominators(Model.t(), Model.node_id()) :: %{Model.node_id() => Model.node_id()}
def dominators(graph, start) do
# Filter only reachable nodes to simplify
reachable_ids = Traversal.reachable(graph, start)
# Get predecessors for these nodes (only considering those in reachable set)
predecessors =
Enum.into(reachable_ids, %{}, fn id ->
{:ok, in_n} = Model.in_neighbors(graph, id)
{id, Map.keys(in_n)}
end)
# Initial dominators: D(start) = {start}, D(v) = {all reachable nodes}
initial_doms =
Enum.into(reachable_ids, %{}, fn
id when id == start -> {id, [id]}
id -> {id, reachable_ids}
end)
compute_dominators(initial_doms, predecessors, reachable_ids, start)
end
defp compute_dominators(doms, preds, ids, start) do
new_doms =
Enum.reduce(ids, doms, fn v, acc ->
if v == Enum.at(acc[v], 0) and length(acc[v]) == 1 do
acc
else
# D(v) = {v} union Intersection of D(p) for all predecessors p of v
intersection =
case preds[v] do
[] -> []
ps -> Enum.map(ps, &acc[&1]) |> Enum.reduce(&intersect/2)
end
Map.put(acc, v, Enum.uniq([v | intersection]))
end
end)
if new_doms == doms do
# Convert set of dominators to immediate dominator (idom)
# idom(v) is the node in D(v) \ {v} that is "closest" to v,
# which in the set-based approach is the one with the largest dominator set among D(v) \ {v}.
Enum.into(new_doms, %{}, fn {v, ds} ->
if v == start do
{v, v}
else
# Filter out v itself
candidates = Enum.reject(ds, &(&1 == v))
# The idom is the candidate whose own dominator set is the largest.
# (Wait, if A dominates B, then D(A) is a subset of D(B). So idom is the one with largest D(x) in D(v)\{v})
idom = Enum.max_by(candidates, fn c -> length(new_doms[c]) end)
{v, idom}
end
end)
else
compute_dominators(new_doms, preds, ids, start)
end
end
defp intersect(a, b), do: Enum.filter(a, &(&1 in b))
end