Skip to content

Commit d235a69

Browse files
author
Neo
committed
feat(social): server-side For You feed ranking through the seam (v1, no ML)
Adds a transparent, config-tunable "For You" ranker for the per-user social feed. Server ranks, client renders — the ranker runs in the platform, reached via SocialService.get_feed through the existing _call -> gate_action seam. - runtime/social/feed_ranker.py: pure, deterministic scorer. Four named signals (recency half-life, capped engagement, follow-affinity, discovery), every weight in one FeedWeights dataclass. NO machine learning, NO per-user model — the only personalization is the viewer's own follow set. - Guardrails: engagement ceiling (one viral post can't dominate); hard per-page discovery cap (a flood of non-followed posts can't take over, page is shorter not backfilled); author-diversity cap in every mode incl. cold-start (no single account can own even a new viewer's page); honest chronological fallback that is never dressed up as ranked. - SocialService.get_feed: adds `mode` (latest|for_you), filters to publicly-visible items BEFORE ranking (private/confidential never enters any feed), reads only already-stored signals, clamps non-positive limit. Builds weights from config via one source of default truth (dataclasses.replace). - get_feed_view: formats ranked items into the {posts:[...]} shape the iOS FeedResponse decodes (snake_case, ISO timestamps) — only public, already- ranked items reach it. Route now returns this shape and passes address/mode/ limit through the gate; rejects non-integer/non-positive limit with 400. - FEED_ALGORITHM.md: operator spec generated from the code; a drift test fails CI if the documented defaults leave the dataclass or DEFAULT_CONFIG. - Tests: 36 cases incl. adversarial gates (hidden-signal leakage, cap bypass, single-account domination, doc/code drift, fabricated-ranking honesty, privacy in both modes). Verified via a multi-lens adversarial review. Cold-start Sybil resistance (many distinct new accounts) is deliberately an identity-layer concern, out of ranker scope, and documented as such.
1 parent 7d208aa commit d235a69

7 files changed

Lines changed: 1138 additions & 19 deletions

File tree

FEED_ALGORITHM.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Feed Algorithm — "For You" ranking (v1)
2+
3+
Operator-facing spec for the social-feed ranker. It is **generated from and kept in
4+
lockstep with** [`runtime/social/feed_ranker.py`](runtime/social/feed_ranker.py); a
5+
drift test (`tests/test_feed_algorithm_doc.py`) fails CI if the weight defaults here
6+
stop matching the code.
7+
8+
> **v1 is transparent, weighted, config-tunable scoring.**
9+
> **There is no machine learning and no per-user model anywhere.** The only
10+
> personalization is the viewer's own follow set — a signal they control directly by
11+
> following/unfollowing. Any future "engagement learning" is a separate proposal
12+
> requiring explicit sign-off, not part of this system.
13+
14+
## Where it runs
15+
16+
**Server-side, in the 0pnMatrx platform** — never on the device. The iOS client calls
17+
`GET /api/v1/social/feed/{wallet}?mode=for_you`, which crosses the standard
18+
`_call → gate_action` security seam like every other service, then invokes
19+
`SocialService.get_feed(address, limit, mode)`. **The server ranks; the client
20+
renders.** The client never sees a ranking signal it didn't already have.
21+
22+
Two modes on the same endpoint:
23+
24+
| `mode` | Behavior |
25+
|---|---|
26+
| `latest` (default) | Chronological — posts from followed authors + the viewer's own, newest first. Also the **honest fallback** whenever ranking can't run. Never dressed up as "For You". |
27+
| `for_you` | The transparent weighted ranking described below. |
28+
29+
## Signals (the ONLY inputs)
30+
31+
The ranker reads exactly four already-stored, non-sensitive signals per post,
32+
normalized into a `FeedCandidate(id, author_id, created_at, likes, comments)`:
33+
34+
1. **Recency**`created_at` (post publish time).
35+
2. **Engagement** — public `likes` count + public `comments` count.
36+
3. **Affinity** — does the viewer follow the author? (boolean, from the viewer's own follow list)
37+
4. **Discovery** — the complement of affinity (the author is not followed).
38+
39+
It reads **nothing else** — no message bodies, no DMs, no private flags, no profile
40+
PII, no watch-time, no cross-user behavioral history. `FeedCandidate` is the ranker's
41+
entire input surface, and a test asserts it exposes only those five fields, so a new
42+
signal cannot be added silently.
43+
44+
## Privacy (absolute, both modes)
45+
46+
`SocialService.get_feed` filters the candidate set to **publicly-visible items only**
47+
(`_is_public_item`) **before** ranking or chronological assembly. Private or
48+
confidential content (any item whose visibility is not `public`) **never becomes a
49+
candidate**, so it structurally cannot enter anyone's feed — not the ranked feed, not
50+
the Latest feed, not even the viewer's own private items. Proof-shares carry an
51+
explicit `visibility`; plain posts have none and are public by default.
52+
53+
## The score
54+
55+
For each public candidate the linear score is a weighted sum of four terms:
56+
57+
```
58+
score = w_recency * recency_term
59+
+ w_engagement * engagement_term
60+
+ w_affinity * (1 if followed else 0)
61+
+ w_discovery * (1 if not followed else 0)
62+
```
63+
64+
- **recency_term** = `0.5 ** (age_hours / recency_halflife_hours)` — exponential
65+
half-life decay. Future/zero ages clamp to `1.0` (clock-skew safe).
66+
- **engagement_term** = `min(log1p(likes + comment_weight * comments), engagement_ceiling)`
67+
— diminishing returns via `log1p`, then **hard-capped** at the ceiling.
68+
69+
Every ranked item carries a `breakdown` of its four term contributions, so any
70+
ordering is fully explainable and can be surfaced for debugging — the ranking is
71+
never fabricated.
72+
73+
## Weights (defaults — all operator-tunable)
74+
75+
Set under the `feed_ranker` key of the social service config
76+
(`SocialService` merges it over `DEFAULT_CONFIG`). Change a number → the feed changes,
77+
explainably, with no retraining.
78+
79+
| Knob | Default | Meaning |
80+
|---|---|---|
81+
| `recency` | `1.0` | weight of the recency term |
82+
| `engagement` | `0.6` | weight of the (capped) engagement term |
83+
| `affinity` | `0.9` | boost for authors the viewer follows |
84+
| `discovery` | `0.4` | weight for non-followed authors' posts |
85+
| `recency_halflife_hours` | `6.0` | a post loses half its recency term every N hours |
86+
| `comment_weight` | `2.0` | a comment counts as this many likes in engagement |
87+
| `engagement_ceiling` | `4.0` | max engagement term — the anti-domination cap |
88+
| `discovery_cap_fraction` | `0.20` | max share of a page from non-followed authors |
89+
| `max_posts_per_author` | `3` | max slots one author may occupy on a page (every mode) |
90+
91+
## Guardrails
92+
93+
- **Engagement ceiling (one viral post can't dominate).** Past the ceiling, more
94+
likes/comments add **zero** score, so a runaway-viral post can't swamp everything;
95+
recency and affinity still decide order among high-engagement posts.
96+
- **Discovery hard cap (no flood).** At most `floor(discovery_cap_fraction * page_size)`
97+
of a page comes from non-followed authors. When followed content can't fill the page,
98+
the page is **shorter** — it is *never* backfilled with strangers. This is what stops
99+
a flood of high-engagement non-followed posts from taking over a feed. The excess is
100+
not destroyed; a larger page / future paginated call surfaces it.
101+
- **Author diversity (no single-account takeover).** A hard cap in *every* mode,
102+
including cold-start: no single author may occupy more than `max_posts_per_author`
103+
slots on a page. This stops one account — viral or spamming — from owning a page
104+
even when there is no follow signal to lean on (a brand-new viewer). It bounds
105+
per-account dominance; it does **not** solve distinct-account (Sybil) collusion,
106+
which is an identity-layer concern a ranker cannot fix — an honest limitation, not
107+
a silent one.
108+
- **Cold-start (new users).** A viewer who follows no one gets an all-discovery feed
109+
ranked by recency + engagement (the follow-based discovery cap is lifted —
110+
everything is discovery), still **author-diversity-capped** so no single account
111+
dominates, and bounded by the requested page size. As they follow people, affinity
112+
takes over.
113+
- **Honest fallback.** If ranking cannot run (e.g. a misconfigured weight), `get_feed`
114+
logs and returns the chronological **Latest** feed — it never 500s and never
115+
fabricates a ranked order or a score.
116+
117+
## Performance
118+
119+
The ranker is O(n log n) over the candidate set (one score pass + one sort), pure
120+
Python, no I/O, no network, no model load — the same order of cost as the chronological
121+
sort it augments. Parity with the Latest feed is expected.
122+
123+
## Live updates
124+
125+
Both modes are served over the same endpoint and the same SSE feed-event stream, so
126+
iOS live-updates work identically whether the user is on **For You** or **Latest**
127+
a new post triggers a refetch in the active mode.

gateway/service_routes.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,11 +1746,29 @@ async def _handle_community_create(self, request: web.Request) -> web.Response:
17461746
return self._ok(result)
17471747

17481748
async def _handle_social_feed(self, request: web.Request) -> web.Response:
1749+
# NOTE: SocialService.get_feed takes `address` (this route historically
1750+
# passed `wallet=`, which the method never accepted → 400). Fixed here.
17491751
wallet = request.match_info["wallet"]
1750-
result = await self._call(
1751-
"social", "get_feed",
1752-
wallet=wallet,
1753-
)
1752+
mode = request.query.get("mode", "latest")
1753+
kwargs: dict = {"address": wallet, "mode": mode}
1754+
limit_raw = request.query.get("limit")
1755+
if limit_raw:
1756+
try:
1757+
limit = int(limit_raw)
1758+
except ValueError:
1759+
raise web.HTTPBadRequest(
1760+
text=json.dumps({"error": "limit must be an integer"}),
1761+
content_type="application/json",
1762+
)
1763+
if limit <= 0:
1764+
raise web.HTTPBadRequest(
1765+
text=json.dumps({"error": "limit must be a positive integer"}),
1766+
content_type="application/json",
1767+
)
1768+
kwargs["limit"] = limit
1769+
# get_feed_view wraps get_feed (same privacy + ranking) and returns the
1770+
# {"posts": [...]} shape the iOS FeedResponse decodes.
1771+
result = await self._call("social", "get_feed_view", **kwargs)
17541772
return self._ok(result)
17551773

17561774
# -- Payments Expanded --

0 commit comments

Comments
 (0)