-module(graffeo).
-moduledoc """
The graffeo façade: the universal algorithm layer plus Tier-1
(`graffeo_map`) constructors and operations. The one module most
users touch.
""".
-include("graffeo.hrl").
%% Tier-1 constructors (blessed front door)
-export([
new/0,
add_vertex/2, add_vertex/3,
add_edge/3, add_edge/4
]).
%% Internal: envelope access for backends
-export([wrap_ref/2, extract_ref/2, backend/1]).
%% Read accessors
-export([
vertices/1,
out_neighbours/2,
in_neighbours/2,
in_degree/2,
out_degree/2,
no_edges/1,
no_vertices/1,
edge_meta/3,
vertex_label/2
]).
%% Algorithms
-export([
topsort/1,
dijkstra/2,
dijkstra/3,
astar/3,
astar/4,
bfs/2,
bfs/3,
degree/2,
degree_centrality/2,
top_k_by_degree/2
]).
%% Constructive algorithms
-export([
subgraph/2, subgraph/3,
condensation/1,
filter_edges/2,
contract/2, contract/3,
copy/2
]).
%% Path/cycle queries (ported from digraph)
-export([
get_path/3,
get_cycle/2,
get_short_path/3,
get_short_cycle/2,
source_vertices/1,
sink_vertices/1
]).
%% Connectivity / DFS-family (ported from digraph_utils)
-export([
components/1,
strong_components/1,
cyclic_strong_components/1,
reachable/2,
reachable_neighbours/2,
reaching/2,
reaching_neighbours/2,
is_acyclic/1,
loop_vertices/1,
preorder/1,
postorder/1,
is_tree/1,
is_arborescence/1,
arborescence_root/1
]).
-export_type([
graph/0,
vertex/0,
label/0,
weight/0,
edge_meta/0
]).
-doc "An opaque graph handle (map-backed or digraph-backed).".
-opaque graph() :: #graffeo{}.
-doc "A graph vertex (any Erlang term).".
-type vertex() :: term().
-doc "Vertex label; defaults to `undefined`.".
-type label() :: term().
-doc "Numeric edge weight.".
-type weight() :: number().
-doc "Per-edge metadata: weight and optional label.".
-type edge_meta() :: #{weight => weight(), label => label()}.
%%% === Envelope construction ===
-doc false.
-spec wrap_ref(module(), term()) -> graph().
wrap_ref(Backend, Ref) ->
#graffeo{backend = Backend, ref = Ref}.
-doc false.
-spec extract_ref(module(), graph()) -> term().
extract_ref(Expected, #graffeo{backend = Expected, ref = Ref}) ->
Ref;
extract_ref(Expected, #graffeo{backend = Actual}) ->
erlang:error({backend_mismatch, Expected, Actual}).
-doc false.
-spec backend(graph()) -> module().
backend(#graffeo{backend = B}) ->
B.
%%% === Tier-1 constructors ===
-doc "Create a new, empty map-backed value graph.".
-spec new() -> graph().
new() ->
wrap_ref(graffeo_map, graffeo_map:new()).
-doc """
Add a vertex with the default label.
Tier-1 operation — only valid on map-backed graphs.
For handle-backed graphs, use `graffeo_ets:add_vertex/2`.
""".
-spec add_vertex(graph(), vertex()) -> graph().
add_vertex(#graffeo{backend = graffeo_map, ref = Ref}, V) ->
wrap_ref(graffeo_map, graffeo_map:add_vertex(Ref, V));
add_vertex(#graffeo{backend = Backend}, _V) ->
erlang:error({tier1_only, add_vertex, Backend}).
-doc """
Add a vertex with a label.
Tier-1 operation — only valid on map-backed graphs.
For handle-backed graphs, use `graffeo_ets:add_vertex/3`.
""".
-spec add_vertex(graph(), vertex(), label()) -> graph().
add_vertex(#graffeo{backend = graffeo_map, ref = Ref}, V, Label) ->
wrap_ref(graffeo_map, graffeo_map:add_vertex(Ref, V, Label));
add_vertex(#graffeo{backend = Backend}, _V, _Label) ->
erlang:error({tier1_only, add_vertex, Backend}).
-doc """
Add an edge with default metadata.
Tier-1 operation — only valid on map-backed graphs.
For handle-backed graphs, use `graffeo_ets:add_edge/3`.
""".
-spec add_edge(graph(), vertex(), vertex()) -> graph().
add_edge(#graffeo{backend = graffeo_map, ref = Ref}, From, To) ->
wrap_ref(graffeo_map, graffeo_map:add_edge(Ref, From, To));
add_edge(#graffeo{backend = Backend}, _From, _To) ->
erlang:error({tier1_only, add_edge, Backend}).
-doc """
Add an edge with metadata (weight, label).
Tier-1 operation — only valid on map-backed graphs.
For handle-backed graphs, use `graffeo_ets:add_edge/4`.
""".
-spec add_edge(graph(), vertex(), vertex(), edge_meta()) -> graph().
add_edge(#graffeo{backend = graffeo_map, ref = Ref}, From, To, Meta) ->
wrap_ref(graffeo_map, graffeo_map:add_edge(Ref, From, To, Meta));
add_edge(#graffeo{backend = Backend}, _From, _To, _Meta) ->
erlang:error({tier1_only, add_edge, Backend}).
%%% === Read accessors (dispatch via behaviour) ===
-doc "All vertices in the graph.".
-spec vertices(graph()) -> [vertex()].
vertices(#graffeo{backend = B, ref = R}) ->
B:vertices(R).
-doc "Vertices reachable from `V` via outgoing edges.".
-spec out_neighbours(graph(), vertex()) -> [vertex()].
out_neighbours(#graffeo{backend = B, ref = R}, V) ->
B:out_neighbours(R, V).
-doc "Vertices that reach `V` via incoming edges.".
-spec in_neighbours(graph(), vertex()) -> [vertex()].
in_neighbours(#graffeo{backend = B, ref = R}, V) ->
B:in_neighbours(R, V).
-doc "Number of incoming edges to `V`.".
-spec in_degree(graph(), vertex()) -> non_neg_integer().
in_degree(#graffeo{backend = B, ref = R}, V) ->
B:in_degree(R, V).
-doc "Number of outgoing edges from `V`.".
-spec out_degree(graph(), vertex()) -> non_neg_integer().
out_degree(#graffeo{backend = B, ref = R}, V) ->
B:out_degree(R, V).
-doc "Total number of edges in the graph.".
-spec no_edges(graph()) -> non_neg_integer().
no_edges(#graffeo{backend = B, ref = R}) ->
B:no_edges(R).
-doc "Total number of vertices in the graph.".
-spec no_vertices(graph()) -> non_neg_integer().
no_vertices(#graffeo{backend = B, ref = R}) ->
B:no_vertices(R).
-doc "Get edge metadata between two vertices.".
-spec edge_meta(graph(), vertex(), vertex()) -> {ok, edge_meta()} | error.
edge_meta(#graffeo{backend = B, ref = R}, From, To) ->
B:edge_meta(R, From, To).
-doc "Get the label of a vertex.".
-spec vertex_label(graph(), vertex()) -> {ok, label()} | error.
vertex_label(#graffeo{backend = B, ref = R}, V) ->
B:vertex_label(R, V).
%%% === Algorithms ===
-doc """
Topological sort. Returns `{ok, Vertices}` in dependency order,
or `false` if the graph contains a cycle.
""".
-spec topsort(graph()) -> {ok, [vertex()]} | false.
topsort(#graffeo{backend = B, ref = R}) ->
graffeo_conn:topsort(B, R, B:vertices(R)).
-doc """
Dijkstra shortest paths from `Source`, using stored edge weights.
Returns `{Distances, Predecessors}`.
**Precondition:** all edge costs must be non-negative. Negative costs
produce undefined results. For negative-weight graphs, use a future
Bellman-Ford (not yet implemented).
""".
-spec dijkstra(graph(), vertex()) ->
{#{vertex() => number()}, #{vertex() => vertex()}}.
dijkstra(#graffeo{backend = B, ref = R}, Source) ->
graffeo_path:dijkstra(B, R, Source).
-doc """
Dijkstra shortest paths with options.
Options: `#{cost => fun(edge_meta()) -> number()}`.
**Precondition:** the cost function must return non-negative values.
Negative costs produce undefined results.
""".
-spec dijkstra(graph(), vertex(), map()) ->
{#{vertex() => number()}, #{vertex() => vertex()}}.
dijkstra(#graffeo{backend = B, ref = R}, Source, Opts) ->
graffeo_path:dijkstra(B, R, Source, Opts).
-doc """
A* shortest path from `Source` to `Target`, using stored edge weights.
Returns `{ok, Path, Cost}` or `none` if unreachable.
With the default zero heuristic, degenerates to Dijkstra.
**Preconditions:** costs must be non-negative; the heuristic must be
admissible (never overestimate) for the result to be optimal.
""".
-spec astar(graph(), vertex(), vertex()) ->
{ok, [vertex(), ...], number()} | none.
astar(#graffeo{backend = B, ref = R}, Source, Target) ->
graffeo_path:astar(B, R, Source, Target, B:vertices(R)).
-doc """
A* shortest path with options.
Options: `#{cost => fun(edge_meta()) -> number(), heuristic => fun(vertex()) -> number()}`.
""".
-spec astar(graph(), vertex(), vertex(), map()) ->
{ok, [vertex(), ...], number()} | none.
astar(#graffeo{backend = B, ref = R}, Source, Target, Opts) ->
graffeo_path:astar(B, R, Source, Target, B:vertices(R), Opts).
-doc """
BFS from `Source` with default options (direction `out`, no filter).
Returns `[{Vertex, Distance}]`.
""".
-spec bfs(graph(), vertex()) -> [{vertex(), non_neg_integer()}].
bfs(G, Source) ->
bfs(G, Source, #{}).
-doc """
BFS from `Source` with options.
Options: `direction` (`out`/`in`/`both`), `filter` (`fun(From, To) -> bool()`).
""".
-spec bfs(graph(), vertex(), map()) -> [{vertex(), non_neg_integer()}].
bfs(#graffeo{backend = B, ref = R}, Source, Opts) ->
graffeo_traverse:bfs(B, R, Source, Opts).
-doc "Total degree (in + out) of vertex `V`.".
-spec degree(graph(), vertex()) -> non_neg_integer().
degree(#graffeo{backend = B, ref = R}, V) ->
graffeo_traverse:degree(B, R, V).
-doc "Normalised degree centrality of vertex `V`.".
-spec degree_centrality(graph(), vertex()) -> float().
degree_centrality(#graffeo{backend = B, ref = R}, V) ->
graffeo_traverse:degree_centrality(B, R, V).
-doc """
Top-k vertices by total degree, descending.
Returns `[{Vertex, Degree}]`.
""".
-spec top_k_by_degree(graph(), pos_integer()) ->
[{vertex(), non_neg_integer()}].
top_k_by_degree(#graffeo{backend = B, ref = R}, K) ->
graffeo_traverse:top_k_by_degree(B, R, B:vertices(R), K).
%%% === Connectivity / DFS-family ===
-doc "Connected components (undirected). Returns `[[vertex()]]`.".
-spec components(graph()) -> [[vertex()]].
components(#graffeo{backend = B, ref = R}) ->
graffeo_conn:components(B, R, B:vertices(R)).
-doc "Strongly connected components. Returns `[[vertex()]]`.".
-spec strong_components(graph()) -> [[vertex()]].
strong_components(#graffeo{backend = B, ref = R}) ->
graffeo_conn:strong_components(B, R, B:vertices(R)).
-doc "Strongly connected components that contain a cycle.".
-spec cyclic_strong_components(graph()) -> [[vertex()]].
cyclic_strong_components(#graffeo{backend = B, ref = R}) ->
graffeo_conn:cyclic_strong_components(B, R, B:vertices(R)).
-doc "Vertices reachable from `Vs` (including `Vs`).".
-spec reachable(graph(), [vertex()]) -> [vertex()].
reachable(#graffeo{backend = B, ref = R}, Vs) ->
graffeo_conn:reachable(B, R, B:vertices(R), Vs).
-doc "Vertices reachable from `Vs` (excluding `Vs` unless in a cycle).".
-spec reachable_neighbours(graph(), [vertex()]) -> [vertex()].
reachable_neighbours(#graffeo{backend = B, ref = R}, Vs) ->
graffeo_conn:reachable_neighbours(B, R, B:vertices(R), Vs).
-doc "Vertices from which `Vs` are reachable (including `Vs`).".
-spec reaching(graph(), [vertex()]) -> [vertex()].
reaching(#graffeo{backend = B, ref = R}, Vs) ->
graffeo_conn:reaching(B, R, B:vertices(R), Vs).
-doc "Vertices from which `Vs` are reachable (excluding `Vs` unless in a cycle).".
-spec reaching_neighbours(graph(), [vertex()]) -> [vertex()].
reaching_neighbours(#graffeo{backend = B, ref = R}, Vs) ->
graffeo_conn:reaching_neighbours(B, R, B:vertices(R), Vs).
-doc "True if the graph is acyclic (no cycles).".
-spec is_acyclic(graph()) -> boolean().
is_acyclic(#graffeo{backend = B, ref = R}) ->
graffeo_conn:is_acyclic(B, R, B:vertices(R)).
-doc "Vertices with a self-loop.".
-spec loop_vertices(graph()) -> [vertex()].
loop_vertices(#graffeo{backend = B, ref = R}) ->
graffeo_conn:loop_vertices(B, R, B:vertices(R)).
-doc "Vertices in DFS preorder.".
-spec preorder(graph()) -> [vertex()].
preorder(#graffeo{backend = B, ref = R}) ->
graffeo_conn:preorder(B, R, B:vertices(R)).
-doc "Vertices in DFS postorder.".
-spec postorder(graph()) -> [vertex()].
postorder(#graffeo{backend = B, ref = R}) ->
graffeo_conn:postorder(B, R, B:vertices(R)).
-doc "True if the graph is a tree (undirected, connected, acyclic).".
-spec is_tree(graph()) -> boolean().
is_tree(#graffeo{backend = B, ref = R}) ->
graffeo_conn:is_tree(B, R, B:vertices(R)).
-doc "True if the graph is an arborescence (rooted tree, directed).".
-spec is_arborescence(graph()) -> boolean().
is_arborescence(#graffeo{backend = B, ref = R}) ->
graffeo_conn:is_arborescence(B, R, B:vertices(R)).
-doc "Returns `{yes, Root}` if the graph is an arborescence, `no` otherwise.".
-spec arborescence_root(graph()) -> {yes, vertex()} | no.
arborescence_root(#graffeo{backend = B, ref = R}) ->
graffeo_conn:arborescence_root(B, R, B:vertices(R)).
%%% === Constructive algorithms ===
-doc """
Induced subgraph over the given vertices.
Returns a new graph (same backend) with only the listed vertices
and edges where both endpoints are in the list.
**Tier-2 lifecycle:** over a handle graph, the result is a new
handle the caller must `graffeo_ets:delete/1`.
""".
-spec subgraph(graph(), [vertex()]) -> graph().
subgraph(G, SubVs) ->
subgraph(G, SubVs, []).
-doc """
Induced subgraph with options.
Options: `{keep_labels, boolean()}` (default `true`),
`{type, inherit | [d_type()]}` (handle backend only; ignored for value).
Raises `badarg` on malformed options.
""".
-spec subgraph(graph(), [vertex()], [{keep_labels, boolean()} | {type, inherit | list()}]) ->
graph().
subgraph(#graffeo{backend = B, ref = R} = G, SubVs, Opts) ->
graffeo_conn:subgraph(G, B, R, SubVs, Opts).
-doc """
Condensation: one vertex per strongly connected component.
Each vertex in the result is the member-list of a SCC. An edge
exists where any cross-component edge exists in the original.
**Tier-2 lifecycle:** over a handle graph, the result is a new
handle the caller must `graffeo_ets:delete/1`.
""".
-spec condensation(graph()) -> graph().
condensation(#graffeo{backend = B, ref = R} = G) ->
graffeo_conn:condensation(B, R, G, B:vertices(R)).
-doc """
Edge-induced subgraph by predicate.
Keeps edges where `Pred(From, To, Meta)` returns `true`, preserving
metadata. Only vertices incident to a kept edge appear in the result.
**Tier-2 lifecycle:** over a handle graph, the result is a new
handle the caller must `graffeo_ets:delete/1`.
""".
-spec filter_edges(graph(), fun((vertex(), vertex(), edge_meta()) -> boolean())) ->
graph().
filter_edges(#graffeo{backend = B, ref = R} = G, Pred) ->
graffeo_conn:filter_edges(G, B, R, Pred).
-doc """
Quotient graph by class-function (default metadata).
Result vertices are the distinct `ClassFun(V)` values. Edges between
different classes are induced; intra-class edges are dropped.
**Tier-2 lifecycle:** over a handle graph, the result is a new
handle the caller must `graffeo_ets:delete/1`.
""".
-spec contract(graph(), fun((vertex()) -> term())) -> graph().
contract(#graffeo{backend = B, ref = R} = G, ClassFun) ->
graffeo_conn:contract(G, B, R, ClassFun).
-doc """
Quotient graph with a metadata merge function.
When multiple original edges collapse onto the same class-pair,
their metadata is folded with `MergeFun(AccMeta, NextMeta)`.
""".
-spec contract(
graph(),
fun((vertex()) -> term()),
fun((edge_meta(), edge_meta()) -> edge_meta())
) -> graph().
contract(#graffeo{backend = B, ref = R} = G, ClassFun, MergeFun) ->
graffeo_conn:contract(G, B, R, ClassFun, MergeFun).
-doc """
Copy all vertices and edges from `Src` into `Dst`.
Walks `Src` through the read-half and builds into `Dst` via the
build-half. Returns `Dst` (which now contains the union). Labels
and edge metadata are preserved.
This is the persist primitive: build in memory, then
`copy(MapGraph, graffeo_dets:open("my_graph"))`.
""".
-spec copy(graph(), graph()) -> graph().
copy(#graffeo{backend = SB, ref = SR}, #graffeo{backend = DB} = Dst) ->
Vs = SB:vertices(SR),
Dst1 = lists:foldl(
fun(V, Acc) ->
case SB:vertex_label(SR, V) of
{ok, undefined} -> DB:build_add_vertex(Acc, V);
{ok, Label} -> DB:build_add_vertex(Acc, V, Label);
error -> Acc
end
end,
Dst,
Vs
),
lists:foldl(
fun(V, Acc0) ->
Ns = SB:out_neighbours(SR, V),
lists:foldl(
fun(N, Acc1) ->
Meta =
case SB:edge_meta(SR, V, N) of
{ok, M} -> M;
error -> #{}
end,
DB:build_add_edge(Acc1, V, N, Meta)
end,
Acc0,
Ns
)
end,
Dst1,
Vs
).
%%% === Path/cycle queries ===
-doc "DFS path from `V1` to `V2`, or `false`.".
-spec get_path(graph(), vertex(), vertex()) -> [vertex(), ...] | false.
get_path(#graffeo{backend = B, ref = R}, V1, V2) ->
graffeo_path:get_path(B, R, V1, V2).
-doc "A cycle through `V` (DFS), or `false`.".
-spec get_cycle(graph(), vertex()) -> [vertex(), ...] | false.
get_cycle(#graffeo{backend = B, ref = R}, V) ->
graffeo_path:get_cycle(B, R, V).
-doc """
Shortest (fewest-edges) path from `V1` to `V2` (BFS), or `false`.
When `V1 =:= V2`, returns the shortest cycle through `V`.
This is an **unweighted** path — edge weights are ignored.
""".
-spec get_short_path(graph(), vertex(), vertex()) -> [vertex(), ...] | false.
get_short_path(#graffeo{backend = B, ref = R}, V1, V2) ->
graffeo_path:get_short_path(B, R, V1, V2).
-doc "Shortest cycle through `V` (BFS), or `false`.".
-spec get_short_cycle(graph(), vertex()) -> [vertex(), ...] | false.
get_short_cycle(#graffeo{backend = B, ref = R}, V) ->
graffeo_path:get_short_cycle(B, R, V).
-doc "Vertices with in-degree 0.".
-spec source_vertices(graph()) -> [vertex()].
source_vertices(#graffeo{backend = B, ref = R}) ->
[V || V <- B:vertices(R), B:in_degree(R, V) =:= 0].
-doc "Vertices with out-degree 0.".
-spec sink_vertices(graph()) -> [vertex()].
sink_vertices(#graffeo{backend = B, ref = R}) ->
[V || V <- B:vertices(R), B:out_degree(R, V) =:= 0].