Files
rose-ash/next/kernel/follower_graph.erl
giles e890380a1a
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
fed-sx-m2: Step 6a — follower_graph projection + 18 tests
New next/kernel/follower_graph.erl is the Erlang-fun stand-in for
the genesis follower-graph.sx projection body, mirroring the
shape of actor_state.erl and define_registry.erl.

State shape (substrate has no maps, so a proplist):
  [{ActorId, [{following,        [PeerId, ...]},
              {followers,        [PeerId, ...]},
              {pending_outbound, [PeerId, ...]},
              {pending_inbound,  [PeerId, ...]}]}, ...]

Fold rules per design §13.2:
  Follow{actor: A, object: B}
      add B to A.pending_outbound
      add A to B.pending_inbound
  Accept{actor: B, object: Follow{A->B}}
      A moves from B.pending_inbound -> B.followers
      B moves from A.pending_outbound -> A.following
  Reject{actor: B, object: Follow{A->B}}
      clear A from B.pending_inbound, B from A.pending_outbound
  Undo{actor: A, object: Follow{A->B}}
      drop A<->B from every list on either side
      only the Follow's original actor may Undo it

Edge cases handled:
  - self-follow (alice -> alice) is a no-op
  - duplicate Follow is idempotent (list sets)
  - Accept/Reject/Undo whose :object isn't a Follow proplist
    passes through
  - Undo by the wrong actor (carol Undoing Follow{alice->bob})
    is a no-op

Public API:
  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
  fold/2, fold_fn/0

fold_fn/0 returns the standard 2-arity Erlang fun for
projection:start_link/3 (same plug shape as actor_state and
define_registry).

Local find_keyed/set_keyed/contains/remove_member helpers — no
lists:keyfind/keymember/member in this substrate (same gap as
Step 1a/2b/5a/5c).

18/18 in next/tests/follower_graph.sh covering all four verbs,
predicates, edge cases (self-follow, duplicate Follow, untyped
activity, non-Follow :object, wrong-actor Undo).

Step 6b wires this into the inbox handler so a peer Follow lands,
fires auto-Accept publish (open-world policy per §13.2; manual
moderation deferred to v3).

Conformance 761/761. 130/130 across 9 Step-6-adjacent suites
(inbox, inbox_bucket, inbox_pipeline, inbox_peer_resolution,
actor_state_pure, define_registry_pure, projection_pure,
nx_kernel_multi, smoke_app_pure).
2026-06-06 20:47:01 +00:00

238 lines
9.1 KiB
Erlang

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