diff --git a/next/kernel/announce_state.erl b/next/kernel/announce_state.erl new file mode 100644 index 00000000..ebe17a00 --- /dev/null +++ b/next/kernel/announce_state.erl @@ -0,0 +1,79 @@ +-module(announce_state). +-export([new/0, fold/2, fold_fn/0, + announcers_for/2, announce_count/2, announced_cids/1, + has_announced/3]). + +%% Announce-fanout projection. Folds Announce activities into a +%% per-target-Cid set of announcer ActorIds so projections can +%% answer "who re-broadcast this activity" / "how many announces +%% does this Note have" / "what activities has X announced". +%% +%% Announce envelope shape (per next/genesis/activity-types/announce.sx): +%% [{type, announce}, +%% {actor, AnnouncerActorId}, +%% {object, TargetCidBinary}, +%% ...] +%% +%% State shape: +%% [{TargetCid, [Announcer1, Announcer2, ...]}, ...] +%% +%% Set semantics — the same actor announcing the same target twice +%% is a no-op (already in the list). Undo{Announce} retraction +%% defers to a follow-up. + +new() -> []. + +fold_fn() -> + fun (Activity, State) -> fold(Activity, State) end. + +fold(Activity, State) -> + case envelope:get_field(type, Activity) of + {ok, announce} -> fold_announce(Activity, State); + _ -> State + end. + +fold_announce(Activity, State) -> + case {envelope:get_field(actor, Activity), + envelope:get_field(object, Activity)} of + {{ok, Actor}, {ok, Cid}} -> add_announcer(Cid, Actor, State); + _ -> State + end. + +add_announcer(Cid, Actor, State) -> + Current = case find_keyed(Cid, State) of + {ok, Set} -> Set; + _ -> [] + end, + case contains(Actor, Current) of + true -> State; + false -> set_keyed(Cid, Current ++ [Actor], State) + end. + +%% ── Read-side accessors ─────────────────────────────────────── + +announcers_for(Cid, State) -> + case find_keyed(Cid, State) of + {ok, Set} -> Set; + _ -> [] + end. + +announce_count(Cid, State) -> length(announcers_for(Cid, State)). + +announced_cids(State) -> [C || {C, _} <- State]. + +has_announced(Actor, Cid, State) -> + contains(Actor, announcers_for(Cid, State)). + +%% ── Internal ────────────────────────────────────────────────── + +contains(_, []) -> false; +contains(X, [X | _]) -> true; +contains(X, [_ | Rest]) -> contains(X, Rest). + +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)]. diff --git a/next/kernel/endorsement_state.erl b/next/kernel/endorsement_state.erl new file mode 100644 index 00000000..319e3f20 --- /dev/null +++ b/next/kernel/endorsement_state.erl @@ -0,0 +1,118 @@ +-module(endorsement_state). +-export([new/0, fold/2, fold_fn/0, + counters_for/2, total_for/2, kinds_for/2, + endorsers_for/3, has_endorsed/4]). + +%% Endorsement counter projection. Folds Endorse activities into a +%% per-target-Cid + per-kind counter so projections can serve +%% "how many likes does this Note have" / "list everyone who shared +%% this Announce" queries. +%% +%% Endorse envelope shape (per next/genesis/activity-types/endorse.sx): +%% [{type, endorse}, +%% {actor, ActorId}, +%% {object, TargetCidBinary}, +%% {kind, KindAtomOrBinary}, +%% ...] +%% +%% State shape: +%% [{TargetCid, [{Kind, [{ActorId, Count}, ...]}, ...]}, ...] +%% +%% Each ActorId can endorse the same target multiple times under +%% the same kind (e.g. like → unlike → like → ...); the counter +%% tracks how many *net* endorsement events fired. Step 11b ships +%% the additive counter only; the unlike / un-endorse semantics +%% (Undo{Endorse}) and reaction-toggling defer to a follow-up. + +new() -> []. + +fold_fn() -> + fun (Activity, State) -> fold(Activity, State) end. + +fold(Activity, State) -> + case envelope:get_field(type, Activity) of + {ok, endorse} -> fold_endorse(Activity, State); + _ -> State + end. + +fold_endorse(Activity, State) -> + case {envelope:get_field(actor, Activity), + envelope:get_field(object, Activity), + envelope:get_field(kind, Activity)} of + {{ok, Actor}, {ok, Cid}, {ok, Kind}} -> + bump(Cid, Kind, Actor, State); + _ -> + State + end. + +bump(Cid, Kind, Actor, State) -> + KindMap = case find_keyed(Cid, State) of + {ok, KM} -> KM; + _ -> [] + end, + ActorMap = case find_keyed(Kind, KindMap) of + {ok, AM} -> AM; + _ -> [] + end, + Current = case find_keyed(Actor, ActorMap) of + {ok, N} -> N; + _ -> 0 + end, + ActorMap1 = set_keyed(Actor, Current + 1, ActorMap), + KindMap1 = set_keyed(Kind, ActorMap1, KindMap), + set_keyed(Cid, KindMap1, State). + +%% ── Read-side accessors ─────────────────────────────────────── + +%% counters_for(Cid, State) -> [{Kind, TotalCount}, ...] +%% Sum per-kind across all endorsers. + +counters_for(Cid, State) -> + case find_keyed(Cid, State) of + {ok, KindMap} -> + [{K, sum_counts(AM)} || {K, AM} <- KindMap]; + _ -> [] + end. + +total_for(Cid, State) -> + lists:foldl(fun ({_, N}, Acc) -> N + Acc end, 0, counters_for(Cid, State)). + +kinds_for(Cid, State) -> + [K || {K, _} <- counters_for(Cid, State)]. + +endorsers_for(Cid, Kind, State) -> + case find_keyed(Cid, State) of + {ok, KindMap} -> + case find_keyed(Kind, KindMap) of + {ok, AM} -> [A || {A, _} <- AM]; + _ -> [] + end; + _ -> [] + end. + +has_endorsed(Actor, Cid, Kind, State) -> + case find_keyed(Cid, State) of + {ok, KindMap} -> + case find_keyed(Kind, KindMap) of + {ok, AM} -> + case find_keyed(Actor, AM) of + {ok, N} -> N > 0; + _ -> false + end; + _ -> false + end; + _ -> false + end. + +%% ── Internal ────────────────────────────────────────────────── + +sum_counts([]) -> 0; +sum_counts([{_, N} | Rest]) -> N + sum_counts(Rest). + +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)]. diff --git a/next/tests/rich_verbs.sh b/next/tests/rich_verbs.sh new file mode 100755 index 00000000..f5794666 --- /dev/null +++ b/next/tests/rich_verbs.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# next/tests/rich_verbs.sh — m2 Step 11b test. +# +# Projection folds for Announce + Endorse activity-types. +# announce_state tracks per-cid announcer sets; +# endorsement_state tracks per-cid + per-kind + per-actor counters. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +PASS=0; FAIL=0; ERRORS="" +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT + +# Cid1/Cid2 are the targets being announced/endorsed. +SETUP='Cid1 = <<99,49>>, Cid2 = <<99,50>>, Ann_BC1 = [{type, announce}, {actor, bob}, {object, Cid1}], Ann_CC1 = [{type, announce}, {actor, carol}, {object, Cid1}], Ann_BC2 = [{type, announce}, {actor, bob}, {object, Cid2}], End_BLikeC1 = [{type, endorse}, {actor, bob}, {object, Cid1}, {kind, like}], End_CLikeC1 = [{type, endorse}, {actor, carol}, {object, Cid1}, {kind, like}], End_BShareC1 = [{type, endorse}, {actor, bob}, {object, Cid1}, {kind, share}],' + +cat > "$TMPFILE" < announcer added +(epoch 11) +(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC1, announce_state:new()), announce_state:announcers_for(Cid1, S) =:= [bob]\") :name)") + +;; Two announces same target -> both announcers +(epoch 12) +(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_CC1, announce_state:fold(Ann_BC1, announce_state:new())), announce_state:announcers_for(Cid1, S) =:= [bob, carol]\") :name)") + +;; Duplicate announce by same actor -> no double-add +(epoch 13) +(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC1, announce_state:fold(Ann_BC1, announce_state:new())), announce_state:announcers_for(Cid1, S) =:= [bob]\") :name)") + +;; announce_count + announced_cids +(epoch 14) +(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC2, announce_state:fold(Ann_CC1, announce_state:fold(Ann_BC1, announce_state:new()))), {announce_state:announce_count(Cid1, S), announce_state:announce_count(Cid2, S), announce_state:announced_cids(S)} =:= {2, 1, [Cid1, Cid2]}\") :name)") + +;; has_announced predicate +(epoch 15) +(eval "(get (erlang-eval-ast \"${SETUP} S = announce_state:fold(Ann_BC1, announce_state:new()), {announce_state:has_announced(bob, Cid1, S), announce_state:has_announced(carol, Cid1, S)} =:= {true, false}\") :name)") + +;; announce_state fold_fn/0 is fun/2 +(epoch 16) +(eval "(get (erlang-eval-ast \"is_function(announce_state:fold_fn(), 2)\") :name)") + +;; Non-Announce activity passes through +(epoch 17) +(eval "(get (erlang-eval-ast \"Note = [{type, note}, {actor, alice}, {object, [{content, hi}]}], announce_state:fold(Note, announce_state:new()) =:= []\") :name)") + +;; ── endorsement_state ───────────────────────────────────── + +;; new/0 +(epoch 20) +(eval "(get (erlang-eval-ast \"endorsement_state:new() =:= []\") :name)") + +;; Endorse -> counter goes to 1 +(epoch 21) +(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BLikeC1, endorsement_state:new()), endorsement_state:counters_for(Cid1, S) =:= [{like, 1}]\") :name)") + +;; Two like-endorses by different actors -> total = 2 +(epoch 22) +(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_CLikeC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:total_for(Cid1, S) =:= 2\") :name)") + +;; like + share -> two kinds +(epoch 23) +(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BShareC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:kinds_for(Cid1, S) =:= [like, share]\") :name)") + +;; endorsers_for(Cid, like) +(epoch 24) +(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_CLikeC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:endorsers_for(Cid1, like, S) =:= [bob, carol]\") :name)") + +;; has_endorsed predicate +(epoch 25) +(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BLikeC1, endorsement_state:new()), {endorsement_state:has_endorsed(bob, Cid1, like, S), endorsement_state:has_endorsed(carol, Cid1, like, S), endorsement_state:has_endorsed(bob, Cid1, share, S)} =:= {true, false, false}\") :name)") + +;; endorsement_state fold_fn/0 is fun/2 +(epoch 26) +(eval "(get (erlang-eval-ast \"is_function(endorsement_state:fold_fn(), 2)\") :name)") + +;; Non-Endorse activity passes through +(epoch 27) +(eval "(get (erlang-eval-ast \"Note = [{type, note}, {actor, alice}, {object, [{content, hi}]}], endorsement_state:fold(Note, endorsement_state:new()) =:= []\") :name)") + +;; Same actor endorsing twice bumps the counter (additive semantics) +(epoch 28) +(eval "(get (erlang-eval-ast \"${SETUP} S = endorsement_state:fold(End_BLikeC1, endorsement_state:fold(End_BLikeC1, endorsement_state:new())), endorsement_state:total_for(Cid1, S) =:= 2\") :name)") +EPOCHS + +OUTPUT=$(timeout 280 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 3 "announce_state module loaded" "announce_state" +check 4 "endorsement_state module loaded" "endorsement_state" +check 10 "announce_state:new -> []" "true" +check 11 "Announce -> announcer" "true" +check 12 "Two announces same target" "true" +check 13 "Duplicate announce no-op" "true" +check 14 "count / announced_cids" "true" +check 15 "has_announced predicate" "true" +check 16 "announce fold_fn/0 fun/2" "true" +check 17 "Non-Announce passes through" "true" +check 20 "endorsement_state:new -> []" "true" +check 21 "Endorse -> counter 1" "true" +check 22 "Two likes -> total 2" "true" +check 23 "like + share -> two kinds" "true" +check 24 "endorsers_for(Cid, like)" "true" +check 25 "has_endorsed predicate" "true" +check 26 "endorse fold_fn/0 fun/2" "true" +check 27 "Non-Endorse passes through" "true" +check 28 "Same actor endorse twice -> 2" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/rich_verbs.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index 624fa60d..27bcb4ee 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -726,6 +726,22 @@ re-broadcast another actor's content to their own followers. **Deliverables:** +- [x] **11b** — Projection folds for the new verbs. + - `next/kernel/announce_state.erl`: tracks per-Cid announcer + set. Public API `new/0`, `fold/2`, `fold_fn/0`, + `announcers_for/2`, `announce_count/2`, + `announced_cids/1`, `has_announced/3`. Set semantics + (duplicate Announce by same actor is a no-op). + - `next/kernel/endorsement_state.erl`: tracks per-Cid + + per-kind + per-actor endorsement counters. Public API + `new/0`, `fold/2`, `fold_fn/0`, `counters_for/2`, + `total_for/2`, `kinds_for/2`, `endorsers_for/3`, + `has_endorsed/4`. Additive semantics (re-endorse by same + actor under same kind bumps the counter; Undo{Endorse} + semantics defer to a follow-up). + Both `fold_fn/0`s plug into `projection:start_link/3`. 19/19 + in `rich_verbs.sh` covering happy paths + predicates + non- + matching-activity pass-through. - [x] **11a** — Announce + Endorse genesis activity-types (Note already exists as an object-type from M1 — Create{Note} is the publish path). Two new `DefineActivity` SX files in @@ -994,6 +1010,20 @@ proceed. Newest first. +- **2026-06-07** — Step 11b: projection folds for the new verbs. + Two new modules in `next/kernel/`: + `announce_state.erl` (per-Cid announcer-set fold, set + semantics) and `endorsement_state.erl` (per-Cid + per-kind + + per-actor counter, additive semantics). Both follow the + same plug shape as `actor_state` / `follower_graph` / + `delivery_state`: `fold_fn/0` returns a 2-arity Erlang fun + for `projection:start_link/3`. Non-matching activities pass + through unchanged. Read-side accessors cover both + enumeration (announcers_for / endorsers_for) and predicates + (has_announced / has_endorsed) so the feed/timeline layer + doesn't have to re-implement that logic. 19/19 in + `rich_verbs.sh`. Conformance preserved at 761/761. + - **2026-06-07** — Step 11a: Announce + Endorse genesis activity-types. Two new DefineActivity SX files in `next/genesis/activity-types/`: announce.sx (`:object` is a