diff --git a/lib/feed/conformance.sh b/lib/feed/conformance.sh index dc2fe512..77c1bde5 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 notify home dedupe) +SUITES=(basic fanout rank integration content notify home dedupe trending) OUT_JSON="lib/feed/scoreboard.json" OUT_MD="lib/feed/scoreboard.md" @@ -40,6 +40,7 @@ run_suite() { (load "lib/feed/content.sx") (load "lib/feed/notify.sx") (load "lib/feed/home.sx") +(load "lib/feed/trending.sx") (epoch 2) (eval "(define feed-test-pass 0)") (eval "(define feed-test-fail 0)") diff --git a/lib/feed/scoreboard.json b/lib/feed/scoreboard.json index e8a40eae..4f6c35af 100644 --- a/lib/feed/scoreboard.json +++ b/lib/feed/scoreboard.json @@ -7,9 +7,10 @@ "content": {"pass": 15, "fail": 0}, "notify": {"pass": 8, "fail": 0}, "home": {"pass": 6, "fail": 0}, - "dedupe": {"pass": 9, "fail": 0} + "dedupe": {"pass": 9, "fail": 0}, + "trending": {"pass": 11, "fail": 0} }, - "total_pass": 143, + "total_pass": 154, "total_fail": 0, - "total": 143 + "total": 154 } diff --git a/lib/feed/scoreboard.md b/lib/feed/scoreboard.md index e6895f72..29167bad 100644 --- a/lib/feed/scoreboard.md +++ b/lib/feed/scoreboard.md @@ -12,4 +12,5 @@ _Generated by `lib/feed/conformance.sh`_ | notify | 8 | 0 | 8 | | home | 6 | 0 | 6 | | dedupe | 9 | 0 | 9 | -| **Total** | **143** | **0** | **143** | +| trending | 11 | 0 | 11 | +| **Total** | **154** | **0** | **154** | diff --git a/lib/feed/tests/trending.sx b/lib/feed/tests/trending.sx new file mode 100644 index 00000000..63c54c09 --- /dev/null +++ b/lib/feed/tests/trending.sx @@ -0,0 +1,82 @@ +; Follow-up — trending objects/actors by recent activity. (feed-test name got expected) + +; window (50,100]: X@60,X@70 (a), Y@80 (b), Z@90 (c); W@40 is too old +(define + S + (feed/stream + (list + (feed/activity "a" "post" "X" 60 (list)) + (feed/activity "a" "post" "X" 70 (list)) + (feed/activity "b" "post" "Y" 80 (list)) + (feed/activity "c" "post" "Z" 90 (list)) + (feed/activity "d" "post" "W" 40 (list))))) + +; ---------- trending objects ---------- + +(feed-test + "trending count (3 in window)" + (len (feed/trending S 100 50 10)) + 3) +(feed-test + "trending top object" + (get + (nth (feed/trending S 100 50 10) 0) + :object) + "X") +(feed-test + "trending top count" + (get + (nth (feed/trending S 100 50 10) 0) + :count) + 2) +(feed-test + "trending order (count desc, key asc tiebreak)" + (map + (fn (e) (get e :object)) + (feed/trending S 100 50 10)) + (list "X" "Y" "Z")) +(feed-test + "trending top-2" + (map + (fn (e) (get e :object)) + (feed/trending S 100 50 2)) + (list "X" "Y")) +(feed-test + "old object W excluded" + (feed/-elem? + "W" + (map + (fn (e) (get e :object)) + (feed/trending S 100 50 10))) + false) +(feed-test + "narrow window keeps only newest" + (map + (fn (e) (get e :object)) + (feed/trending S 100 15 10)) + (list "Z")) +(feed-test + "empty window -> nothing" + (feed/trending S 100 5 10) + (list)) + +; ---------- trending actors ---------- + +(feed-test + "trending actor top" + (get + (nth (feed/trending-actors S 100 50 10) 0) + :actor) + "a") +(feed-test + "trending actor count" + (get + (nth (feed/trending-actors S 100 50 10) 0) + :count) + 2) +(feed-test + "trending actors order" + (map + (fn (e) (get e :actor)) + (feed/trending-actors S 100 50 10)) + (list "a" "b" "c")) diff --git a/lib/feed/trending.sx b/lib/feed/trending.sx new file mode 100644 index 00000000..c0a48b1a --- /dev/null +++ b/lib/feed/trending.sx @@ -0,0 +1,42 @@ +; feed/trending — what's hot right now: objects (or actors) ranked by activity +; count within a recency window. Deterministic: count descending, ties broken by +; key ascending (entries are pre-sorted by key, then stable grade-down by count). +; +; Requires: lib/feed/stream.sx, lib/feed/aggregate.sx (object/actor-counts), +; lib/feed/rank.sx (feed/-desc-by). + +; activities within (now-window, now] +(define + feed/-recent + (fn + (stream now window) + (feed/filter + stream + (fn (a) (and (<= (get a :at) now) (> (get a :at) (- now window))))))) + +; counts dict -> top-N entries {label key, :count n}, count desc, key asc +(define + feed/-top-counts + (fn + (counts label n) + (let + ((entries (map (fn (k) (assoc {:count (get counts k)} label k)) (sort (keys counts))))) + (take (feed/-desc-by entries (fn (e) (get e :count))) n)))) + +; top-N trending objects in the window +(define + feed/trending + (fn + (stream now window n) + (feed/-top-counts + (feed/object-counts (feed/-recent stream now window)) + :object n))) + +; top-N most active actors in the window +(define + feed/trending-actors + (fn + (stream now window n) + (feed/-top-counts + (feed/actor-counts (feed/-recent stream now window)) + :actor n))) diff --git a/plans/feed-on-sx.md b/plans/feed-on-sx.md index 80022842..7c70ee53 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` → **143/143** (Phases 1–4 + TF-IDF + notifications + home + smart-dedupe) +`bash lib/feed/conformance.sh` → **154/154** (Phases 1–4 + TF-IDF + notifications + home + smart-dedupe + trending) ## Ground rules @@ -151,6 +151,9 @@ are function parameters. Real acl-sx / fed-sx wire in at the call site unchanged `feed/smart-key`: reactions (like/follow/boost/...) collapse cross-actor on (verb,object); posts stay distinct per actor. `feed/collapse-verbs` is rebindable policy; 9 tests. (143/143 total.) +- [x] Trending — `feed/trending` / `feed/trending-actors`: objects/actors ranked + by activity count in a recency window, count-desc with key-asc tiebreak + (`trending.sx`); 11 tests. (154/154 total.) (none)