Candidate sources.
When Maya opens Stoop, about 940 unseen posts from her friends, groups and pages are eligible, and millions more from outside her network could be. The ranker can afford 600. This topic decides which 600 get through. It covers where candidates come from, how each source cheaply ranks its own pile, how the slots are split between sources, and how to tell when a source has stopped earning its share.
Builds on Multi-stage funnels and Two towers and a nearest-neighbour index.
Framing.
Candidate generation decides what the ranker is allowed to consider. A post that never becomes a candidate can't be ranked, however good the ranker is.
From business goal to ML task
Stoop is a fictional social app for friends, neighbourhood groups and local pages such as a bakery, the library or the council. Its Home feed mixes posts from people you know, groups you joined, pages you follow, and a small share from outside your network. Every Stoop number here is illustrative. Each Home request starts with one question: out of everything this person could see, which few hundred posts are worth scoring at all?
| Layer | Definition |
|---|---|
| Business goal | Viewers come back because their feed shows what their people and places are up to (measured as 28-day return). |
| ML objective | Of the posts a viewer would engage with in this session, maximise the share that are among the 600 candidates (engagement recall@600). |
| ML task | Several retrievers (inbox reads, index reads, a graph walk, an embedding search), each trimmed by a cheap per-source model, merged under quotas. |
| Out of scope | Scoring and ordering (engagement model, value model), never-show and demote decisions (integrity), serving plumbing such as fan-out and cursors (news-feed). |
What candidate generation must do
Scale and budget
Stoop's Home feed, from sources to screen
Read the diagram from right to left to see why this stage matters. Everything after the mixer can only reorder or drop posts: the integrity check drops what must not be shown and passes on a multiplier, the engagement model predicts, the value scorer turns predictions into one number. None can add a post the sources missed. So the ceiling on feed quality is set here: if a close friend's news never reaches the 600, the best ranker in the world can't show it. The later stages exist to pick well among the candidates. This one exists to make sure the right ones are in the pile.
Data.
Every candidate carries a tag saying which source found it. Without that tag you can't tell which source earns its slots.
| Source | Used as | Note |
|---|---|---|
| Impression log with source tags | Labels for per-source light rankers | One row per shown post: viewer, post, the source or sources that found it, position, and what the viewer did. A post found by two sources keeps both tags. |
| Viewer-to-viewer interactions (likes, comments, messages, profile visits; 90 days, decayed) | Edge weights for the graph walk and the friends ranker | Turned into a predicted chance that one person interacts with another, the same idea as the real-graph model in X's open-sourced recommender. |
| Engagements that started outside the feed (search, notifications, a friend's profile) | Recall ground truth | Posts the viewer clearly wanted but the feed never offered. This is the only direct evidence of what the sources missed. |
| Random-inventory sample (0.1% of requests: the heavy ranker scores all ~940 connected posts, logged, not shown) | Offline recall baseline | Shows how much the light rankers throw away that the heavy ranker would have liked. |
What is eligible for one request (Maya, a typical viewer)
- Friends who post
- 1901.5 posts each per 3 days
- Groups joined
- 1445 posts each per 3 days
- Pages followed
- 704 posts each per 3 days
- Already seen (friends · groups · pages)
- 30% · 20% · 15%
- Unseen friend posts190 × 1.5 = 285; × 0.7~200from Friends who post and Already seen (friends · groups · pages)
- Unseen group posts14 × 45 = 630; × 0.8504from Groups joined and Already seen (friends · groups · pages)
- Unseen page posts70 × 4 = 280; × 0.85238from Pages followed and Already seen (friends · groups · pages)
- Eligible connected posts200 + 504 + 238942from Unseen friend posts, Unseen group posts and Unseen page posts
- Eligible out-of-network postspublic posts from the last 72 hmillionsOnly a graph walk or an embedding search can reach these; no filter over the inbox will.
- Friends' posts (~200) fit under their quota of 240, so they are never trimmed. The unused slots spill over to groups first, then to interest.
- Groups and pages need a light ranker to cut them down (504 → 165 and 238 → 60).
- Out-of-network posts need a retriever that goes looking for them, not a filter over what already arrived.
The impression log has a blind spot built in: a viewer can only engage in the feed with posts that some source already offered. Train a light ranker on feed engagements alone and it learns to agree with the sources it already has. The off-feed engagements and the random-inventory sample exist to break that loop. They are small, so weight them up when you measure recall, and keep them out of the rows that train the models (see exploration-vs-exploitation/feedback-loops).
Features.
Each source's light ranker sees only cheap features: a few dozen per post, mostly precomputed.
| Feature | Used by | Type |
|---|---|---|
| Tie strength (predicted chance the viewer interacts with the author in the next 7 days) | friends · graph walk | daily batch score |
| Days since last interaction with the author | friends | numeric, bucketed |
| Viewer's engagement rate in this group or on this page (28 days) | groups · pages | numeric |
| Group activity (posts per day, members) | groups | numeric, log-scaled |
| Post age · type (text, photo, video) | all | bucketed · categorical |
| Early engagement (likes and comments in the first hour per 100 impressions) | all | streaming counter |
| Close friends who engaged, weighted by tie strength | graph walk | computed during the walk |
| Distance from the viewer's home area | fresh local | numeric, km |
Tie strength, the feature everything reuses
Tie strength is a small model of its own. For each pair of viewer and friend it predicts whether the viewer will like, comment on or message that friend in the next 7 days, from interaction counts with time decay, mutual friends and shared groups. X's open-sourced code has a component with the same job, real-graph, which predicts how likely one account is to interact with another. Stoop scores Maya's ~190 friend edges every night, and three consumers read the result: the friends light ranker, the graph walker, and the engagement model in the next topic.
A friend added yesterday has no interaction history, so for the first few weeks her score leans on mutual-friend count and shared groups. Without that fallback, every new friendship would start at zero and never get the exposure that creates the history the model needs.
- Pro:Scores ~900 posts in well under a millisecond on one core
- Pro:Easy to read which feature moved a post
- Pro:Retrains in minutes per source
- Con:No feature interactions unless you add crosses by hand
- Con:Its scores are not comparable across sources
Needs the heavy ranker's scores on the dropped posts too (the random-inventory sample); Harder to debug when one source's share shifts
Model.
Six sources, each cheap and each biased in its own way, then one decision about how many slots each gets.
Stoop's sources
| Source | How it finds posts | Light ranker | Blind spot |
|---|---|---|---|
| Friends | Reads the fan-out inbox (news-feed/fan-out-on-write). | None needed; all ~200 pass. | Friends who post rarely can still be buried by busy ones, but that happens in ranking, not here. |
| Groups | Reads each joined group's recent posts from the index. | Logistic regression on the light features; keeps 165. | Busy groups crowd out small ones, so each group is capped at 30. |
| Pages | Reads followed pages' recent posts. | Same model family; keeps 60. | A page that posts 20 times a day. |
| Friends engaged with | Walks from the viewer to her 20 strongest ties, then to posts they liked or commented on in the last 48 h. | The sum of tie strengths of the friends who engaged. | Amplifies whatever her friends already like. |
| Interest | Two-tower user vector, ANN search over public posts from the last 72 h (video-recommendation/candidate-retrieval). | The dot product. | Needs history, so it is weak for new viewers. |
| Fresh local | Posts under 2 h old within 5 km. | Early-engagement rate. | Brand-new posts with no signal yet. |
Real systems split the work the same way. X's 2023 write-up says its For You timeline averages half posts from followed accounts and half from outside, the latter found by walking a user-to-post engagement graph and by searching community embeddings (SimClusters). Pinterest's Pixie runs random walks on a graph of 3 billion nodes and 17 billion edges, and one server answers 1,200 requests a second at 60 ms (Eksombatchai et al. 2018). Stoop's six sources are a smaller version of that mix.
Share of a source's engaged posts captured, by slots given (Stoop, illustrative)
- Groups
- Pages
- Interest
Data
| Slots given to the source | Groups (%) | Pages (%) | Interest (%) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 20 | no value | 45 | no value |
| 25 | 30 | no value | 30 |
| 40 | no value | 70 | no value |
| 50 | 50 | no value | 45 |
| 60 | no value | 84 | no value |
| 90 | no value | 90 | no value |
| 100 | 74 | no value | 60 |
| 150 | 86 | 95 | 68 |
| 200 | 92 | no value | 73 |
| 300 | 97 | no value | no value |
- At 90: pages quota
- At 50: interest quota
Moving 30 slots away from pages
- Engaged posts per request each source could supply if uncapped
- groups 1.2 · pages 0.5 · interest 0.6
- Groups slope, 150 → 200 slots
- 6 points per 50 slots
- Pages slope, 60 → 90 slots
- 6 points per 30 slots
- Interest slope, 50 → 100 slots
- 15 points per 50 slots
- One more page slot is worth0.5 × 6% ÷ 300.0010 engaged postsfrom Engaged posts per request each source could supply if uncapped and Pages slope, 60 → 90 slots
- One more group slot is worth1.2 × 6% ÷ 500.00144 engaged postsfrom Engaged posts per request each source could supply if uncapped and Groups slope, 150 → 200 slots
- One more interest slot is worth0.6 × 15% ÷ 500.0018 engaged postsfrom Engaged posts per request each source could supply if uncapped and Interest slope, 50 → 100 slots
- Loss: pages 90 → 606 points × 0.50.030 per requestfrom Engaged posts per request each source could supply if uncapped and Pages slope, 60 → 90 slots
- Gain: groups 150 → 165, interest 50 → 6515 × 0.00144 + 15 × 0.0018 = 0.0216 + 0.0270.0486 per requestfrom One more group slot is worth and One more interest slot is worth
- Net gain0.0486 − 0.030 = 0.0186; × 160M requests~3.0M engaged posts a dayfrom Loss: pages 90 → 60 and Gain: groups 150 → 165, interest 50 → 65
- Give the next slot to the source where it brings in the most future engagement, and re-measure after each move, because the slopes flatten as a source grows.
- Why not all 30 to interest? Its curve is still straight up to 100 slots, so the limit is the guardrail, not the curve. With 65 interest slots, out-of-network impressions are 11 + 11 + 2 = 24% against the 25% cap; at 80 slots interest would reach about 11 × 80 ÷ 65 = 13.5%, taking the total to about 26.5%.
- Final quotas: friends 240, groups 165, pages 60, friends engaged 60, interest 65, fresh local 10 = 600.
- Pro:Each source stays easy to debug on its own
- Pro:A slow source costs only its own slots
- Pro:Quotas stop one source from flooding the candidates
- Con:Quotas need re-tuning as sources change
- Con:Scores are not comparable across sources
Needs the same features for every source's posts; The source with the most candidates tends to dominate; One model to retrain for any change to any source
~940+ heavy scores per request instead of 600, about 57% more cost (940 ÷ 600 = 1.57); Still finds nothing outside the network
The shared-ranker option is not a straw man. Large feeds often trim the union of all sources with one first-pass model, sometimes distilled from the main ranker; serving-architectures/multi-stage-funnels walks through published examples and transfer-learning-and-compression/distillation covers how such a model is trained. It suits a system whose sources share one feature set. Stoop keeps per-source quotas while its sources are few and differ a lot; merging is the natural move once they converge.
Evaluation.
A source is judged by what it adds that no other source would have found.
Each source's share of candidates, impressions and engagements (Stoop, illustrative)
- Candidates
- Impressions
- Meaningful interactions
Data
| Source | Candidates (%) | Impressions (%) | Meaningful interactions (%) |
|---|---|---|---|
| Friends | 33.3 | 44 | 58 |
| Groups | 34.2 | 26 | 22 |
| Pages | 10 | 6 | 3 |
| Friends engaged | 10 | 11 | 9 |
| Interest | 10.8 | 11 | 6 |
| Fresh local | 1.7 | 2 | 2 |
Correlation is not a source's value
The chart is correlational: the ranker decides which candidates get shown, so a source can look weak because the ranker distrusts it, or strong because it duplicates posts another source would have supplied anyway. The causal test is a leave-one-source-out arm, or a halved quota, run for 2 to 4 weeks (a-b-testing/experiment-design). At Stoop, watch the out-of-network sources closely: if one lifts clicks and likes while comments, shares or return fall, it is not earning its slots, so judge them on meaningful interactions and 28-day return, not on clicks. Out-of-network impressions here add up to 11 + 11 + 2 = 24%, just under the 25% guardrail.
Serving and monitoring.
Sources are called in parallel with a deadline. A late source loses its slots; the page never waits.
One Home request when the graph walker is slow
- Source mixer → Friend inbox: refs, last 72 h
- Source mixer → Group and page index: groups, pages, fresh local
- Source mixer → Graph walker: walk 20 ties, 40 ms
- Source mixer → Interest retriever: ANN top 300
- Friend inbox → Source mixer (reply): ~285 refs
- Group and page index → Source mixer (reply): ~910 posts + local
- Interest retriever → Source mixer (reply): 300 posts
- Note over Graph walker: misses deadline
- Note over Source mixer: filter · dedupe · trim · quotas; walker's 60 → groups
- Source mixer → Ranking stack: 600 + source tags
| Step | When | What happens |
|---|---|---|
| Score tie strength | Nightly | Score every viewer-friend edge; the friends ranker, the graph walker and the engagement model read it. |
| Embed new public posts | Every few minutes | Append their vectors to the interest index so a post can be found within minutes of being written. |
| Retrain light rankers | Daily per source | Train on yesterday's tagged impressions; compare recall on the random-inventory sample before swapping. |
| Re-tune quotas | Monthly or after a source changes | Redraw the marginal-recall curves and move slots toward the steepest one, within the out-of-network guardrail. |
What to watch for
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| Walker keeps timing out3Graph walker | Fewer posts that close friends engaged with | Its share of candidates falls to zero on the per-source dashboard | Backfill its slots from groups; cap the walk's depth; alert on the source's share, not only on latency. | Viewers still get 600 candidates, with fewer posts their friends engaged with. |
| Stale index4Interest retriever | Out-of-network picks are old news | Out-of-network candidates are all more than 24 h old | Append new posts' vectors every few minutes; alert on the age of the newest vector. | In-network sources still fill their quotas. |
| Popularity loop in the friends-engaged source | The same viral posts fill every feed | The share of candidates coming from the top 100 posts keeps rising | Down-weight posts already engaged with by many ties across the network; reserve exploration slots (exploration-vs-exploitation/exploration-slots). | Other sources keep their own quotas. |
| Inbox gaps for accounts with huge followings1Friend inbox | Viewers miss big accounts' new posts | Friends' posts reach the feed hours late; time from post to first candidate climbs | Read large accounts at request time instead of pushing them (news-feed/fan-out-on-write). | Posts arrive late, not lost. |