fed-sx-m2: Step 11b — Announce + Endorse projection folds + 19 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 20s

Two new projection modules for the rich verbs landed in Step 11a:

  next/kernel/announce_state.erl
    Per-target-Cid announcer set.
    State: [{TargetCid, [AnnouncerActorId, ...]}, ...]
    Set semantics — duplicate Announce by the same actor on the
    same target is a no-op.

    Public API:
      new/0, fold/2, fold_fn/0
      announcers_for/2, announce_count/2, announced_cids/1
      has_announced/3

  next/kernel/endorsement_state.erl
    Per-target-Cid + per-kind + per-actor endorsement counter.
    State: [{TargetCid, [{Kind, [{ActorId, Count}, ...]}, ...]}, ...]
    Additive semantics — re-endorse by the same actor under the
    same kind bumps the counter. Undo{Endorse} retraction defers
    to a follow-up.

    Public API:
      new/0, fold/2, fold_fn/0
      counters_for/2, total_for/2, kinds_for/2
      endorsers_for/3, has_endorsed/4

Both fold_fn/0 returns a 2-arity Erlang fun for
projection:start_link/3 (same plug shape as actor_state /
follower_graph / delivery_state). Non-matching activity types
pass through unchanged.

Read-side accessors cover both enumeration (announcers_for,
endorsers_for) and predicates (has_announced, has_endorsed) so
the feed/timeline projection layer doesn't have to re-implement
that logic on every consumer.

19/19 in next/tests/rich_verbs.sh:

  announce_state:
    - new/0 -> []
    - Announce -> announcer added
    - Two announces same target -> both in set
    - Duplicate announce by same actor -> no-op
    - announce_count + announced_cids
    - has_announced predicate
    - fold_fn/0 is fun/2
    - Non-Announce activity passes through

  endorsement_state:
    - new/0 -> []
    - Endorse -> counter 1
    - Two likes by different actors -> total 2
    - like + share -> two kinds tracked
    - endorsers_for(Cid, Kind)
    - has_endorsed predicate
    - fold_fn/0 is fun/2
    - Non-Endorse activity passes through
    - Same actor endorsing twice -> total = 2 (additive)

Conformance preserved at 761/761.
This commit is contained in:
2026-06-07 05:06:27 +00:00
parent 80f6fc9279
commit b2b61a0112
4 changed files with 390 additions and 0 deletions

View File

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

View File

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

163
next/tests/rich_verbs.sh Executable file
View File

@@ -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" <<EPOCHS
(epoch 1)
(load "lib/erlang/tokenizer.sx")
(load "lib/erlang/parser.sx")
(load "lib/erlang/parser-core.sx")
(load "lib/erlang/parser-expr.sx")
(load "lib/erlang/parser-module.sx")
(load "lib/erlang/transpile.sx")
(load "lib/erlang/runtime.sx")
(load "lib/erlang/vm/dispatcher.sx")
(epoch 2)
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
(epoch 3)
(eval "(get (erlang-load-module (file-read \"next/kernel/announce_state.erl\")) :name)")
(epoch 4)
(eval "(get (erlang-load-module (file-read \"next/kernel/endorsement_state.erl\")) :name)")
;; announce_state new/0
(epoch 10)
(eval "(get (erlang-eval-ast \"announce_state:new() =:= []\") :name)")
;; Announce -> 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="<no output for epoch $epoch>"
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 ]

View File

@@ -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