Feed ranker
The feed is two stages. Retrieval is the fan-out into each user's precomputed feed, scored by a heuristic trending boost. Ranking is a learned re-order of the top 80 unseen entries, and it is the most developed model in the codebase.
Retrieval
Retrieval is the fan-out described in the architecture chapter: the author's own entry, three discovery seeders, and up to 12 friend waves five minutes apart. Each entry's score is its creation time plus a trending boost of one to 250 hours, plus four hours for friend posts. Marking seen subtracts a large constant so the index skips the entry.
Trending scores are recomputed every four minutes:
engagementRate = (likes + 0.2 * pollVotes) / max(views, 30)
timeDecay = 1 / (1 + ageHours / 24)
warmup = min(1, ageHours / 0.25)
score = (0.1 + engagementRate) * timeDecay * warmup
The same job runs viral reseeding. A public post between 15 minutes and 12 hours old with at least 15 views and 3% engagement is re-seeded to up to 22,000 users, taste-similar users first, with a milestone push to the author when it crosses 7,500.
Feed health keeps each user at a target of 300 unseen entries. It reserves 40% of seed slots for the users with the fewest entries, refills from popular posts when a feed drops below 50, and cleans entries older than 48 hours every hour.
Ranking
The production read path is the ranked query. It pulls the top 80 unseen candidates, computes roughly 25 base features per viewer-post pair, scores them with a six-head logistic regression, and carries a custom cursor so re-ranking happens only when the queue drains. The six heads are like, comment, feed dwell of seven seconds or more, detail dwell of three seconds or more, fullscreen open, and skip.
The feature vector has grown through eight versions, and the file comments record why each feature was added.
| Family | Features |
|---|---|
| Personal affinity | tasteSim, authorAffinity, friendCloseness, demoMatch (town, school cohort, age, sex), hobbyOverlap, authorIsFriend, graphSim |
| Post quality and momentum | likeRate, commentRate, pollVoteRate, viewVolume, recentVelocity, authorQuality, freshness, captionLength, hasImage, hasVideo, hasPoll |
| Page posts | pageFollowAffinity, isPageFollower, pageQuality (the personal author features are zeroed for page posts) |
| Session state | positionInSession, sessionDurationMs, sessionAvgFeedDwellMs, sessionMode (likes in the last 10 minutes), hourSin, hourCos |
| Social proof and crosses | friendLikersCount, a 2 by 2 of friend-or-stranger author and friend-or-stranger likers, freshness times friend, authorAffinity times video, tasteSim times likeRate |
| Debiasing controls | positionInPage and coldStart: stamped with real values at impression time, held constant at inference so their coefficients absorb position and cold-start bias |
Two of those features come from the embeddings: tasteSim compares the viewer's taste vector with the post's engagement vector, and graphSim compares the viewer's graph vector with the author's.
Scoring
Scoring is a weighted sum of the six head probabilities, then age damping:
score = ( 3.5·P(detailDwell) + 2.0·P(comment) + 2.0·P(fullscreen)
+ 1.0·P(like) + 0.2·P(feedDwell) − 0.7·P(skip) )
× 1 / (1 + ageHours / 48)
× min(1, ageMinutes / 15)
The action weights are hand-set, and the comments keep a tuning ladder. The skip weight was once minus 5.0, was pulled after the head learned inverted signs, and is being restored step by step as retrains beat baseline. It stands at minus 0.7 today.
Labels
Labels come from an impression log capped at ten million rows. Impressions are logged when a candidate is served, outcomes are stamped by later mutations, and impressions with no outcome after 30 minutes become skips.
Like and unlike are explicit. The dwell thresholds walked up over cycles, from three seconds to five to seven, trading class balance for label purity. Skip is re-derived at training time from dwell with per-content-type thresholds, rather than trusting the stamped flag.
Training
A nightly job at 03:00 retrains all six heads.
- Optimiser
- Mini-batch SGD, 30 epochs, batch 512, learning rate 0.05, L2 on the weights, no penalty on the intercept.
- Class imbalance
- Inverse-frequency class weights capped at 30, then intercept recalibration.
- Sign constraints
- The skip head trains under sign constraints through projected gradient: taste similarity can never raise the probability of a skip.
- Split and gate
- 80/20 stratified. A challenger ships only if it beats both the seed baseline and the current champion by 0.25% validation log loss.
- Data floors
- 5,000 examples and 30 positives per head. Feature-version cutoffs exclude rows logged before a feature existed.
- Memory budget
- Up to 100k rows packed into Float32 matrices to fit the 64 MB action budget.
Scope
The ranker is linear, and the feature crosses exist, in the words of the code comments, "without having to upgrade to GBDT". It does not rank the whole corpus: retrieval decides what is eligible and the ranker orders 80 candidates. It does not run on every scroll: with the custom cursor a session is ranked once and consumed until the queue drains.