-module(follower_graph). -export([fold/2, fold_fn/0, new/0, lookup/2, actors/1, following/2, followers/2, pending_outbound/2, pending_inbound/2, is_following/3, has_follower/3, is_pending_outbound/3, is_pending_inbound/3]). %% Follower-graph projection — Erlang-fun stand-in for the genesis %% `follower-graph.sx` body. Tracks per-actor follow relationships %% per design §13.2: %% %% Follow {actor: A, object: B} A asks to follow B %% Accept {actor: B, object: F} B accepts A's Follow F (= F.actor → F.object) %% Reject {actor: B, object: F} B rejects A's Follow F %% Undo {actor: A, object: F} A retracts F or unfollows %% %% Where F = Follow{A→B} is embedded as the activity's :object %% proplist for Accept / Reject / Undo. %% %% State shape: %% [{ActorId, ActorEntry}, ...] %% %% ActorEntry = [{following, [PeerId, ...]}, %% {followers, [PeerId, ...]}, %% {pending_outbound, [PeerId, ...]}, %% I asked, no answer yet %% {pending_inbound, [PeerId, ...]}] %% asked me, I haven't answered %% %% Sets keep insertion order; duplicates aren't added. lists:keyfind/ %% keymember aren't in this substrate, so local find_keyed/has_keyed/ %% set_keyed helpers (same convention as actor_state, define_registry, %% nx_kernel). %% ── Public API ────────────────────────────────────────────────── new() -> []. actors(State) -> [Id || {Id, _Entry} <- State]. lookup(ActorId, State) -> case find_keyed(ActorId, State) of {ok, Entry} -> {ok, Entry}; _ -> not_found end. following(ActorId, State) -> entry_field(ActorId, following, State). followers(ActorId, State) -> entry_field(ActorId, followers, State). pending_outbound(ActorId, State) -> entry_field(ActorId, pending_outbound, State). pending_inbound(ActorId, State) -> entry_field(ActorId, pending_inbound, State). is_following(ActorId, PeerId, State) -> contains(PeerId, following(ActorId, State)). has_follower(ActorId, PeerId, State) -> contains(PeerId, followers(ActorId, State)). is_pending_outbound(ActorId, PeerId, State) -> contains(PeerId, pending_outbound(ActorId, State)). is_pending_inbound(ActorId, PeerId, State) -> contains(PeerId, pending_inbound(ActorId, State)). %% ── Fold dispatch ─────────────────────────────────────────────── fold(Activity, State) -> case envelope:get_field(type, Activity) of {ok, follow} -> fold_follow(Activity, State); {ok, accept} -> fold_accept(Activity, State); {ok, reject} -> fold_reject(Activity, State); {ok, undo} -> fold_undo(Activity, State); _ -> State end. fold_fn() -> fun (Activity, State) -> fold(Activity, State) end. %% Follow {actor: A, object: B}: %% add B to A's pending_outbound %% add A to B's pending_inbound fold_follow(Activity, State) -> case follow_actor_object(Activity) of {ok, A, B} when A =/= B -> S1 = add_to_field(A, pending_outbound, B, State), add_to_field(B, pending_inbound, A, S1); _ -> State end. %% Accept {actor: B, object: Follow{A→B}}: %% move A from B's pending_inbound to B's followers %% move B from A's pending_outbound to A's following fold_accept(Activity, State) -> case nested_follow_actor_object(Activity) of {ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B -> S1 = move_field(B, pending_inbound, followers, A, State), move_field(A, pending_outbound, following, B, S1); _ -> State end. %% Reject {actor: B, object: Follow{A→B}}: %% drop A from B's pending_inbound %% drop B from A's pending_outbound fold_reject(Activity, State) -> case nested_follow_actor_object(Activity) of {ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B -> S1 = drop_from_field(B, pending_inbound, A, State), drop_from_field(A, pending_outbound, B, S1); _ -> State end. %% Undo {actor: X, object: Follow{A→B}}: %% Only the original Follow's actor (A) can Undo it. %% Drops A↔B from every list on either side. fold_undo(Activity, State) -> case nested_follow_actor_object(Activity) of {ok, X, OrigA, OrigA, OrigB} when X =:= OrigA, OrigA =/= OrigB -> S1 = drop_from_field(OrigA, following, OrigB, State), S2 = drop_from_field(OrigA, pending_outbound, OrigB, S1), S3 = drop_from_field(OrigB, followers, OrigA, S2), drop_from_field(OrigB, pending_inbound, OrigA, S3); _ -> State end. %% ── Extraction helpers ───────────────────────────────────────── follow_actor_object(Activity) -> case envelope:get_field(actor, Activity) of {ok, A} -> case envelope:get_field(object, Activity) of {ok, B} when is_atom(B) -> {ok, A, B}; _ -> not_follow end; _ -> not_follow end. %% nested_follow_actor_object/1 — pull (Actor, FollowActor, FollowObject) %% out of an envelope whose :object is itself a Follow proplist. %% Returns {ok, OuterActor, InferredPeer, InnerActor, InnerObject}. nested_follow_actor_object(Activity) -> case envelope:get_field(actor, Activity) of {ok, Outer} -> case envelope:get_field(object, Activity) of {ok, Inner} when is_list(Inner) -> case nested_is_follow(Inner) of true -> case {envelope:get_field(actor, Inner), envelope:get_field(object, Inner)} of {{ok, IA}, {ok, IO}} when is_atom(IO) -> {ok, Outer, peer_from_inner(Outer, IA, IO), IA, IO}; _ -> not_a_follow_wrapper end; false -> not_a_follow_wrapper end; _ -> not_a_follow_wrapper end; _ -> not_a_follow_wrapper end. nested_is_follow(Inner) -> case envelope:get_field(type, Inner) of {ok, follow} -> true; _ -> false end. %% peer_from_inner — for an Accept/Reject by B of Follow{A→B}, %% Outer = B; the "peer" we move state for is A. For an Undo by A, %% Outer = A; the peer is B. Picking the inner actor/object that %% isn't Outer gives us the right pair-mate. peer_from_inner(Outer, IA, _IO) when Outer =:= IA -> IA; peer_from_inner(_Outer, IA, _IO) -> IA. %% ── Entry / field accessors ──────────────────────────────────── entry_field(ActorId, Field, State) -> case find_keyed(ActorId, State) of {ok, Entry} -> case find_keyed(Field, Entry) of {ok, Val} -> Val; _ -> [] end; _ -> [] end. empty_entry() -> [{following, []}, {followers, []}, {pending_outbound, []}, {pending_inbound, []}]. ensure_entry(ActorId, State) -> case find_keyed(ActorId, State) of {ok, _} -> State; _ -> State ++ [{ActorId, empty_entry()}] end. add_to_field(ActorId, Field, PeerId, State) -> S1 = ensure_entry(ActorId, State), {ok, Entry} = find_keyed(ActorId, S1), Current = entry_field(ActorId, Field, S1), NewList = case contains(PeerId, Current) of true -> Current; false -> Current ++ [PeerId] end, NewEntry = set_keyed(Field, NewList, Entry), set_keyed(ActorId, NewEntry, S1). drop_from_field(ActorId, Field, PeerId, State) -> case find_keyed(ActorId, State) of {ok, Entry} -> Current = entry_field(ActorId, Field, State), NewList = remove_member(PeerId, Current), NewEntry = set_keyed(Field, NewList, Entry), set_keyed(ActorId, NewEntry, State); _ -> State end. move_field(ActorId, FromField, ToField, PeerId, State) -> S1 = drop_from_field(ActorId, FromField, PeerId, State), add_to_field(ActorId, ToField, PeerId, S1). %% ── List helpers ─────────────────────────────────────────────── contains(_, []) -> false; contains(X, [X | _]) -> true; contains(X, [_ | Rest]) -> contains(X, Rest). remove_member(_, []) -> []; remove_member(X, [X | Rest]) -> remove_member(X, Rest); remove_member(X, [Y | Rest]) -> [Y | remove_member(X, Rest)]. %% ── Keyed-list helpers ───────────────────────────────────────── find_keyed(_, []) -> {error, not_found}; find_keyed(K, [{K, V} | _]) -> {ok, V}; find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest). set_keyed(K, V, []) -> [{K, V}]; set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest]; set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].