Skip to main content

src/graffeo_ets.erl

-module(graffeo_ets).
-moduledoc """
ETS-backed, process-owned, mutable handle backend.

Implemented over the stdlib `digraph` module (which uses ETS
internally). `wrap/1` accepts a bare `digraph:graph()` handle and
`unwrap/1` returns it for raw `digraph:*` access.

Construction, mutation, and lifecycle operate on the opaque
`graffeo:graph()` envelope. Presents a **simple directed graph**
view: at most one edge per ordered `(From, To)` pair.
""".

-behaviour(graffeo_backend).
-behaviour(graffeo_builder).

%% Construction / lifecycle (envelope-based)
-export([
    new/0,
    wrap/1,
    unwrap/1,
    delete/1
]).

%% Mutation (envelope-based, mutate-in-place)
-export([
    add_vertex/2, add_vertex/3,
    add_edge/3, add_edge/4,
    del_vertex/2,
    del_vertices/2,
    del_edge/3,
    del_edges/2
]).

%% Read (graffeo_backend, bare-ref)
-export([
    vertices/1,
    out_neighbours/2,
    in_neighbours/2,
    in_degree/2,
    out_degree/2,
    no_edges/1,
    no_vertices/1
]).

%% Extra accessors (bare-ref)
-export([
    edge_meta/3,
    vertex_label/2
]).

%% graffeo_builder callbacks (envelope-based)
-export([
    empty_like/1,
    build_add_vertex/2, build_add_vertex/3,
    build_add_edge/4
]).

%%% === Construction / lifecycle ===

-doc "Create a new, empty digraph handle wrapped in a graffeo envelope.".
-spec new() -> graffeo:graph().
new() ->
    Ref = digraph:new(),
    graffeo:wrap_ref(?MODULE, Ref).

-doc "Lift a bare `digraph` handle into a graffeo envelope.".
-spec wrap(digraph:graph()) -> graffeo:graph().
wrap(Ref) ->
    graffeo:wrap_ref(?MODULE, Ref).

-doc """
Hand back the bare `digraph` handle from the envelope.

The explicit escape hatch for users who need raw `digraph:*` access.
""".
-spec unwrap(graffeo:graph()) -> digraph:graph().
unwrap(G) ->
    require_handle(unwrap, G).

-doc """
Free the underlying digraph handle (ETS tables).

Use-after-delete is undefined.
""".
-spec delete(graffeo:graph()) -> ok.
delete(G) ->
    D = require_handle(delete, G),
    digraph:delete(D),
    ok.

%%% === Mutation (envelope-based) ===

-doc "Add a vertex with the default label.".
-spec add_vertex(graffeo:graph(), graffeo:vertex()) -> ok.
add_vertex(G, V) ->
    D = require_handle(add_vertex, G),
    digraph:add_vertex(D, V),
    ok.

-doc "Add a vertex with a label.".
-spec add_vertex(graffeo:graph(), graffeo:vertex(), graffeo:label()) -> ok.
add_vertex(G, V, Label) ->
    D = require_handle(add_vertex, G),
    digraph:add_vertex(D, V, Label),
    ok.

-doc """
Add an edge with default metadata.

If a `From→To` edge already exists, its metadata is replaced
(simple-graph contract: at most one edge per ordered pair).
""".
-spec add_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
add_edge(G, From, To) ->
    add_edge(G, From, To, #{weight => 1}).

-doc """
Add an edge with metadata (stored as the edge label).

If a `From→To` edge already exists, it is replaced with the new
metadata (last-writer-wins, simple-graph contract).
""".
-spec add_edge(
    graffeo:graph(), graffeo:vertex(), graffeo:vertex(), graffeo:edge_meta()
) -> ok.
add_edge(G, From, To, Meta) ->
    D = require_handle(add_edge, G),
    digraph:add_vertex(D, From),
    digraph:add_vertex(D, To),
    remove_edges(D, From, To),
    digraph:add_edge(D, From, To, Meta),
    ok.

-doc "Remove vertex `V` and all its incident edges.".
-spec del_vertex(graffeo:graph(), graffeo:vertex()) -> ok.
del_vertex(G, V) ->
    D = require_handle(del_vertex, G),
    digraph:del_vertex(D, V),
    ok.

-doc """
Remove the `From→To` edge.

If the underlying digraph has parallel edges (e.g. from `wrap/1`),
all of them are removed (simple-graph contract).
""".
-spec del_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
del_edge(G, From, To) ->
    D = require_handle(del_edge, G),
    remove_edges(D, From, To),
    ok.

-doc "Delete a list of vertices and their incident edges.".
-spec del_vertices(graffeo:graph(), [graffeo:vertex()]) -> ok.
del_vertices(G, Vs) ->
    D = require_handle(del_vertices, G),
    lists:foreach(fun(V) -> digraph:del_vertex(D, V) end, Vs),
    ok.

-doc "Delete a list of `{From, To}` edges.".
-spec del_edges(graffeo:graph(), [{graffeo:vertex(), graffeo:vertex()}]) -> ok.
del_edges(G, Pairs) ->
    D = require_handle(del_edges, G),
    lists:foreach(fun({From, To}) -> remove_edges(D, From, To) end, Pairs),
    ok.

%%% === graffeo_backend callbacks (bare-ref) ===

-doc "All vertices in the graph.".
-spec vertices(digraph:graph()) -> [graffeo:vertex()].
vertices(Ref) ->
    digraph:vertices(Ref).

-doc "Vertices reachable from `V` via outgoing edges (deduplicated).".
-spec out_neighbours(digraph:graph(), graffeo:vertex()) -> [graffeo:vertex()].
out_neighbours(Ref, V) ->
    lists:uniq(digraph:out_neighbours(Ref, V)).

-doc "Vertices that reach `V` via incoming edges (deduplicated).".
-spec in_neighbours(digraph:graph(), graffeo:vertex()) -> [graffeo:vertex()].
in_neighbours(Ref, V) ->
    lists:uniq(digraph:in_neighbours(Ref, V)).

-doc "Number of distinct incoming neighbours of `V`.".
-spec in_degree(digraph:graph(), graffeo:vertex()) -> non_neg_integer().
in_degree(Ref, V) ->
    length(in_neighbours(Ref, V)).

-doc "Number of distinct outgoing neighbours of `V`.".
-spec out_degree(digraph:graph(), graffeo:vertex()) -> non_neg_integer().
out_degree(Ref, V) ->
    length(out_neighbours(Ref, V)).

-doc "Total number of distinct `(From, To)` edges in the graph.".
-spec no_edges(digraph:graph()) -> non_neg_integer().
no_edges(Ref) ->
    distinct_edge_count(Ref).

-doc "Total number of vertices in the graph.".
-spec no_vertices(digraph:graph()) -> non_neg_integer().
no_vertices(Ref) ->
    digraph:no_vertices(Ref).

%%% === Extra accessors (bare-ref) ===

-doc """
Get edge metadata between two vertices.

If parallel edges exist (only possible via `wrap/1` of an external
multigraph), returns the metadata of the edge with the highest edge
ID by Erlang term order. For stdlib `digraph` this is the
most-recently-added edge, matching the map backend's last-writer-wins.
The selection is deterministic and independent of traversal order.
""".
-spec edge_meta(digraph:graph(), graffeo:vertex(), graffeo:vertex()) ->
    {ok, graffeo:edge_meta()} | error.
edge_meta(Ref, From, To) ->
    Edges = digraph:out_edges(Ref, From),
    find_max_edge_meta(Ref, Edges, To).

-doc "Get the label of a vertex.".
-spec vertex_label(digraph:graph(), graffeo:vertex()) ->
    {ok, graffeo:label()} | error.
vertex_label(Ref, V) ->
    case digraph:vertex(Ref, V) of
        {V, Label} -> {ok, Label};
        false -> error
    end.

%%% === graffeo_builder callbacks (envelope-based) ===

-doc false.
-spec empty_like(graffeo:graph()) -> graffeo:graph().
empty_like(_G) ->
    new().

-doc false.
-spec build_add_vertex(graffeo:graph(), graffeo:vertex()) -> graffeo:graph().
build_add_vertex(G, V) ->
    ok = add_vertex(G, V),
    G.

-doc false.
-spec build_add_vertex(graffeo:graph(), graffeo:vertex(), graffeo:label()) -> graffeo:graph().
build_add_vertex(G, V, Label) ->
    ok = add_vertex(G, V, Label),
    G.

-doc false.
-spec build_add_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex(), graffeo:edge_meta()) ->
    graffeo:graph().
build_add_edge(G, From, To, Meta) ->
    ok = add_edge(G, From, To, Meta),
    G.

%%% --- Internal ---

-spec require_handle(atom(), graffeo:graph()) -> digraph:graph().
require_handle(Op, G) ->
    case graffeo:backend(G) of
        ?MODULE -> graffeo:extract_ref(?MODULE, G);
        Other -> erlang:error({handle_only, Op, Other})
    end.

-spec remove_edges(digraph:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
remove_edges(Ref, From, To) ->
    Edges = digraph:out_edges(Ref, From),
    lists:foreach(
        fun(E) ->
            case digraph:edge(Ref, E) of
                {E, From, To, _Meta} -> digraph:del_edge(Ref, E);
                _ -> ok
            end
        end,
        Edges
    ).

-spec find_max_edge_meta(digraph:graph(), [digraph:edge()], graffeo:vertex()) ->
    {ok, graffeo:edge_meta()} | error.
find_max_edge_meta(Ref, Edges, To) ->
    Matching = [
        {E, Meta}
     || E <- Edges,
        {E1, _From, To1, Meta} <- [digraph:edge(Ref, E)],
        E1 =:= E,
        To1 =:= To
    ],
    case Matching of
        [] ->
            error;
        _ ->
            {_, Meta} = lists:max(Matching),
            {ok, Meta}
    end.

-spec distinct_edge_count(digraph:graph()) -> non_neg_integer().
distinct_edge_count(Ref) ->
    Pairs = lists:usort([
        {From, To}
     || E <- digraph:edges(Ref),
        {E1, From, To, _} <- [digraph:edge(Ref, E)],
        E1 =:= E
    ]),
    length(Pairs).