Skip to main content

src/graffeo.erl

-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].