Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
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).
238 lines
9.1 KiB
Erlang
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)].
|