Current section

Files

Jump to
yog src yog@centrality.erl
Raw

src/yog@centrality.erl

-module(yog@centrality).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/yog/centrality.gleam").
-export([degree/2, closeness/5, harmonic_centrality/5, degree_total/1, closeness_int/1, closeness_float/1, harmonic_centrality_int/1, harmonic_centrality_float/1, default_pagerank_options/0, betweenness/5, pagerank/2, eigenvector/3, katz/5, alpha_centrality/5, betweenness_int/1, betweenness_float/1]).
-export_type([degree_mode/0, page_rank_options/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Centrality measures for identifying important nodes in graphs.\n"
"\n"
" Provides degree, closeness, harmonic, betweenness, and PageRank centrality.\n"
" All functions return a Dict(NodeId, Float) mapping nodes to their scores.\n"
"\n"
" ## Overview\n"
"\n"
" | Measure | Function | Best For |\n"
" |---------|----------|----------|\n"
" | Degree | `degree/2` | Local connectivity |\n"
" | Closeness | `closeness/5` | Distance to all others |\n"
" | Harmonic | `harmonic_centrality/5` | Disconnected graphs |\n"
" | Betweenness | `betweenness/5` | Bridge/gatekeeper detection |\n"
" | PageRank | `pagerank/2` | Link-quality importance |\n"
).
-type degree_mode() :: in_degree | out_degree | total_degree.
-type page_rank_options() :: {page_rank_options, float(), integer(), float()}.
-file("src/yog/centrality.gleam", 60).
?DOC(
" Calculates the Degree Centrality for all nodes in the graph.\n"
" \n"
" For directed graphs, use `mode` to specify which edges to count.\n"
" For undirected graphs, the `mode` is ignored.\n"
"\n"
" ## Interpreting Degree Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | `1.0` | The node is connected to every other node (hub) |\n"
" | `0.5` | The node is connected to half the other nodes |\n"
" | `0.0` | Isolated node — no connections |\n"
).
-spec degree(yog@model:graph(any(), any()), degree_mode()) -> gleam@dict:dict(integer(), float()).
degree(Graph, Mode) ->
N = yog@model:order(Graph),
Factor = case N > 1 of
true ->
erlang:float(N - 1);
false ->
1.0
end,
gleam@list:fold(
yog@model:all_nodes(Graph),
maps:new(),
fun(Acc, Id) ->
Count = case erlang:element(2, Graph) of
undirected ->
erlang:length(yog@model:neighbors(Graph, Id));
directed ->
case Mode of
in_degree ->
erlang:length(yog@model:predecessors(Graph, Id));
out_degree ->
erlang:length(yog@model:successors(Graph, Id));
total_degree ->
erlang:length(yog@model:successors(Graph, Id)) + erlang:length(
yog@model:predecessors(Graph, Id)
)
end
end,
gleam@dict:insert(Acc, Id, case Factor of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> erlang:float(Count) / Gleam@denominator
end)
end
).
-file("src/yog/centrality.gleam", 123).
?DOC(
" Calculates Closeness Centrality for all nodes.\n"
"\n"
" Closeness centrality measures how close a node is to all other nodes\n"
" in the graph. It is calculated as the reciprocal of the sum of the\n"
" shortest path distances from the node to all other nodes.\n"
"\n"
" Formula: C(v) = (n - 1) / Σ d(v, u) for all u ≠ v\n"
"\n"
" Note: In disconnected graphs, nodes that cannot reach all other nodes\n"
" will have a centrality of 0.0. Consider harmonic_centrality for \n"
" disconnected graphs.\n"
"\n"
" **Time Complexity:** O(V * (V + E) log V) using Dijkstra from each node\n"
"\n"
" ## Parameters\n"
"\n"
" - `zero`: The identity element for distances (e.g., 0 for integers)\n"
" - `add`: Function to add two distances\n"
" - `compare`: Function to compare two distances\n"
" - `to_float`: Function to convert distance type to Float for final score\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" centrality.closeness(\n"
" graph,\n"
" with_zero: 0,\n"
" with_add: int.add,\n"
" with_compare: int.compare,\n"
" with_to_float: int.to_float,\n"
" )\n"
" // => dict.from_list([#(1, 0.666), #(2, 1.0), #(3, 0.666)])\n"
" ```\n"
"\n"
" ## Interpreting Closeness Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | `1.0` | The node is one hop away from all others (e.g. center of a star) |\n"
" | `0.5` | The node is typically 2 hops away from others |\n"
" | `0.0` | The node cannot reach everyone (disconnected or isolated) |\n"
).
-spec closeness(
yog@model:graph(any(), JTR),
JTR,
fun((JTR, JTR) -> JTR),
fun((JTR, JTR) -> gleam@order:order()),
fun((JTR) -> float())
) -> gleam@dict:dict(integer(), float()).
closeness(Graph, Zero, Add, Compare, To_float) ->
Nodes = yog@model:all_nodes(Graph),
N = erlang:length(Nodes),
case N =< 1 of
true ->
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, +0.0) end
);
false ->
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc@1, Source) ->
Distances = yog@pathfinding@dijkstra:single_source_distances(
Graph,
Source,
Zero,
Add,
Compare
),
case maps:size(Distances) =:= N of
false ->
gleam@dict:insert(Acc@1, Source, +0.0);
true ->
Total_distance = gleam@dict:fold(
Distances,
Zero,
fun(Sum, _, Dist) -> Add(Sum, Dist) end
),
Centrality_score = case To_float(Total_distance) of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> erlang:float(N - 1) / Gleam@denominator
end,
gleam@dict:insert(Acc@1, Source, Centrality_score)
end
end
)
end.
-file("src/yog/centrality.gleam", 180).
?DOC(
" Calculates Harmonic Centrality for all nodes.\n"
"\n"
" Harmonic centrality is a variation of closeness centrality that handles\n"
" disconnected graphs gracefully. It sums the reciprocals of the shortest \n"
" path distances from a node to all other reachable nodes.\n"
"\n"
" Formula: H(v) = Σ (1 / d(v, u)) / (n - 1) for all u ≠ v\n"
"\n"
" **Time Complexity:** O(V * (V + E) log V)\n"
"\n"
" ## Interpreting Harmonic Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | `1.0` | The node is directly connected to all others |\n"
" | `0.5` | The node is directly connected to half the others |\n"
" | `0.0` | Isolated node — cannot reach anyone else |\n"
"\n"
" Unlike closeness, disconnected nodes still receive credit for the\n"
" neighbors they *can* reach rather than being penalized with `0.0`.\n"
).
-spec harmonic_centrality(
yog@model:graph(any(), JTV),
JTV,
fun((JTV, JTV) -> JTV),
fun((JTV, JTV) -> gleam@order:order()),
fun((JTV) -> float())
) -> gleam@dict:dict(integer(), float()).
harmonic_centrality(Graph, Zero, Add, Compare, To_float) ->
Nodes = yog@model:all_nodes(Graph),
N = erlang:length(Nodes),
case N =< 1 of
true ->
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, +0.0) end
);
false ->
Denominator = erlang:float(N - 1),
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc@1, Source) ->
Distances = yog@pathfinding@dijkstra:single_source_distances(
Graph,
Source,
Zero,
Add,
Compare
),
Sum_of_reciprocals = gleam@dict:fold(
Distances,
+0.0,
fun(Sum, Node, Dist) -> case Node =:= Source of
true ->
Sum;
false ->
D = To_float(Dist),
case D > +0.0 of
true ->
Sum + (case D of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> 1.0 / Gleam@denominator
end);
false ->
Sum
end
end end
),
gleam@dict:insert(Acc@1, Source, case Denominator of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator@1 -> Sum_of_reciprocals / Gleam@denominator@1
end)
end
)
end.
-file("src/yog/centrality.gleam", 279).
-spec scale_all(gleam@dict:dict(integer(), float()), float()) -> gleam@dict:dict(integer(), float()).
scale_all(Scores, Factor) ->
gleam@dict:map_values(Scores, fun(_, Score) -> Score * Factor end).
-file("src/yog/centrality.gleam", 283).
-spec apply_undirected_scaling(
gleam@dict:dict(integer(), float()),
yog@model:graph_type()
) -> gleam@dict:dict(integer(), float()).
apply_undirected_scaling(Scores, Kind) ->
case Kind of
undirected ->
scale_all(Scores, 0.5);
directed ->
Scores
end.
-file("src/yog/centrality.gleam", 446).
-spec get_in_neighbors(yog@model:graph(any(), any()), integer()) -> list(integer()).
get_in_neighbors(Graph, Node) ->
case erlang:element(2, Graph) of
undirected ->
_pipe = yog@model:successors(Graph, Node),
gleam@list:map(_pipe, fun(Edge) -> erlang:element(1, Edge) end);
directed ->
_pipe@1 = yog@model:predecessors(Graph, Node),
gleam@list:map(
_pipe@1,
fun(Edge@1) -> erlang:element(1, Edge@1) end
)
end.
-file("src/yog/centrality.gleam", 457).
-spec get_out_degree(yog@model:graph(any(), any()), integer()) -> integer().
get_out_degree(Graph, Node) ->
case erlang:element(2, Graph) of
undirected ->
_pipe = yog@model:neighbors(Graph, Node),
erlang:length(_pipe);
directed ->
_pipe@1 = yog@model:successors(Graph, Node),
erlang:length(_pipe@1)
end.
-file("src/yog/centrality.gleam", 895).
?DOC(
" Degree centrality with default options for undirected graphs.\n"
" Uses TotalDegree mode.\n"
).
-spec degree_total(yog@model:graph(any(), any())) -> gleam@dict:dict(integer(), float()).
degree_total(Graph) ->
degree(Graph, total_degree).
-file("src/yog/centrality.gleam", 901).
?DOC(
" Closeness centrality with **Int** weights (e.g., unweighted graphs).\n"
" Uses 0 as zero, int.add, int.compare, and int.to_float.\n"
).
-spec closeness_int(yog@model:graph(any(), integer())) -> gleam@dict:dict(integer(), float()).
closeness_int(Graph) ->
closeness(
Graph,
0,
fun gleam@int:add/2,
fun gleam@int:compare/2,
fun erlang:float/1
).
-file("src/yog/centrality.gleam", 913).
?DOC(
" Closeness centrality with **Float** weights.\n"
" Uses 0.0 as zero, float.add, float.compare, and identity.\n"
).
-spec closeness_float(yog@model:graph(any(), float())) -> gleam@dict:dict(integer(), float()).
closeness_float(Graph) ->
closeness(
Graph,
+0.0,
fun gleam@float:add/2,
fun gleam@float:compare/2,
fun(X) -> X end
).
-file("src/yog/centrality.gleam", 924).
?DOC(" Harmonic centrality with **Int** weights.\n").
-spec harmonic_centrality_int(yog@model:graph(any(), integer())) -> gleam@dict:dict(integer(), float()).
harmonic_centrality_int(Graph) ->
harmonic_centrality(
Graph,
0,
fun gleam@int:add/2,
fun gleam@int:compare/2,
fun erlang:float/1
).
-file("src/yog/centrality.gleam", 935).
?DOC(" Harmonic centrality with **Float** weights.\n").
-spec harmonic_centrality_float(yog@model:graph(any(), float())) -> gleam@dict:dict(integer(), float()).
harmonic_centrality_float(Graph) ->
harmonic_centrality(
Graph,
+0.0,
fun gleam@float:add/2,
fun gleam@float:compare/2,
fun(X) -> X end
).
-file("src/yog/centrality.gleam", 968).
?DOC(" Default PageRank options (damping=0.85, max_iterations=100, tolerance=0.0001).\n").
-spec default_pagerank_options() -> page_rank_options().
default_pagerank_options() ->
{page_rank_options, 0.85, 100, 0.0001}.
-file("src/yog/centrality.gleam", 972).
-spec dict_get_float(gleam@dict:dict(integer(), float()), integer()) -> float().
dict_get_float(Dict, Key) ->
_pipe = gleam_stdlib:map_get(Dict, Key),
gleam@result:unwrap(_pipe, +0.0).
-file("src/yog/centrality.gleam", 263).
-spec merge_scores(
gleam@dict:dict(integer(), float()),
gleam@dict:dict(integer(), float()),
integer()
) -> gleam@dict:dict(integer(), float()).
merge_scores(Acc, Dependencies, Source) ->
gleam@dict:fold(
Dependencies,
Acc,
fun(Acc2, Node, Delta) -> case Node =:= Source of
true ->
Acc2;
false ->
Current = dict_get_float(Acc2, Node),
gleam@dict:insert(Acc2, Node, Current + Delta)
end end
).
-file("src/yog/centrality.gleam", 242).
?DOC(
" Calculates Betweenness Centrality for all nodes.\n"
" \n"
" Betweenness centrality of a node v is the sum of the fraction of \n"
" all-pairs shortest paths that pass through v.\n"
"\n"
" **Time Complexity:** O(VE) for unweighted, O(VE + V²logV) for weighted.\n"
"\n"
" ## Interpreting Betweenness Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | **High** | The node is a bridge or gatekeeper — many shortest paths go through it |\n"
" | **Low** | The node is peripheral — most paths bypass it |\n"
" | `0.0` | The node lies on no shortest paths between any other pair |\n"
"\n"
" A high betweenness node is critical for network connectivity:\n"
" removing it can fragment the graph or severely increase path lengths.\n"
).
-spec betweenness(
yog@model:graph(any(), JTZ),
JTZ,
fun((JTZ, JTZ) -> JTZ),
fun((JTZ, JTZ) -> gleam@order:order()),
fun((JTZ) -> float())
) -> gleam@dict:dict(integer(), float()).
betweenness(Graph, Zero, Add, Compare, _) ->
Nodes = yog@model:all_nodes(Graph),
Initial = gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, +0.0) end
),
Scores = begin
gleam@list:fold(
Nodes,
Initial,
fun(Acc@1, S) ->
Discovery = yog@internal@brandes:run_discovery(
Graph,
S,
Zero,
Add,
Compare
),
Dependencies = yog@internal@brandes:accumulate_node_dependencies(
Discovery
),
merge_scores(Acc@1, Dependencies, S)
end
)
end,
apply_undirected_scaling(Scores, erlang:element(2, Graph)).
-file("src/yog/centrality.gleam", 432).
-spec calculate_sink_sum(
yog@model:graph(any(), any()),
gleam@dict:dict(integer(), float()),
list(integer()),
float()
) -> float().
calculate_sink_sum(Graph, Ranks, Nodes, N_float) ->
gleam@list:fold(
Nodes,
+0.0,
fun(Sum, Node) ->
Out_degree = get_out_degree(Graph, Node),
case Out_degree =:= 0 of
true ->
Sum + (case N_float of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> dict_get_float(Ranks, Node) / Gleam@denominator
end);
false ->
Sum
end
end
).
-file("src/yog/centrality.gleam", 468).
-spec calculate_l1_norm(
gleam@dict:dict(integer(), float()),
gleam@dict:dict(integer(), float()),
list(integer())
) -> float().
calculate_l1_norm(Old_ranks, New_ranks, Nodes) ->
gleam@list:fold(
Nodes,
+0.0,
fun(Sum, Node) ->
Old_val = dict_get_float(Old_ranks, Node),
New_val = dict_get_float(New_ranks, Node),
Sum + gleam@float:absolute_value(New_val - Old_val)
end
).
-file("src/yog/centrality.gleam", 387).
-spec iterate_pagerank(
yog@model:graph(any(), any()),
gleam@dict:dict(integer(), float()),
list(integer()),
integer(),
page_rank_options(),
integer()
) -> gleam@dict:dict(integer(), float()).
iterate_pagerank(Graph, Ranks, Nodes, N, Options, Iteration) ->
case Iteration >= erlang:element(3, Options) of
true ->
Ranks;
false ->
Damping = erlang:element(2, Options),
N_float = erlang:float(N),
Sink_sum = calculate_sink_sum(Graph, Ranks, Nodes, N_float),
New_ranks = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Node) ->
Rank_sum = begin
gleam@list:fold(
get_in_neighbors(Graph, Node),
+0.0,
fun(Sum, Neighbor) ->
Neighbor_rank = dict_get_float(
Ranks,
Neighbor
),
Out_degree = get_out_degree(Graph, Neighbor),
case Out_degree > 0 of
true ->
Sum + (case erlang:float(Out_degree) of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> Neighbor_rank
/ Gleam@denominator
end);
false ->
Sum
end
end
)
end,
New_rank = (case N_float of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator@1 -> (1.0 - Damping) / Gleam@denominator@1
end) + (Damping * (Sink_sum + Rank_sum)),
gleam@dict:insert(Acc, Node, New_rank)
end
)
end,
L1_norm = calculate_l1_norm(Ranks, New_ranks, Nodes),
case L1_norm < erlang:element(4, Options) of
true ->
New_ranks;
false ->
iterate_pagerank(
Graph,
New_ranks,
Nodes,
N,
Options,
Iteration + 1
)
end
end.
-file("src/yog/centrality.gleam", 374).
?DOC(
" Calculates PageRank centrality for all nodes.\n"
"\n"
" PageRank measures node importance based on the quality and quantity of\n"
" incoming links. A node is important if it is linked to by other important\n"
" nodes. Originally developed for ranking web pages, it's useful for:\n"
"\n"
" - Ranking nodes in directed networks\n"
" - Identifying influential nodes in citation networks\n"
" - Finding important entities in knowledge graphs\n"
" - Recommendation systems\n"
"\n"
" The algorithm uses a \"random surfer\" model: with probability `damping`,\n"
" the surfer follows a random outgoing link; otherwise, they jump to any\n"
" random node. This models both link-following behavior and the possibility\n"
" of starting a new browsing session.\n"
"\n"
" **Time Complexity:** O(max_iterations × (V + E))\n"
"\n"
" ## When to Use PageRank\n"
"\n"
" - **Directed graphs** where link direction matters\n"
" - When you care about **link quality** (links from important nodes count more)\n"
" - Citation networks, web graphs, recommendation systems\n"
"\n"
" For undirected graphs, consider `eigenvector/3` instead.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" // Use default options (recommended for most cases)\n"
" let options = centrality.default_pagerank_options()\n"
" let scores = centrality.pagerank(graph, options)\n"
" // => dict.from_list([#(1, 0.256), #(2, 0.488), #(3, 0.256)])\n"
"\n"
" // Custom options for faster convergence or different damping\n"
" let custom = centrality.PageRankOptions(\n"
" damping: 0.9, // Follow more links before jumping\n"
" max_iterations: 50, // Faster but less precise\n"
" tolerance: 0.001, // Less strict convergence\n"
" )\n"
" let scores = centrality.pagerank(graph, custom)\n"
" ```\n"
"\n"
" ## Interpreting PageRank\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | **High** | The node is linked to by many other important nodes |\n"
" | **Low** | The node has few or low-quality incoming links |\n"
" | `1.0` | Single-node graph (trivial case) |\n"
"\n"
" PageRank scores always sum to `1.0` across all nodes. A node with\n"
" rank `0.5` in a 2-node graph means it captures half the total\n"
" importance in the network.\n"
).
-spec pagerank(yog@model:graph(any(), any()), page_rank_options()) -> gleam@dict:dict(integer(), float()).
pagerank(Graph, Options) ->
Nodes = yog@model:all_nodes(Graph),
N = erlang:length(Nodes),
Initial_rank = case erlang:float(N) of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> 1.0 / Gleam@denominator
end,
Ranks = gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, Initial_rank) end
),
iterate_pagerank(Graph, Ranks, Nodes, N, Options, 0).
-file("src/yog/centrality.gleam", 619).
-spec calculate_l2_norm(gleam@dict:dict(integer(), float()), list(integer())) -> float().
calculate_l2_norm(Scores, Nodes) ->
Sum_squares = begin
gleam@list:fold(
Nodes,
+0.0,
fun(Sum, Node) ->
Score = dict_get_float(Scores, Node),
Sum + (Score * Score)
end
)
end,
case Sum_squares > +0.0 of
true ->
_pipe = Sum_squares,
_pipe@1 = gleam@float:square_root(_pipe),
gleam@result:unwrap(_pipe@1, +0.0);
false ->
+0.0
end.
-file("src/yog/centrality.gleam", 632).
-spec calculate_l2_difference(
gleam@dict:dict(integer(), float()),
gleam@dict:dict(integer(), float()),
list(integer())
) -> float().
calculate_l2_difference(Old_scores, New_scores, Nodes) ->
Sum_squares = begin
gleam@list:fold(
Nodes,
+0.0,
fun(Sum, Node) ->
Old_val = dict_get_float(Old_scores, Node),
New_val = dict_get_float(New_scores, Node),
Diff = New_val - Old_val,
Sum + (Diff * Diff)
end
)
end,
case Sum_squares > +0.0 of
true ->
_pipe = Sum_squares,
_pipe@1 = gleam@float:square_root(_pipe),
gleam@result:unwrap(_pipe@1, +0.0);
false ->
+0.0
end.
-file("src/yog/centrality.gleam", 544).
-spec iterate_eigenvector_with_oscillation_check(
yog@model:graph(any(), any()),
list(integer()),
gleam@dict:dict(integer(), float()),
gleam@dict:dict(integer(), float()),
integer(),
float(),
integer()
) -> gleam@dict:dict(integer(), float()).
iterate_eigenvector_with_oscillation_check(
Graph,
Nodes,
Scores,
Prev_prev_scores,
Max_iterations,
Tolerance,
Iteration
) ->
case Iteration >= Max_iterations of
true ->
Scores;
false ->
New_scores = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Node) ->
Neighbor_sum = begin
gleam@list:fold(
get_in_neighbors(Graph, Node),
+0.0,
fun(Sum, Neighbor) ->
Neighbor_score = dict_get_float(
Scores,
Neighbor
),
Sum + Neighbor_score
end
)
end,
gleam@dict:insert(Acc, Node, Neighbor_sum)
end
)
end,
L2_norm = calculate_l2_norm(New_scores, Nodes),
Normalized = case L2_norm > +0.0 of
true ->
gleam@dict:map_values(
New_scores,
fun(_, Score) -> case L2_norm of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> Score / Gleam@denominator
end end
);
false ->
New_scores
end,
L2_diff = calculate_l2_difference(Scores, Normalized, Nodes),
Is_oscillating = case maps:size(Prev_prev_scores) > 0 of
true ->
L2_diff_2 = calculate_l2_difference(
Prev_prev_scores,
Normalized,
Nodes
),
L2_diff_2 < Tolerance;
false ->
false
end,
case Is_oscillating of
true ->
Averaged = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc@1, Node@1) ->
Val1 = dict_get_float(Normalized, Node@1),
Val2 = dict_get_float(Scores, Node@1),
gleam@dict:insert(
Acc@1,
Node@1,
(Val1 + Val2) / 2.0
)
end
)
end,
Avg_norm = calculate_l2_norm(Averaged, Nodes),
case Avg_norm > +0.0 of
true ->
gleam@dict:map_values(
Averaged,
fun(_, Score@1) -> case Avg_norm of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator@1 -> Score@1 / Gleam@denominator@1
end end
);
false ->
Averaged
end;
false ->
case L2_diff < Tolerance of
true ->
Normalized;
false ->
iterate_eigenvector_with_oscillation_check(
Graph,
Nodes,
Normalized,
Scores,
Max_iterations,
Tolerance,
Iteration + 1
)
end
end
end.
-file("src/yog/centrality.gleam", 513).
?DOC(
" Calculates Eigenvector Centrality for all nodes.\n"
"\n"
" Eigenvector centrality measures a node's influence based on the centrality\n"
" of its neighbors. A node is important if it is connected to other important\n"
" nodes. Uses power iteration to converge on the principal eigenvector.\n"
"\n"
" **Time Complexity:** O(max_iterations * (V + E))\n"
"\n"
" ## Parameters\n"
"\n"
" - `max_iterations`: Maximum number of power iterations\n"
" - `tolerance`: Convergence threshold for L2 norm\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" centrality.eigenvector(graph, max_iterations: 100, tolerance: 0.0001)\n"
" // => dict.from_list([#(1, 0.707), #(2, 1.0), #(3, 0.707)])\n"
" ```\n"
"\n"
" ## Interpreting Eigenvector Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | **High** | The node is connected to other highly central nodes |\n"
" | **Low** | The node is connected to peripheral or unimportant nodes |\n"
" | `0.0` | Isolated node with no connections |\n"
"\n"
" Eigenvector scores are normalized (L2 norm = 1.0), so they represent\n"
" relative importance rather than absolute counts.\n"
).
-spec eigenvector(yog@model:graph(any(), any()), integer(), float()) -> gleam@dict:dict(integer(), float()).
eigenvector(Graph, Max_iterations, Tolerance) ->
Nodes = yog@model:all_nodes(Graph),
N = erlang:length(Nodes),
case N =< 1 of
true ->
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, 1.0) end
);
false ->
Initial_scores = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc@1, Id@1) ->
Perturbation = erlang:float(Id@1) / 1000.0,
gleam@dict:insert(Acc@1, Id@1, 1.0 + Perturbation)
end
)
end,
iterate_eigenvector_with_oscillation_check(
Graph,
Nodes,
Initial_scores,
maps:new(),
Max_iterations,
Tolerance,
0
)
end.
-file("src/yog/centrality.gleam", 765).
-spec calculate_l1_norm_diff(
gleam@dict:dict(integer(), float()),
gleam@dict:dict(integer(), float()),
list(integer())
) -> float().
calculate_l1_norm_diff(Old_scores, New_scores, Nodes) ->
gleam@list:fold(
Nodes,
+0.0,
fun(Sum, Node) ->
Old_val = dict_get_float(Old_scores, Node),
New_val = dict_get_float(New_scores, Node),
Sum + gleam@float:absolute_value(New_val - Old_val)
end
).
-file("src/yog/centrality.gleam", 722).
-spec iterate_katz(
yog@model:graph(any(), any()),
list(integer()),
gleam@dict:dict(integer(), float()),
float(),
float(),
integer(),
float(),
integer()
) -> gleam@dict:dict(integer(), float()).
iterate_katz(
Graph,
Nodes,
Scores,
Alpha,
Beta,
Max_iterations,
Tolerance,
Iteration
) ->
case Iteration >= Max_iterations of
true ->
Scores;
false ->
New_scores = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Node) ->
Neighbor_sum = begin
gleam@list:fold(
get_in_neighbors(Graph, Node),
+0.0,
fun(Sum, Neighbor) ->
Neighbor_score = dict_get_float(
Scores,
Neighbor
),
Sum + Neighbor_score
end
)
end,
New_score = (Alpha * Neighbor_sum) + Beta,
gleam@dict:insert(Acc, Node, New_score)
end
)
end,
L1_diff = calculate_l1_norm_diff(Scores, New_scores, Nodes),
case L1_diff < Tolerance of
true ->
New_scores;
false ->
iterate_katz(
Graph,
Nodes,
New_scores,
Alpha,
Beta,
Max_iterations,
Tolerance,
Iteration + 1
)
end
end.
-file("src/yog/centrality.gleam", 690).
?DOC(
" Calculates Katz Centrality for all nodes.\n"
"\n"
" Katz centrality is a variant of eigenvector centrality that adds an\n"
" attenuation factor (alpha) to prevent the infinite accumulation of\n"
" centrality in cycles. It also includes a constant term (beta) to give\n"
" every node some base centrality.\n"
"\n"
" Formula: C(v) = α * Σ C(u) + β for all neighbors u\n"
"\n"
" **Time Complexity:** O(max_iterations * (V + E))\n"
"\n"
" ## Parameters\n"
"\n"
" - `alpha`: Attenuation factor (must be < 1/largest_eigenvalue, typically 0.1-0.3)\n"
" - `beta`: Base centrality (typically 1.0)\n"
" - `max_iterations`: Maximum number of iterations\n"
" - `tolerance`: Convergence threshold\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" centrality.katz(graph, alpha: 0.1, beta: 1.0, max_iterations: 100, tolerance: 0.0001)\n"
" // => dict.from_list([#(1, 2.5), #(2, 3.0), #(3, 2.5)])\n"
" ```\n"
"\n"
" ## Interpreting Katz Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | **High** | The node has many short paths to other important nodes |\n"
" | **Low** | The node is distant from the network core |\n"
" | `≈ beta` | Isolated node — only receives the baseline score |\n"
"\n"
" Because of the constant `beta` term, even isolated nodes receive a\n"
" non-zero score, making Katz more forgiving than eigenvector centrality.\n"
).
-spec katz(yog@model:graph(any(), any()), float(), float(), integer(), float()) -> gleam@dict:dict(integer(), float()).
katz(Graph, Alpha, Beta, Max_iterations, Tolerance) ->
Nodes = yog@model:all_nodes(Graph),
N = erlang:length(Nodes),
case N =< 0 of
true ->
maps:new();
false ->
Initial_scores = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, Beta) end
)
end,
iterate_katz(
Graph,
Nodes,
Initial_scores,
Alpha,
Beta,
Max_iterations,
Tolerance,
0
)
end.
-file("src/yog/centrality.gleam", 848).
-spec iterate_alpha(
yog@model:graph(any(), any()),
list(integer()),
gleam@dict:dict(integer(), float()),
float(),
integer(),
float(),
integer()
) -> gleam@dict:dict(integer(), float()).
iterate_alpha(Graph, Nodes, Scores, Alpha, Max_iterations, Tolerance, Iteration) ->
case Iteration >= Max_iterations of
true ->
Scores;
false ->
New_scores = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Node) ->
Neighbor_sum = begin
gleam@list:fold(
get_in_neighbors(Graph, Node),
+0.0,
fun(Sum, Neighbor) ->
Neighbor_score = dict_get_float(
Scores,
Neighbor
),
Sum + Neighbor_score
end
)
end,
New_score = Alpha * Neighbor_sum,
gleam@dict:insert(Acc, Node, New_score)
end
)
end,
L1_diff = calculate_l1_norm_diff(Scores, New_scores, Nodes),
case L1_diff < Tolerance of
true ->
New_scores;
false ->
iterate_alpha(
Graph,
Nodes,
New_scores,
Alpha,
Max_iterations,
Tolerance,
Iteration + 1
)
end
end.
-file("src/yog/centrality.gleam", 817).
?DOC(
" Calculates Alpha Centrality for all nodes.\n"
"\n"
" Alpha centrality is a generalization of Katz centrality for directed\n"
" graphs. It measures the total number of paths from a node, weighted\n"
" by path length with attenuation factor alpha.\n"
"\n"
" Unlike Katz, alpha centrality does not include a constant beta term\n"
" and is particularly useful for analyzing influence in directed networks.\n"
"\n"
" Formula: C(v) = α * Σ C(u) for all predecessors u (or neighbors for undirected)\n"
"\n"
" **Time Complexity:** O(max_iterations * (V + E))\n"
"\n"
" ## Parameters\n"
"\n"
" - `alpha`: Attenuation factor (typically 0.1-0.5)\n"
" - `initial`: Initial centrality value for all nodes\n"
" - `max_iterations`: Maximum number of iterations\n"
" - `tolerance`: Convergence threshold\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" centrality.alpha_centrality(graph, alpha: 0.3, initial: 1.0, max_iterations: 100, tolerance: 0.0001)\n"
" // => dict.from_list([#(1, 2.0), #(2, 3.0), #(3, 2.0)])\n"
" ```\n"
"\n"
" ## Interpreting Alpha Centrality\n"
"\n"
" | Value | Meaning |\n"
" |-------|---------|\n"
" | **High** | The node has many paths from other central nodes |\n"
" | **Low** | The node is at the edge of the network with few incoming paths |\n"
" | `0.0` | Isolated node — no incoming paths to accumulate influence |\n"
"\n"
" Unlike Katz, alpha centrality has no baseline `beta` term, so isolated\n"
" nodes converge to `0.0` rather than retaining a minimum score.\n"
).
-spec alpha_centrality(
yog@model:graph(any(), any()),
float(),
float(),
integer(),
float()
) -> gleam@dict:dict(integer(), float()).
alpha_centrality(Graph, Alpha, Initial, Max_iterations, Tolerance) ->
Nodes = yog@model:all_nodes(Graph),
N = erlang:length(Nodes),
case N =< 0 of
true ->
maps:new();
false ->
Initial_scores = begin
gleam@list:fold(
Nodes,
maps:new(),
fun(Acc, Id) -> gleam@dict:insert(Acc, Id, Initial) end
)
end,
iterate_alpha(
Graph,
Nodes,
Initial_scores,
Alpha,
Max_iterations,
Tolerance,
0
)
end.
-file("src/yog/centrality.gleam", 946).
?DOC(" Betweenness centrality with **Int** weights.\n").
-spec betweenness_int(yog@model:graph(any(), integer())) -> gleam@dict:dict(integer(), float()).
betweenness_int(Graph) ->
betweenness(
Graph,
0,
fun gleam@int:add/2,
fun gleam@int:compare/2,
fun erlang:float/1
).
-file("src/yog/centrality.gleam", 957).
?DOC(" Betweenness centrality with **Float** weights.\n").
-spec betweenness_float(yog@model:graph(any(), float())) -> gleam@dict:dict(integer(), float()).
betweenness_float(Graph) ->
betweenness(
Graph,
+0.0,
fun gleam@float:add/2,
fun gleam@float:compare/2,
fun(X) -> X end
).