|
| 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. |
0 commit comments