From b0feb7b01bb5649e7aae79d975d55fbe89eb3f9b Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 16:51:53 +0000 Subject: [PATCH] =?UTF-8?q?feed:=20notification=20feed=20=E2=80=94=20per-r?= =?UTF-8?q?ecipient=20inbox,=20verb=20filter,=20(verb,object)=20digest=20+?= =?UTF-8?q?=208=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/feed/conformance.sh | 3 +- lib/feed/notify.sx | 45 ++++++++++++++++++++++++++ lib/feed/scoreboard.json | 7 ++-- lib/feed/scoreboard.md | 3 +- lib/feed/tests/notify.sx | 69 ++++++++++++++++++++++++++++++++++++++++ plans/feed-on-sx.md | 6 ++-- 6 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 lib/feed/notify.sx create mode 100644 lib/feed/tests/notify.sx diff --git a/lib/feed/conformance.sh b/lib/feed/conformance.sh index 95676dd2..d7263cf1 100755 --- a/lib/feed/conformance.sh +++ b/lib/feed/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(basic fanout rank integration content) +SUITES=(basic fanout rank integration content notify) OUT_JSON="lib/feed/scoreboard.json" OUT_MD="lib/feed/scoreboard.md" @@ -38,6 +38,7 @@ run_suite() { (load "lib/feed/acl.sx") (load "lib/feed/fed.sx") (load "lib/feed/content.sx") +(load "lib/feed/notify.sx") (epoch 2) (eval "(define feed-test-pass 0)") (eval "(define feed-test-fail 0)") diff --git a/lib/feed/notify.sx b/lib/feed/notify.sx new file mode 100644 index 00000000..7c499af7 --- /dev/null +++ b/lib/feed/notify.sx @@ -0,0 +1,45 @@ +; feed/notify — a notification feed is a thin layer over a recipient's inbox: +; the events directed at a user, optionally verb-filtered, and a digest that +; collapses "alice, bob and 1 other liked X" by (verb, object). +; +; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx +; (feed/inbox-for, feed/-elem?). + +; all inbox events for a user (their raw notifications) +(define feed/notifications (fn (inbox user) (feed/inbox-for inbox user))) + +; restrict to notification-worthy verbs (e.g. (list "like" "reply" "follow")) +(define + feed/notify-verbs + (fn + (inbox user verbs) + (feed/filter + (feed/inbox-for inbox user) + (fn (ev) (feed/-elem? (get (get ev :activity) :verb) verbs))))) + +; group key "verb|object" — deterministic, sortable +(define + feed/-notify-key + (fn + (ev) + (let + ((a (get ev :activity))) + (string-append (get a :verb) "|" (get a :object))))) + +; digest: one entry per (verb, object) with the distinct actors and a count, +; ordered by key for determinism. +(define + feed/notify-digest + (fn + (inbox user) + (let + ((events (feed/items (feed/inbox-for inbox user)))) + (let + ((groups (reduce (fn (g ev) (let ((a (get ev :activity)) (k (feed/-notify-key ev))) (let ((cur (get g k {:object (get a :object) :actors (list) :verb (get a :verb)}))) (assoc g k (assoc cur :actors (append (get cur :actors) (list (get a :actor)))))))) {} events))) + (map + (fn + (k) + (let + ((grp (get groups k))) + (assoc grp :count (len (get grp :actors))))) + (sort (keys groups))))))) diff --git a/lib/feed/scoreboard.json b/lib/feed/scoreboard.json index b8d206dc..3d682267 100644 --- a/lib/feed/scoreboard.json +++ b/lib/feed/scoreboard.json @@ -4,9 +4,10 @@ "fanout": {"pass": 29, "fail": 0}, "rank": {"pass": 24, "fail": 0}, "integration": {"pass": 22, "fail": 0}, - "content": {"pass": 15, "fail": 0} + "content": {"pass": 15, "fail": 0}, + "notify": {"pass": 8, "fail": 0} }, - "total_pass": 120, + "total_pass": 128, "total_fail": 0, - "total": 120 + "total": 128 } diff --git a/lib/feed/scoreboard.md b/lib/feed/scoreboard.md index beafe516..ec92d6f4 100644 --- a/lib/feed/scoreboard.md +++ b/lib/feed/scoreboard.md @@ -9,4 +9,5 @@ _Generated by `lib/feed/conformance.sh`_ | rank | 24 | 0 | 24 | | integration | 22 | 0 | 22 | | content | 15 | 0 | 15 | -| **Total** | **120** | **0** | **120** | +| notify | 8 | 0 | 8 | +| **Total** | **128** | **0** | **128** | diff --git a/lib/feed/tests/notify.sx b/lib/feed/tests/notify.sx new file mode 100644 index 00000000..d7212912 --- /dev/null +++ b/lib/feed/tests/notify.sx @@ -0,0 +1,69 @@ +; Follow-up — notification feed over an inbox. (feed-test name got expected) + +; an inbox is a stream of {:to receiver :activity act} events +(define mk-ev (fn (to act) {:activity act :to to})) + +(define + IB + (feed/stream + (list + (mk-ev "alice" (feed/activity "bob" "like" "P" 10 (list))) + (mk-ev "alice" (feed/activity "carol" "like" "P" 20 (list))) + (mk-ev "alice" (feed/activity "dave" "reply" "Q" 30 (list))) + (mk-ev "bob" (feed/activity "eve" "like" "R" 40 (list)))))) + +; ---------- raw notifications ---------- + +(feed-test + "alice notification count" + (feed/count (feed/notifications IB "alice")) + 3) +(feed-test + "bob notification count" + (feed/count (feed/notifications IB "bob")) + 1) +(feed-test + "zoe no notifications" + (feed/count (feed/notifications IB "zoe")) + 0) + +; ---------- verb filtering ---------- + +(feed-test + "alice likes only" + (feed/count (feed/notify-verbs IB "alice" (list "like"))) + 2) +(feed-test + "alice replies only" + (feed/count (feed/notify-verbs IB "alice" (list "reply"))) + 1) +(feed-test + "alice like+reply" + (feed/count (feed/notify-verbs IB "alice" (list "like" "reply"))) + 3) +(feed-test + "alice follow (none)" + (feed/count (feed/notify-verbs IB "alice" (list "follow"))) + 0) + +; ---------- digest ---------- + +(define dig (feed/notify-digest IB "alice")) + +(feed-test "digest group count" (len dig) 2) +(feed-test + "digest sorted by key (like|P before reply|Q)" + (map (fn (g) (get g :object)) dig) + (list "P" "Q")) +(feed-test + "like group actors" + (get (nth dig 0) :actors) + (list "bob" "carol")) +(feed-test "like group count" (get (nth dig 0) :count) 2) +(feed-test "like group verb" (get (nth dig 0) :verb) "like") +(feed-test "reply group count" (get (nth dig 1) :count) 1) +(feed-test + "reply group actors" + (get (nth dig 1) :actors) + (list "dave")) +(feed-test "empty digest for zoe" (feed/notify-digest IB "zoe") (list)) diff --git a/plans/feed-on-sx.md b/plans/feed-on-sx.md index 70b21e51..08620c1a 100644 --- a/plans/feed-on-sx.md +++ b/plans/feed-on-sx.md @@ -14,7 +14,7 @@ APL, ACL visibility filtering via `lib/acl/`, federation via fed-sx. ## Status (rolling) -`bash lib/feed/conformance.sh` → **120/120** (Phases 1–4 + TF-IDF complete) +`bash lib/feed/conformance.sh` → **128/128** (Phases 1–4 + TF-IDF + notifications) ## Ground rules @@ -141,7 +141,9 @@ are function parameters. Real acl-sx / fed-sx wire in at the call site unchanged - [x] TF-IDF over `:tags` for content ranking — `content.sx`: `feed/tag-df`, `feed/tag-idf` (log N/df), `feed/tfidf-score`, `feed/by-relevance`; 15 tests. Composes as a scorer with rank.sx. (120/120 total.) -- Notification feed (verb-filtered, per-recipient) as a thin layer over fanout. +- [x] Notification feed (verb-filtered, per-recipient) — `notify.sx`: + `feed/notifications`, `feed/notify-verbs`, `feed/notify-digest` (collapses + "X, Y liked Z" by (verb,object), sorted-deterministic); 8 tests. (128/128 total.) (none)