Current section

Files

Jump to
ragex lib ragex graph algorithms.ex
Raw

lib/ragex/graph/algorithms.ex

defmodule Ragex.Graph.Algorithms do
@moduledoc """
Graph algorithms for analyzing code structure and relationships.
Provides algorithms for:
- PageRank: Importance scoring based on call relationships
- Path Finding: Discovering call chains between functions
- Centrality Metrics: Degree, betweenness, clustering
- Graph Statistics: Overall graph analysis
"""
alias Ragex.Graph.Store
@doc """
Computes PageRank scores for all nodes in the graph.
PageRank measures the importance of nodes based on incoming edges.
Higher scores indicate more "important" functions/modules (those called by many others).
## Parameters
- `damping_factor`: Probability of following an edge (default: 0.85)
- `max_iterations`: Maximum iterations (default: 100)
- `tolerance`: Convergence threshold (default: 0.0001)
## Returns
Map of node_id => pagerank_score
"""
def pagerank(opts \\ []) do
damping = Keyword.get(opts, :damping_factor, 0.85)
max_iter = Keyword.get(opts, :max_iterations, 100)
tolerance = Keyword.get(opts, :tolerance, 0.0001)
# Get all edges
edges = get_call_edges()
# Build adjacency structure
{out_edges, in_edges} = build_adjacency(edges)
# Get all nodes involved in the call graph
node_ids =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
n = length(node_ids)
if n == 0 do
%{}
else
initial_score = 1.0 / n
scores = Map.new(node_ids, fn id -> {id, initial_score} end)
# Iterate until convergence
iterate_pagerank(scores, node_ids, out_edges, in_edges, damping, max_iter, tolerance)
end
end
@doc """
Finds all paths between two nodes up to a maximum depth.
## Parameters
- `from`: Source node ID
- `to`: Target node ID
- `opts`: Keyword list of options
- `:max_depth` - Maximum path length in edges (default: 10)
- `:max_paths` - Maximum number of paths to return (default: 100)
- `:warn_dense` - Emit warnings for dense graphs (default: true)
## Returns
List of paths, where each path is a list of node IDs.
Returns up to `max_paths` paths to prevent hangs on dense graphs.
## Examples
# Find up to 100 paths with max depth of 10
find_paths(from, to)
# Find up to 50 paths with max depth of 5
find_paths(from, to, max_depth: 5, max_paths: 50)
# Disable warnings for dense graphs
find_paths(from, to, warn_dense: false)
"""
def find_paths(from, to, opts \\ []) do
max_depth = Keyword.get(opts, :max_depth, 10)
max_paths = Keyword.get(opts, :max_paths, 100)
warn_dense = Keyword.get(opts, :warn_dense, true)
edges = get_call_edges()
adjacency = build_out_adjacency(edges)
# Check for dense graphs and emit warning if needed
if warn_dense do
check_dense_graph(from, adjacency)
end
# Use accumulator to track path count for early stopping
{paths, _count} =
find_paths_dfs(
from,
to,
adjacency,
max_depth,
max_paths,
[from],
MapSet.new([from]),
0
)
paths
end
@doc """
Computes degree centrality for all nodes.
Returns:
- `in_degree`: Number of incoming edges (callers)
- `out_degree`: Number of outgoing edges (callees)
- `total_degree`: Sum of in and out degree
"""
def degree_centrality do
edges = get_call_edges()
in_degrees =
edges
|> Enum.group_by(fn {_from, to} -> to end)
|> Map.new(fn {node, list} -> {node, length(list)} end)
out_degrees =
edges
|> Enum.group_by(fn {from, _to} -> from end)
|> Map.new(fn {node, list} -> {node, length(list)} end)
# Get all nodes - calls use 4-tuple format {:function, module, name, arity}
# But nodes are stored with 3-tuple IDs {module, name, arity}
# So we need to normalize to the 4-tuple format to match call edges
all_nodes =
Store.list_nodes()
|> Enum.map(fn node ->
case {node.type, node.id} do
{:function, {module, name, arity}} -> {:function, module, name, arity}
{:module, id} -> {:module, id}
{type, id} -> {type, id}
end
end)
|> MapSet.new()
Map.new(all_nodes, fn node ->
in_deg = Map.get(in_degrees, node, 0)
out_deg = Map.get(out_degrees, node, 0)
{node,
%{
in_degree: in_deg,
out_degree: out_deg,
total_degree: in_deg + out_deg
}}
end)
end
@doc """
Computes betweenness centrality for nodes in the graph.
Betweenness measures how often a node appears on shortest paths between other nodes.
Higher scores indicate "bridge" or "bottleneck" functions.
Uses Brandes' algorithm (O(nm) complexity).
## Parameters
- `:max_nodes` - Limit computation to N highest-degree nodes (default: from config, 1000)
- `:normalize` - Return normalized scores 0-1 (default: true)
- `:directed` - Treat graph as directed (default: true)
## Returns
Map of node_id => betweenness_score
## Examples
# Compute for all nodes (up to configured max)
betweenness_centrality()
# Compute for top 100 nodes by degree
betweenness_centrality(max_nodes: 100)
# Get raw (unnormalized) scores
betweenness_centrality(normalize: false)
"""
def betweenness_centrality(opts \\ []) do
default_max =
Application.get_env(:ragex, :graph, []) |> Keyword.get(:max_nodes_betweenness, 1_000)
max_nodes = Keyword.get(opts, :max_nodes, default_max)
normalize = Keyword.get(opts, :normalize, true)
edges = get_call_edges()
# Get all nodes
node_ids =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
# If too many nodes, select top nodes by degree
nodes_to_compute =
if length(node_ids) > max_nodes do
# Get degree centrality and select top nodes
centrality = degree_centrality()
centrality
|> Enum.sort_by(fn {_node, metrics} -> -metrics.total_degree end)
|> Enum.take(max_nodes)
|> Enum.map(fn {node, _metrics} -> node end)
else
node_ids
end
# Build adjacency for shortest paths
adjacency = build_out_adjacency(edges)
# Initialize betweenness scores to 0
betweenness = Map.new(node_ids, fn node -> {node, 0.0} end)
# Compute betweenness using Brandes' algorithm
betweenness =
Enum.reduce(nodes_to_compute, betweenness, fn source, acc ->
compute_betweenness_from_source(source, adjacency, acc)
end)
# Normalize if requested
if normalize and map_size(betweenness) > 2 do
n = map_size(betweenness)
# Normalization factor for directed graphs
factor = (n - 1) * (n - 2)
betweenness
|> Map.new(fn {node, score} -> {node, score / factor} end)
else
betweenness
end
end
@doc """
Computes closeness centrality for nodes in the graph.
Closeness is the inverse of the average shortest path distance from a node
to all other reachable nodes. Higher scores indicate more "central" functions.
## Parameters
- `:normalize` - Return normalized scores 0-1 (default: true)
## Returns
Map of node_id => closeness_score
## Examples
# Compute closeness for all nodes
closeness_centrality()
# Get raw (unnormalized) scores
closeness_centrality(normalize: false)
"""
def closeness_centrality(opts \\ []) do
normalize = Keyword.get(opts, :normalize, true)
edges = get_call_edges()
adjacency = build_out_adjacency(edges)
# Get all nodes
node_ids =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
# Compute closeness for each node
node_ids
|> Map.new(fn node ->
{distances, _} = shortest_paths_bfs(node, adjacency)
# Calculate average distance to reachable nodes
reachable = Map.delete(distances, node)
if map_size(reachable) == 0 do
{node, 0.0}
else
total_distance = reachable |> Map.values() |> Enum.sum()
avg_distance = total_distance / map_size(reachable)
closeness =
if avg_distance > 0 do
1.0 / avg_distance
else
0.0
end
# Normalize by the fraction of reachable nodes
closeness =
if normalize do
n_total = length(node_ids)
n_reachable = map_size(reachable)
if n_total > 1 do
closeness * (n_reachable / (n_total - 1))
else
closeness
end
else
closeness
end
{node, closeness}
end
end)
end
@doc """
Detects communities in the graph using the Louvain method.
The Louvain algorithm iteratively optimizes modularity to discover communities.
It naturally produces a hierarchy through aggregation phases.
## Parameters
- `:max_iterations` - Maximum optimization iterations (default: 10)
- `:min_improvement` - Minimum modularity improvement to continue (default: 0.0001)
- `:resolution` - Resolution parameter for multi-scale detection (default: 1.0)
- `:hierarchical` - Return hierarchical community structure (default: false)
## Returns
- When `:hierarchical` is false: Map of community_id => [node_ids]
- When `:hierarchical` is true: Map with :communities, :hierarchy, :modularity_per_level
## Examples
# Detect communities
detect_communities()
# Get hierarchical structure
detect_communities(hierarchical: true)
# Adjust resolution for finer/coarser communities
detect_communities(resolution: 0.5)
"""
def detect_communities(opts \\ []) do
max_iterations = Keyword.get(opts, :max_iterations, 10)
min_improvement = Keyword.get(opts, :min_improvement, 0.0001)
resolution = Keyword.get(opts, :resolution, 1.0)
hierarchical = Keyword.get(opts, :hierarchical, false)
edges = get_call_edges()
# Get edge weights
edge_weights = get_edge_weights(edges)
total_weight = Enum.sum(Map.values(edge_weights))
# Get all nodes
node_ids =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
if node_ids == [] do
if hierarchical do
%{communities: %{}, hierarchy: [], modularity_per_level: []}
else
%{}
end
else
# Initial: each node in its own community
node_to_community = Map.new(node_ids, fn node -> {node, node} end)
result =
louvain_optimize(
node_to_community,
edges,
edge_weights,
total_weight,
resolution,
max_iterations,
min_improvement,
hierarchical
)
if hierarchical do
result
else
# Convert to community => [nodes] format
node_to_community = result.node_to_community
node_to_community
|> Enum.group_by(fn {_node, comm} -> comm end, fn {node, _comm} -> node end)
end
end
end
@doc """
Detects communities using label propagation algorithm.
Fast alternative to Louvain. Each node adopts the most common label among neighbors.
Converges quickly but results can be non-deterministic.
## Parameters
- `:max_iterations` - Maximum iterations (default: 20)
- `:seed` - Random seed for deterministic results (optional)
## Returns
Map of community_id => [node_ids]
## Examples
# Detect communities with label propagation
detect_communities_lp()
# With deterministic seed
detect_communities_lp(seed: 42)
"""
def detect_communities_lp(opts \\ []) do
max_iterations = Keyword.get(opts, :max_iterations, 20)
seed = Keyword.get(opts, :seed)
# Set random seed if provided
if seed, do: :rand.seed(:exsplus, {seed, seed, seed})
edges = get_call_edges()
adjacency = build_out_adjacency(edges)
in_adjacency = build_in_adjacency(edges)
# Get all nodes
node_ids =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
if node_ids == [] do
%{}
else
# Initial: each node gets its own label
labels = Map.new(node_ids, fn node -> {node, node} end)
# Iterate label propagation
final_labels =
label_propagation_iterate(node_ids, adjacency, in_adjacency, labels, max_iterations)
# Group by final labels
final_labels
|> Enum.group_by(fn {_node, label} -> label end, fn {node, _label} -> node end)
end
end
@doc """
Exports the graph in Graphviz DOT format.
## Parameters
- `:include_communities` - Include community clustering (default: true)
- `:color_by` - Centrality metric for node coloring: :pagerank, :betweenness, :degree (default: :pagerank)
- `:max_nodes` - Maximum nodes to include (default: from config, 500)
## Returns
`{:ok, dot_string}` or `{:error, reason}`
## Examples
# Export with default settings
{:ok, dot} = export_graphviz()
# Color by betweenness centrality
{:ok, dot} = export_graphviz(color_by: :betweenness)
"""
def export_graphviz(opts \\ []) do
include_communities = Keyword.get(opts, :include_communities, true)
color_by = Keyword.get(opts, :color_by, :pagerank)
default_max = Application.get_env(:ragex, :graph, []) |> Keyword.get(:max_nodes_export, 500)
max_nodes = Keyword.get(opts, :max_nodes, default_max)
edges = get_call_edges()
# Get all nodes
all_nodes =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
|> Enum.take(max_nodes)
if all_nodes == [] do
{:ok, "digraph G {\n}\n"}
else
# Compute metrics for coloring
metrics =
case color_by do
:pagerank ->
pagerank()
:betweenness ->
betweenness_centrality(max_nodes: max_nodes)
:degree ->
degree_centrality()
|> Map.new(fn {node, m} -> {node, m.total_degree / 1.0} end)
_ ->
%{}
end
# Detect communities if requested
communities = if include_communities, do: detect_communities(), else: %{}
# Build DOT string
dot = build_graphviz_dot(all_nodes, edges, communities, metrics, color_by)
{:ok, dot}
end
rescue
e -> {:error, Exception.message(e)}
end
@doc """
Exports the graph in D3.js force-directed JSON format.
## Parameters
- `:include_communities` - Include community metadata (default: true)
- `:max_nodes` - Maximum nodes to include (default: from config, 500)
## Returns
`{:ok, json_map}` or `{:error, reason}`
## Examples
# Export for D3.js visualization
{:ok, json} = export_d3_json()
File.write!("graph.json", Jason.encode!(json))
"""
def export_d3_json(opts \\ []) do
include_communities = Keyword.get(opts, :include_communities, true)
default_max = Application.get_env(:ragex, :graph, []) |> Keyword.get(:max_nodes_export, 500)
max_nodes = Keyword.get(opts, :max_nodes, default_max)
edges = get_call_edges()
# Get all nodes
all_nodes =
edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.uniq()
|> Enum.take(max_nodes)
if all_nodes == [] do
{:ok, %{nodes: [], links: []}}
else
# Get metrics
pagerank_scores = pagerank()
degree_metrics = degree_centrality()
# Detect communities
communities = if include_communities, do: detect_communities(), else: %{}
node_to_community = invert_community_map(communities)
# Build nodes list
nodes =
Enum.map(all_nodes, fn node ->
%{
id: format_node_id_string(node),
type: get_node_type(node),
pagerank: Map.get(pagerank_scores, node, 0.0),
degree: Map.get(degree_metrics, node, %{total_degree: 0}).total_degree,
community: Map.get(node_to_community, node)
}
end)
# Build edges list
node_set = MapSet.new(all_nodes)
links =
edges
|> Enum.filter(fn {from, to} ->
MapSet.member?(node_set, from) and MapSet.member?(node_set, to)
end)
|> Enum.map(fn {from, to} ->
weight = get_edge_weight_value(from, to)
%{
source: format_node_id_string(from),
target: format_node_id_string(to),
weight: weight
}
end)
{:ok, %{nodes: nodes, links: links}}
end
rescue
e -> {:error, Exception.message(e)}
end
@doc """
Computes comprehensive graph statistics.
Returns a map with:
- Node counts by type
- Edge counts
- Average degree
- Density
- Connected components count
- Top nodes by PageRank
"""
def graph_stats do
nodes = Store.list_nodes(nil, :infinity)
edges = get_call_edges()
node_counts =
nodes
|> Enum.group_by(& &1.type)
|> Map.new(fn {type, list} -> {type, length(list)} end)
centrality = degree_centrality()
degrees = centrality |> Map.values() |> Enum.map(& &1.total_degree)
avg_degree = if degrees != [], do: Enum.sum(degrees) / length(degrees), else: 0.0
n = length(nodes)
m = length(edges)
density = if n > 1, do: m / (n * (n - 1)), else: 0.0
# Compute PageRank
pr_scores = pagerank()
top_by_pagerank =
pr_scores
|> Enum.sort_by(fn {_id, score} -> -score end)
|> Enum.take(10)
%{
node_count: n,
node_counts_by_type: node_counts,
edge_count: m,
average_degree: Float.round(avg_degree, 2),
density: Float.round(density, 4),
top_nodes: top_by_pagerank
}
end
# Private functions
defp iterate_pagerank(
scores,
node_ids,
out_edges,
in_edges,
damping,
max_iter,
tolerance,
iter \\ 0
)
defp iterate_pagerank(
scores,
_node_ids,
_out_edges,
_in_edges,
_damping,
max_iter,
_tolerance,
iter
)
when iter >= max_iter do
scores
end
defp iterate_pagerank(scores, node_ids, out_edges, in_edges, damping, max_iter, tolerance, iter) do
n = length(node_ids)
base_score = (1.0 - damping) / n
# Compute new scores
new_scores =
Map.new(node_ids, fn node ->
# Sum contributions from incoming edges
incoming_contribution =
case Map.get(in_edges, node, []) do
[] ->
0.0
inbound ->
Enum.reduce(inbound, 0.0, fn from_node, acc ->
from_score = Map.get(scores, from_node, 0.0)
out_count = length(Map.get(out_edges, from_node, []))
if out_count > 0 do
acc + from_score / out_count
else
acc
end
end)
end
score = base_score + damping * incoming_contribution
{node, score}
end)
# Check convergence
max_diff =
Enum.reduce(node_ids, 0.0, fn node, max_d ->
old_score = Map.get(scores, node, 0.0)
new_score = Map.get(new_scores, node, 0.0)
diff = abs(new_score - old_score)
max(max_d, diff)
end)
if max_diff < tolerance do
new_scores
else
iterate_pagerank(
new_scores,
node_ids,
out_edges,
in_edges,
damping,
max_iter,
tolerance,
iter + 1
)
end
end
# Check if we've hit the max_paths limit (early stopping)
defp find_paths_dfs(_from, _to, _adjacency, _max_depth, max_paths, _path, _visited, count)
when count >= max_paths do
{[], count}
end
# Check if path length (number of edges) exceeds max_depth
# Path length in edges = number of nodes - 1
defp find_paths_dfs(_from, _to, _adjacency, max_depth, _max_paths, path, _visited, count)
when length(path) - 1 > max_depth do
{[], count}
end
@dialyzer {:nowarn_function, find_paths_dfs: 8}
defp find_paths_dfs(current, target, adjacency, max_depth, max_paths, path, visited, count) do
cond do
current == target ->
# Found a path, increment count
{[Enum.reverse(path)], count + 1}
length(path) - 1 >= max_depth ->
# Don't explore further if we've already reached max depth
{[], count}
true ->
explore_neighbors(adjacency, current, target, max_depth, max_paths, path, visited, count)
end
end
defp explore_neighbors(adjacency, current, target, max_depth, max_paths, path, visited, count) do
neighbors = Map.get(adjacency, current, [])
# Accumulate paths and count with early stopping
neighbors
|> Enum.reject(&MapSet.member?(visited, &1))
|> Enum.reduce({[], count}, fn neighbor, {acc_paths, acc_count} ->
explore_neighbor(
neighbor,
{target, adjacency, max_depth, max_paths, path, visited},
{acc_paths, acc_count}
)
end)
end
@dialyzer {:nowarn_function, explore_neighbor: 3}
defp explore_neighbor(
neighbor,
{target, adjacency, max_depth, max_paths, path, visited},
{acc_paths, acc_count}
) do
# Stop exploring if we've hit max_paths
if acc_count >= max_paths do
{acc_paths, acc_count}
else
new_path = [neighbor | path]
new_visited = MapSet.put(visited, neighbor)
{new_paths, new_count} =
find_paths_dfs(
neighbor,
target,
adjacency,
max_depth,
max_paths,
new_path,
new_visited,
acc_count
)
{acc_paths ++ new_paths, new_count}
end
end
defp get_call_edges do
# Get all :calls edges from the edges table
# Edges are stored as {{from_node, to_node, edge_type}, metadata}
:ets.match(:ragex_edges, {{:"$1", :"$2", :calls}, :_})
|> Enum.map(fn [from, to] -> {from, to} end)
|> Enum.uniq()
end
defp build_adjacency(edges) do
out_edges =
edges
|> Enum.group_by(fn {from, _to} -> from end, fn {_from, to} -> to end)
in_edges =
edges
|> Enum.group_by(fn {_from, to} -> to end, fn {from, _to} -> from end)
{out_edges, in_edges}
end
defp build_out_adjacency(edges) do
edges
|> Enum.group_by(fn {from, _to} -> from end, fn {_from, to} -> to end)
end
# Check if starting node has high degree (potential for exponential explosion)
defp check_dense_graph(from, adjacency) do
out_degree = length(Map.get(adjacency, from, []))
cond do
out_degree >= 20 ->
require Logger
Logger.warning(
"Dense graph detected: Node #{inspect(from)} has #{out_degree} outgoing edges. " <>
"Path finding may be slow or return partial results. Consider reducing max_depth or max_paths."
)
out_degree >= 10 ->
require Logger
Logger.info(
"Moderately connected node: #{inspect(from)} has #{out_degree} outgoing edges. " <>
"Path finding may take some time."
)
true ->
:ok
end
end
# BFS-based shortest paths from a source node
# Returns {distances, path_counts} maps
defp shortest_paths_bfs(source, adjacency) do
# Initialize
distances = %{source => 0}
path_counts = %{source => 1}
queue = :queue.from_list([source])
bfs_iterate(queue, adjacency, distances, path_counts)
end
defp bfs_iterate(queue, adjacency, distances, path_counts) do
case :queue.out(queue) do
{{:value, node}, rest_queue} ->
current_dist = Map.get(distances, node)
neighbors = Map.get(adjacency, node, [])
# Process each neighbor
{new_queue, new_distances, new_path_counts} =
Enum.reduce(neighbors, {rest_queue, distances, path_counts}, fn neighbor,
{q, dists, counts} ->
neighbor_dist = Map.get(dists, neighbor)
cond do
# First time visiting this node
neighbor_dist == nil ->
new_dists = Map.put(dists, neighbor, current_dist + 1)
new_counts = Map.put(counts, neighbor, Map.get(counts, node, 1))
new_q = :queue.in(neighbor, q)
{new_q, new_dists, new_counts}
# Found another shortest path to this node
neighbor_dist == current_dist + 1 ->
old_count = Map.get(counts, neighbor, 0)
node_count = Map.get(counts, node, 1)
new_counts = Map.put(counts, neighbor, old_count + node_count)
{q, dists, new_counts}
# Already visited via a shorter path
true ->
{q, dists, counts}
end
end)
bfs_iterate(new_queue, adjacency, new_distances, new_path_counts)
{:empty, _} ->
{distances, path_counts}
end
end
# Get edge weights for all edges
defp get_edge_weights(edges) do
edges
|> Map.new(fn {from, to} ->
weight = Store.get_edge_weight(from, to, :calls) || 1.0
{{from, to}, weight}
end)
end
# Get single edge weight
defp get_edge_weight_value(from, to) do
Store.get_edge_weight(from, to, :calls) || 1.0
end
# Build incoming adjacency
defp build_in_adjacency(edges) do
edges
|> Enum.group_by(fn {_from, to} -> to end, fn {from, _to} -> from end)
end
# Louvain optimization with optional hierarchical tracking
defp louvain_optimize(
node_to_community,
edges,
edge_weights,
total_weight,
resolution,
max_iterations,
min_improvement,
hierarchical
) do
hierarchy = if hierarchical, do: [], else: nil
modularity_history = if hierarchical, do: [], else: nil
initial_modularity =
compute_modularity(node_to_community, edges, edge_weights, total_weight, resolution)
result =
louvain_iterate(
node_to_community,
edges,
edge_weights,
total_weight,
resolution,
initial_modularity,
max_iterations,
min_improvement,
0,
hierarchy,
modularity_history
)
if hierarchical do
final_communities =
result.node_to_community
|> Enum.group_by(fn {_node, comm} -> comm end, fn {node, _comm} -> node end)
%{
communities: final_communities,
hierarchy: result.hierarchy || [],
modularity_per_level: result.modularity_history || [],
node_to_community: result.node_to_community
}
else
result
end
end
# credo:disable-for-next-line
defp louvain_iterate(
node_to_community,
_edges,
_edge_weights,
_total_weight,
_resolution,
_current_modularity,
max_iterations,
_min_improvement,
iteration,
hierarchy,
modularity_history
)
when iteration >= max_iterations do
%{
node_to_community: node_to_community,
hierarchy: hierarchy,
modularity_history: modularity_history
}
end
# credo:disable-for-next-line
defp louvain_iterate(
node_to_community,
edges,
edge_weights,
total_weight,
resolution,
current_modularity,
max_iterations,
min_improvement,
iteration,
hierarchy,
modularity_history
) do
# Phase 1: Local optimization
{new_node_to_community, improved} =
louvain_local_move(node_to_community, edges, edge_weights, total_weight, resolution)
new_modularity =
compute_modularity(new_node_to_community, edges, edge_weights, total_weight, resolution)
improvement = new_modularity - current_modularity
# Track hierarchy if requested
new_hierarchy =
if hierarchy != nil do
communities =
new_node_to_community
|> Enum.group_by(fn {_node, comm} -> comm end, fn {node, _comm} -> node end)
hierarchy ++ [%{level: iteration, communities: communities, modularity: new_modularity}]
else
nil
end
new_modularity_history =
if modularity_history != nil do
modularity_history ++ [new_modularity]
else
nil
end
# Check if we should continue
if improved and improvement > min_improvement do
louvain_iterate(
new_node_to_community,
edges,
edge_weights,
total_weight,
resolution,
new_modularity,
max_iterations,
min_improvement,
iteration + 1,
new_hierarchy,
new_modularity_history
)
else
%{
node_to_community: new_node_to_community,
hierarchy: new_hierarchy,
modularity_history: new_modularity_history
}
end
end
# Local move phase of Louvain: try moving each node to neighbor's community
defp louvain_local_move(node_to_community, edges, edge_weights, total_weight, resolution) do
# Build adjacency
adjacency = build_out_adjacency(edges)
in_adjacency = build_in_adjacency(edges)
nodes = Map.keys(node_to_community)
# Try moving each node
Enum.reduce(nodes, {node_to_community, false}, fn node, {current_assignment, any_improved} ->
current_comm = Map.get(current_assignment, node)
# Get neighboring communities
out_neighbors = Map.get(adjacency, node, [])
in_neighbors = Map.get(in_adjacency, node, [])
neighbor_communities =
(out_neighbors ++ in_neighbors)
|> Enum.map(fn neighbor -> Map.get(current_assignment, neighbor) end)
|> Enum.uniq()
|> Enum.reject(&(&1 == current_comm))
# Calculate modularity gain for each move
best_move =
neighbor_communities
|> Enum.map(fn target_comm ->
gain =
modularity_gain(
node,
current_comm,
target_comm,
current_assignment,
edges,
edge_weights,
total_weight,
resolution
)
{target_comm, gain}
end)
|> Enum.max_by(fn {_comm, gain} -> gain end, fn -> {current_comm, 0.0} end)
{best_comm, best_gain} = best_move
if best_gain > 0.0 do
{Map.put(current_assignment, node, best_comm), true}
else
{current_assignment, any_improved}
end
end)
end
# Compute modularity Q
defp compute_modularity(node_to_community, edges, edge_weights, total_weight, resolution) do
if total_weight == 0 do
0.0
else
# Compute internal edges weight for each community
internal_weight =
edges
|> Enum.reduce(0.0, fn {from, to}, acc ->
from_comm = Map.get(node_to_community, from)
to_comm = Map.get(node_to_community, to)
weight = Map.get(edge_weights, {from, to}, 1.0)
if from_comm == to_comm do
acc + weight
else
acc
end
end)
# Compute degree sums for each community
community_degrees =
node_to_community
|> Enum.group_by(fn {_node, comm} -> comm end, fn {node, _comm} -> node end)
|> Map.new(fn {comm, nodes} ->
degree_sum =
edges
|> Enum.reduce(0.0, fn {from, to}, acc ->
weight = Map.get(edge_weights, {from, to}, 1.0)
cond do
Enum.member?(nodes, from) -> acc + weight
Enum.member?(nodes, to) -> acc + weight
true -> acc
end
end)
{comm, degree_sum}
end)
expected_weight =
community_degrees
|> Map.values()
|> Enum.reduce(0.0, fn degree_sum, acc ->
acc + degree_sum * degree_sum / (2.0 * total_weight)
end)
resolution * (internal_weight / total_weight - expected_weight / total_weight)
end
end
# Calculate modularity gain from moving a node to a new community
defp modularity_gain(
node,
from_comm,
to_comm,
node_to_community,
edges,
edge_weights,
total_weight,
resolution
) do
# This is a simplified version; full implementation would track community stats
# For now, compute delta Q approximately
# Edges from node to each community
edges_to_comm = fn comm ->
edges
|> Enum.reduce(0.0, fn {from, to}, acc ->
weight = Map.get(edge_weights, {from, to}, 1.0)
to_comm_actual = Map.get(node_to_community, to)
from_comm_actual = Map.get(node_to_community, from)
cond do
from == node and to_comm_actual == comm -> acc + weight
to == node and from_comm_actual == comm -> acc + weight
true -> acc
end
end)
end
edges_to_from = edges_to_comm.(from_comm)
edges_to_to = edges_to_comm.(to_comm)
# Simplified gain calculation
gain = resolution * ((edges_to_to - edges_to_from) / total_weight)
gain
end
# Label propagation iteration
defp label_propagation_iterate(_node_ids, _adjacency, _in_adjacency, labels, 0) do
labels
end
defp label_propagation_iterate(node_ids, adjacency, in_adjacency, labels, iterations_left) do
# Randomize node order for better convergence
shuffled_nodes = Enum.shuffle(node_ids)
new_labels =
Enum.reduce(shuffled_nodes, labels, fn node, current_labels ->
# Get all neighbors (in + out)
out_neighbors = Map.get(adjacency, node, [])
in_neighbors = Map.get(in_adjacency, node, [])
all_neighbors = out_neighbors ++ in_neighbors
if all_neighbors == [] do
current_labels
else
# Count label frequencies among neighbors
label_counts =
all_neighbors
|> Enum.map(fn neighbor -> Map.get(current_labels, neighbor) end)
|> Enum.frequencies()
# Pick most common label (ties broken randomly)
{most_common_label, _count} =
label_counts
|> Enum.max_by(fn {_label, count} -> count end)
Map.put(current_labels, node, most_common_label)
end
end)
# Check for convergence
if new_labels == labels do
labels
else
label_propagation_iterate(
node_ids,
adjacency,
in_adjacency,
new_labels,
iterations_left - 1
)
end
end
# Build Graphviz DOT string
defp build_graphviz_dot(nodes, edges, communities, metrics, _color_by) do
# Start graph
lines = ["digraph G {"]
lines = lines ++ [" rankdir=LR;"]
lines = lines ++ [" node [shape=box, style=filled];"]
lines = lines ++ [""]
# Get max metric value for color scaling
max_metric =
if map_size(metrics) > 0 do
metrics |> Map.values() |> Enum.max()
else
1.0
end
# Group nodes by community for clustering
node_to_community = invert_community_map(communities)
nodes_by_community =
if map_size(communities) > 0 do
Enum.group_by(nodes, fn node -> Map.get(node_to_community, node, 0) end)
else
%{0 => nodes}
end
# Add nodes with communities as subgraphs
lines =
Enum.reduce(nodes_by_community, lines, fn {comm_id, comm_nodes}, acc ->
if map_size(communities) > 0 do
# Create safe cluster ID (alphanumeric only) for the subgraph name
safe_comm_id = safe_cluster_id(comm_id)
# Create readable label showing the actual community ID
readable_label = format_community_label(comm_id, length(comm_nodes))
acc = acc ++ [" subgraph cluster_#{safe_comm_id} {"]
acc = acc ++ [" label = \"#{readable_label}\";"]
acc = acc ++ [" style = filled;"]
acc = acc ++ [" color = lightgrey;"]
acc = acc ++ [""]
acc =
Enum.reduce(comm_nodes, acc, fn node, node_acc ->
node_acc ++ [format_dot_node(node, metrics, max_metric)]
end)
acc ++ [" }", ""]
else
Enum.reduce(comm_nodes, acc, fn node, node_acc ->
node_acc ++ [format_dot_node(node, metrics, max_metric)]
end)
end
end)
# Add edges
node_set = MapSet.new(nodes)
lines = lines ++ [""]
lines =
edges
|> Enum.filter(fn {from, to} ->
MapSet.member?(node_set, from) and MapSet.member?(node_set, to)
end)
|> Enum.reduce(lines, fn {from, to}, acc ->
weight = get_edge_weight_value(from, to)
# Scale edge thickness
penwidth = 1.0 + (weight - 1.0) * 2.0
from_str = format_node_id_dot(from)
to_str = format_node_id_dot(to)
acc ++ [" #{from_str} -> #{to_str} [penwidth=#{Float.round(penwidth, 1)}];"]
end)
lines = lines ++ ["}"]
Enum.join(lines, "\n") <> "\n"
end
defp format_dot_node(node, metrics, max_metric) do
node_str = format_node_id_dot(node)
label = format_node_id_string(node)
metric_value = Map.get(metrics, node, 0.0)
# Color from white (0) to red (max)
intensity = if max_metric > 0, do: metric_value / max_metric, else: 0.0
# HSV: 0 = red, reduce saturation for lighter colors
color = "\"0.0 #{Float.round(intensity, 2)} 1.0\""
" #{node_str} [label=\"#{String.replace(label, "\"", "\\\"")}\", fillcolor=#{color}];"
end
defp format_node_id_dot(node) do
# Create valid DOT identifier
"n_" <> (format_node_id_string(node) |> String.replace(~r/[^a-zA-Z0-9_]/, "_"))
end
defp safe_cluster_id(comm_id) do
# Convert community ID to safe alphanumeric string
cond do
is_integer(comm_id) ->
Integer.to_string(comm_id)
is_atom(comm_id) ->
Atom.to_string(comm_id) |> String.replace(~r/[^a-zA-Z0-9_]/, "_")
is_tuple(comm_id) ->
# Hash the tuple to get a consistent integer ID
:erlang.phash2(comm_id) |> Integer.to_string()
true ->
# Fallback: hash any other type
:erlang.phash2(comm_id) |> Integer.to_string()
end
end
defp format_community_label(comm_id, node_count) do
# Create readable label for community
label_text =
cond do
is_integer(comm_id) ->
"Community #{comm_id}"
is_atom(comm_id) ->
"Community: #{Atom.to_string(comm_id)}"
is_tuple(comm_id) ->
# For tuple (likely a function node), show a shortened version
case comm_id do
{:function, module, name, arity} ->
"Cluster: #{module}.#{name}/#{arity}"
{:module, id} ->
"Module: #{id}"
_ ->
# For other tuples, show first element or hash
elem_count = tuple_size(comm_id)
if elem_count > 0 do
first = elem(comm_id, 0)
"Community: #{inspect(first)}... (#{elem_count} elements)"
else
"Community #{:erlang.phash2(comm_id)}"
end
end
true ->
"Community #{:erlang.phash2(comm_id)}"
end
# Add node count
"#{label_text} (#{node_count} nodes)"
end
defp format_node_id_string(node) do
case node do
{:function, module, name, arity} -> "#{trim_elixir_prefix(module)}.#{name}/#{arity}"
{:module, id} -> trim_elixir_prefix(id)
_ -> inspect(node)
end
end
defp trim_elixir_prefix(atom) when is_atom(atom) do
atom
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
end
defp trim_elixir_prefix(other), do: to_string(other)
defp get_node_type(node) do
case node do
{:function, _, _, _} -> "function"
{:module, _} -> "module"
_ -> "unknown"
end
end
defp invert_community_map(communities) do
communities
|> Enum.flat_map(fn {comm_id, nodes} ->
Enum.map(nodes, fn node -> {node, comm_id} end)
end)
|> Map.new()
end
# Compute betweenness contribution from a single source using Brandes' algorithm
defp compute_betweenness_from_source(source, adjacency, betweenness) do
# Run BFS to get distances and path counts
{distances, path_counts} = shortest_paths_bfs(source, adjacency)
# Build predecessor lists (nodes that lead to each node on shortest paths)
predecessors =
Enum.reduce(distances, %{}, fn {node, dist}, acc ->
if node == source do
acc
else
preds =
adjacency
|> Enum.filter(fn {pred_node, neighbors} ->
pred_node != node and Enum.member?(neighbors, node) and
Map.get(distances, pred_node, :infinity) == dist - 1
end)
|> Enum.map(fn {pred_node, _} -> pred_node end)
Map.put(acc, node, preds)
end
end)
# Compute dependency scores (bottom-up from furthest nodes)
nodes_by_distance =
distances
|> Enum.sort_by(fn {_node, dist} -> -dist end)
|> Enum.map(fn {node, _dist} -> node end)
dependency = Map.new(distances, fn {node, _} -> {node, 0.0} end)
{_, updated_betweenness} =
Enum.reduce(nodes_by_distance, {dependency, betweenness}, fn node, {dep, btwn} ->
if node == source do
{dep, btwn}
else
preds = Map.get(predecessors, node, [])
node_dep = Map.get(dep, node, 0.0)
node_paths = Map.get(path_counts, node, 1)
# Distribute dependency to predecessors
new_dep =
Enum.reduce(preds, dep, fn pred, acc_dep ->
pred_paths = Map.get(path_counts, pred, 1)
contribution = pred_paths / node_paths * (1.0 + node_dep)
Map.update(acc_dep, pred, contribution, &(&1 + contribution))
end)
# Update betweenness for this node (but not the source)
new_btwn = Map.update(btwn, node, node_dep, &(&1 + node_dep))
{new_dep, new_btwn}
end
end)
updated_betweenness
end
end