Skip to main content

src/graffeo_conn.erl

-module(graffeo_conn).
-moduledoc """
Connectivity algorithms over the read-half behaviour.

The DFS/forest engine is ported from the stdlib but runs over
`graffeo_backend` callbacks only — no direct stdlib calls.
""".

-export([
    topsort/3,
    preorder/3,
    postorder/3,
    dfs/3,
    components/3,
    strong_components/3,
    cyclic_strong_components/3,
    reachable/4,
    reachable_neighbours/4,
    reaching/4,
    reaching_neighbours/4,
    is_acyclic/3,
    loop_vertices/3,
    is_tree/3,
    is_arborescence/3,
    arborescence_root/3,
    subgraph/4, subgraph/5,
    condensation/4,
    filter_edges/4,
    contract/4, contract/5
]).

%%% === Public API ===

-doc "Topological sort; `{ok, Order}` or `false` if cyclic.".
-spec topsort(module(), term(), [graffeo:vertex()]) ->
    {ok, [graffeo:vertex()]} | false.
topsort(B, R, Vs) ->
    L = revpostorder(B, R, Vs),
    case length(forest_grouped(B, R, fun in_sf/4, L, first)) =:= length(Vs) of
        true -> {ok, L};
        false -> false
    end.

-doc "Vertices in DFS preorder.".
-spec preorder(module(), term(), [graffeo:vertex()]) -> [graffeo:vertex()].
preorder(B, R, Vs) ->
    Roots = roots(B, R, Vs),
    T = sets:new([{version, 2}]),
    {_, Acc} = ptraverse(Roots, fun out_sf/4, B, R, T, [], []),
    lists:reverse(lists:append(Acc)).

-doc "Vertices in DFS postorder.".
-spec postorder(module(), term(), [graffeo:vertex()]) -> [graffeo:vertex()].
postorder(B, R, Vs) ->
    Roots = roots(B, R, Vs),
    T = sets:new([{version, 2}]),
    {Acc, _} = posttraverse(Roots, B, R, T, []),
    lists:reverse(Acc).

-doc "DFS visit order (preorder).".
-spec dfs(module(), term(), [graffeo:vertex()]) -> [graffeo:vertex()].
dfs(B, R, Vs) ->
    preorder(B, R, Vs).

-doc "Connected components (undirected).".
-spec components(module(), term(), [graffeo:vertex()]) -> [[graffeo:vertex()]].
components(B, R, Vs) ->
    forest_grouped(B, R, fun inout_sf/4, Vs, first).

-doc "Strongly connected components (Kosaraju).".
-spec strong_components(module(), term(), [graffeo:vertex()]) -> [[graffeo:vertex()]].
strong_components(B, R, Vs) ->
    forest_grouped(B, R, fun in_sf/4, revpostorder(B, R, Vs), first).

-doc "Cyclic strongly connected components.".
-spec cyclic_strong_components(module(), term(), [graffeo:vertex()]) -> [[graffeo:vertex()]].
cyclic_strong_components(B, R, Vs) ->
    remove_singletons(strong_components(B, R, Vs), B, R, []).

-doc "Vertices reachable from `Vs` via outgoing edges (including `Vs`).".
-spec reachable(module(), term(), [graffeo:vertex()], [graffeo:vertex()]) -> [graffeo:vertex()].
reachable(B, R, _AllVs, Vs) ->
    lists:append(forest_grouped(B, R, fun out_sf/4, Vs, first)).

-doc "Vertices reachable from `Vs` (excluding `Vs` unless in a cycle).".
-spec reachable_neighbours(module(), term(), [graffeo:vertex()], [graffeo:vertex()]) ->
    [graffeo:vertex()].
reachable_neighbours(B, R, _AllVs, Vs) ->
    lists:append(forest_grouped(B, R, fun out_sf/4, Vs, not_first)).

-doc "Vertices from which `Vs` is reachable (including `Vs`).".
-spec reaching(module(), term(), [graffeo:vertex()], [graffeo:vertex()]) -> [graffeo:vertex()].
reaching(B, R, _AllVs, Vs) ->
    lists:append(forest_grouped(B, R, fun in_sf/4, Vs, first)).

-doc "Vertices from which `Vs` is reachable (excluding `Vs` unless in a cycle).".
-spec reaching_neighbours(module(), term(), [graffeo:vertex()], [graffeo:vertex()]) ->
    [graffeo:vertex()].
reaching_neighbours(B, R, _AllVs, Vs) ->
    lists:append(forest_grouped(B, R, fun in_sf/4, Vs, not_first)).

-doc "True if the graph is acyclic.".
-spec is_acyclic(module(), term(), [graffeo:vertex()]) -> boolean().
is_acyclic(B, R, Vs) ->
    case loop_vertices(B, R, Vs) of
        [] ->
            case topsort(B, R, Vs) of
                {ok, _} -> true;
                false -> false
            end;
        _ ->
            false
    end.

-doc "Vertices that have a self-loop.".
-spec loop_vertices(module(), term(), [graffeo:vertex()]) -> [graffeo:vertex()].
loop_vertices(B, R, Vs) ->
    [V || V <- Vs, lists:member(V, B:out_neighbours(R, V))].

-doc "True if the graph is a tree (undirected).".
-spec is_tree(module(), term(), [graffeo:vertex()]) -> boolean().
is_tree(B, R, Vs) ->
    B:no_edges(R) =:= B:no_vertices(R) - 1 andalso
        case components(B, R, Vs) of
            [_] -> true;
            _ -> false
        end.

-doc "True if the graph is an arborescence.".
-spec is_arborescence(module(), term(), [graffeo:vertex()]) -> boolean().
is_arborescence(B, R, Vs) ->
    arborescence_root(B, R, Vs) =/= no.

-doc "Returns `{yes, Root}` if arborescence, `no` otherwise.".
-spec arborescence_root(module(), term(), [graffeo:vertex()]) ->
    {yes, graffeo:vertex()} | no.
arborescence_root(B, R, Vs) ->
    case B:no_edges(R) =:= B:no_vertices(R) - 1 of
        true ->
            try
                F = fun(V, Z) ->
                    case B:in_degree(R, V) of
                        1 -> Z;
                        0 when Z =:= [] -> [V]
                    end
                end,
                [Root] = lists:foldl(F, [], Vs),
                {yes, Root}
            catch
                error:{case_clause, _} -> no;
                error:{badmatch, _} -> no
            end;
        false ->
            no
    end.

%%% === Constructive algorithms ===

-doc """
Induced subgraph over the given vertices.

The result is a new graph of the same backend containing only the
vertices in `SubVs` and edges where both endpoints are in `SubVs`.
Labels and edge metadata are preserved.
""".
-spec subgraph(graffeo:graph(), module(), term(), [graffeo:vertex()]) -> graffeo:graph().
subgraph(G, B, R, SubVs) ->
    subgraph(G, B, R, 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 (faithful to `digraph_utils:subgraph/3`).
""".
-spec subgraph(
    graffeo:graph(),
    module(),
    term(),
    [graffeo:vertex()],
    [{keep_labels, boolean()} | {type, inherit | list()}]
) -> graffeo:graph().
subgraph(G, B, R, SubVs, Opts) ->
    {KeepLabels} = parse_subgraph_opts(Opts),
    subgraph_build(G, B, R, SubVs, KeepLabels).

-doc """
Condensation: one vertex per SCC, labelled with the member list.

The result is a new graph of the same backend. Each vertex is the
list of member vertices of a strongly connected component. An edge
exists between condensed vertices where any cross-component edge
exists in the original.
""".
-spec condensation(module(), term(), graffeo:graph(), [graffeo:vertex()]) -> graffeo:graph().
condensation(B, R, G, Vs) ->
    SCs = strong_components(B, R, Vs),
    V2SC = maps:from_list([{V, SC} || SC <- SCs, V <- SC]),
    Result0 = lists:foldl(
        fun(SC, Acc) ->
            B:build_add_vertex(Acc, SC, SC)
        end,
        B:empty_like(G),
        SCs
    ),
    SCPairs = lists:usort(
        lists:flatmap(
            fun(SC) ->
                [
                    {SC, maps:get(N, V2SC)}
                 || V <- SC,
                    N <- B:out_neighbours(R, V),
                    maps:get(N, V2SC) =/= SC
                ]
            end,
            SCs
        )
    ),
    lists:foldl(
        fun({FromSC, ToSC}, Acc) ->
            B:build_add_edge(Acc, FromSC, ToSC, #{})
        end,
        Result0,
        SCPairs
    ).

-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.
""".
-spec filter_edges(
    graffeo:graph(),
    module(),
    term(),
    fun((graffeo:vertex(), graffeo:vertex(), graffeo:edge_meta()) -> boolean())
) -> graffeo:graph().
filter_edges(G, B, R, Pred) ->
    Vs = B:vertices(R),
    lists:foldl(
        fun(From, GAcc) ->
            Ns = B:out_neighbours(R, From),
            lists:foldl(
                fun(To, GAcc2) ->
                    case B:edge_meta(R, From, To) of
                        {ok, Meta} ->
                            case Pred(From, To, Meta) of
                                true ->
                                    GAcc3 = ensure_build_vertex(B, GAcc2, From),
                                    GAcc4 = ensure_build_vertex(B, GAcc3, To),
                                    B:build_add_edge(GAcc4, From, To, Meta);
                                false ->
                                    GAcc2
                            end;
                        error ->
                            GAcc2
                    end
                end,
                GAcc,
                Ns
            )
        end,
        B:empty_like(G),
        Vs
    ).

-doc """
Quotient graph by class-function (default metadata on contracted edges).

Result vertices are the distinct `ClassFun(V)` values. For each edge
`(U, V)` where `ClassFun(U) =/= ClassFun(V)`, an edge is added between
the classes. Intra-class edges are dropped. Contracted edges carry
default metadata.
""".
-spec contract(
    graffeo:graph(),
    module(),
    term(),
    fun((graffeo:vertex()) -> term())
) -> graffeo:graph().
contract(G, B, R, ClassFun) ->
    contract(G, B, R, ClassFun, fun(_Old, New) -> New end).

-doc """
Quotient graph with a metadata merge function.

When multiple original edges collapse onto the same `(ClassA, ClassB)`,
their metadata is folded with `MergeFun(AccMeta, NextMeta)`.
""".
-spec contract(
    graffeo:graph(),
    module(),
    term(),
    fun((graffeo:vertex()) -> term()),
    fun((graffeo:edge_meta(), graffeo:edge_meta()) -> graffeo:edge_meta())
) -> graffeo:graph().
contract(G, B, R, ClassFun, MergeFun) ->
    Vs = B:vertices(R),
    Classes = lists:usort([ClassFun(V) || V <- Vs]),
    G0 = lists:foldl(
        fun(C, Acc) -> B:build_add_vertex(Acc, C) end,
        B:empty_like(G),
        Classes
    ),
    lists:foldl(
        fun(From, GAcc) ->
            ClassFrom = ClassFun(From),
            Ns = B:out_neighbours(R, From),
            lists:foldl(
                fun(To, GAcc2) ->
                    ClassTo = ClassFun(To),
                    case ClassFrom =:= ClassTo of
                        true ->
                            GAcc2;
                        false ->
                            Meta =
                                case B:edge_meta(R, From, To) of
                                    {ok, M} -> M;
                                    error -> #{}
                                end,
                            merge_contracted_edge(B, GAcc2, ClassFrom, ClassTo, Meta, MergeFun)
                    end
                end,
                GAcc,
                Ns
            )
        end,
        G0,
        Vs
    ).

-spec merge_contracted_edge(
    module(),
    graffeo:graph(),
    term(),
    term(),
    graffeo:edge_meta(),
    fun((graffeo:edge_meta(), graffeo:edge_meta()) -> graffeo:edge_meta())
) -> graffeo:graph().
merge_contracted_edge(B, G, ClassFrom, ClassTo, Meta, MergeFun) ->
    case B:edge_meta(graffeo:extract_ref(B, G), ClassFrom, ClassTo) of
        {ok, Existing} ->
            Merged = MergeFun(Existing, Meta),
            B:build_add_edge(G, ClassFrom, ClassTo, Merged);
        error ->
            B:build_add_edge(G, ClassFrom, ClassTo, Meta)
    end.

-spec ensure_build_vertex(module(), graffeo:graph(), graffeo:vertex()) -> graffeo:graph().
ensure_build_vertex(B, G, V) ->
    Ref = graffeo:extract_ref(B, G),
    case lists:member(V, B:vertices(Ref)) of
        true -> G;
        false -> B:build_add_vertex(G, V)
    end.

-spec subgraph_build(graffeo:graph(), module(), term(), [graffeo:vertex()], boolean()) ->
    graffeo:graph().
subgraph_build(G, B, R, SubVs, KeepLabels) ->
    SubSet = sets:from_list(SubVs, [{version, 2}]),
    Result0 = lists:foldl(
        fun(V, Acc) ->
            case B:vertex_label(R, V) of
                {ok, Label} when KeepLabels -> B:build_add_vertex(Acc, V, Label);
                {ok, _} -> B:build_add_vertex(Acc, V);
                error -> Acc
            end
        end,
        B:empty_like(G),
        SubVs
    ),
    lists:foldl(
        fun(V, Acc0) ->
            Ns = B:out_neighbours(R, V),
            lists:foldl(
                fun(N, Acc1) ->
                    case sets:is_element(N, SubSet) of
                        true when KeepLabels ->
                            Meta =
                                case B:edge_meta(R, V, N) of
                                    {ok, M} -> M;
                                    error -> #{}
                                end,
                            B:build_add_edge(Acc1, V, N, Meta);
                        true ->
                            B:build_add_edge(Acc1, V, N, #{});
                        false ->
                            Acc1
                    end
                end,
                Acc0,
                Ns
            )
        end,
        Result0,
        SubVs
    ).

-spec parse_subgraph_opts([{keep_labels, boolean()} | {type, inherit | list()}]) -> {boolean()}.
parse_subgraph_opts(Opts) ->
    parse_subgraph_opts(Opts, true).

-spec parse_subgraph_opts([{keep_labels, boolean()} | {type, inherit | list()}], boolean()) ->
    {boolean()}.
parse_subgraph_opts([], KeepLabels) ->
    {KeepLabels};
parse_subgraph_opts([{keep_labels, V} | Rest], _KL) when is_boolean(V) ->
    parse_subgraph_opts(Rest, V);
parse_subgraph_opts([{type, V} | Rest], KL) when V =:= inherit; is_list(V) ->
    parse_subgraph_opts(Rest, KL);
parse_subgraph_opts(_, _KL) ->
    erlang:error(badarg).

%%% === Internal: the forest engine ===

-type sf() :: fun((module(), term(), graffeo:vertex(), [graffeo:vertex()]) -> [graffeo:vertex()]).

-spec out_sf(module(), term(), graffeo:vertex(), [graffeo:vertex()]) -> [graffeo:vertex()].
out_sf(B, R, V, Vs) -> B:out_neighbours(R, V) ++ Vs.

-spec in_sf(module(), term(), graffeo:vertex(), [graffeo:vertex()]) -> [graffeo:vertex()].
in_sf(B, R, V, Vs) -> B:in_neighbours(R, V) ++ Vs.

-spec inout_sf(module(), term(), graffeo:vertex(), [graffeo:vertex()]) -> [graffeo:vertex()].
inout_sf(B, R, V, Vs) -> in_sf(B, R, V, out_sf(B, R, V, Vs)).

-spec forest_grouped(module(), term(), sf(), [graffeo:vertex()], first | not_first) ->
    [[graffeo:vertex()]].
forest_grouped(B, R, SF, Vs, HandleFirst) ->
    T = sets:new([{version, 2}]),
    F = fun(V, {T0, LL}) -> pretraverse_grouped(HandleFirst, V, SF, B, R, T0, LL) end,
    {_, LL} = lists:foldl(F, {T, []}, Vs),
    LL.

-spec pretraverse_grouped(
    first | not_first, graffeo:vertex(), sf(), module(), term(), sets:set(), [[graffeo:vertex()]]
) ->
    {sets:set(), [[graffeo:vertex()]]}.
pretraverse_grouped(first, V, SF, B, R, T, LL) ->
    ptraverse([V], SF, B, R, T, [], LL);
pretraverse_grouped(not_first, V, SF, B, R, T, LL) ->
    case sets:is_element(V, T) of
        false -> ptraverse(SF(B, R, V, []), SF, B, R, T, [], LL);
        true -> {T, LL}
    end.

-spec ptraverse([graffeo:vertex()], sf(), module(), term(), sets:set(), [graffeo:vertex()], [
    [graffeo:vertex()]
]) ->
    {sets:set(), [[graffeo:vertex()]]}.
ptraverse([V | Vs], SF, B, R, T0, Rs, LL) ->
    case sets:is_element(V, T0) of
        false ->
            T1 = sets:add_element(V, T0),
            ptraverse(SF(B, R, V, Vs), SF, B, R, T1, [V | Rs], LL);
        true ->
            ptraverse(Vs, SF, B, R, T0, Rs, LL)
    end;
ptraverse([], _SF, _B, _R, T, [], LL) ->
    {T, LL};
ptraverse([], _SF, _B, _R, T, Rs, LL) ->
    {T, [Rs | LL]}.

-spec revpostorder(module(), term(), [graffeo:vertex()]) -> [graffeo:vertex()].
revpostorder(B, R, Vs) ->
    T = sets:new([{version, 2}]),
    {L, _} = posttraverse(Vs, B, R, T, []),
    L.

-spec posttraverse([graffeo:vertex()], module(), term(), sets:set(), [graffeo:vertex()]) ->
    {[graffeo:vertex()], sets:set()}.
posttraverse([V | Vs], B, R, T0, Acc0) ->
    case sets:is_element(V, T0) of
        false ->
            T1 = sets:add_element(V, T0),
            {Acc1, T2} = posttraverse(B:out_neighbours(R, V), B, R, T1, Acc0),
            posttraverse(Vs, B, R, T2, [V | Acc1]);
        true ->
            posttraverse(Vs, B, R, T0, Acc0)
    end;
posttraverse([], _B, _R, T, Acc) ->
    {Acc, T}.

-spec roots(module(), term(), [graffeo:vertex()]) -> [graffeo:vertex()].
roots(B, R, Vs) ->
    R1 = [V || V <- Vs, B:in_degree(R, V) =:= 0],
    R2 = [X || [X | _] <- components(B, R, Vs)],
    R1 ++ R2.

-spec remove_singletons([[graffeo:vertex()]], module(), term(), [[graffeo:vertex()]]) ->
    [[graffeo:vertex()]].
remove_singletons([[V] = C | Cs], B, R, L) ->
    case lists:member(V, B:out_neighbours(R, V)) of
        true -> remove_singletons(Cs, B, R, [C | L]);
        false -> remove_singletons(Cs, B, R, L)
    end;
remove_singletons([C | Cs], B, R, L) ->
    remove_singletons(Cs, B, R, [C | L]);
remove_singletons([], _B, _R, L) ->
    L.