The shape of the pipeline
Candidates are bounded on purpose
UNION ALL with one branch per source. The sources are the viewer's network (themselves, people they follow, friends, liked Pages), the groups they belong to, their workspaces, and suggestions from outside their network. Every branch carries its own LIMIT.Where candidates come from
Illustrative valuesFriends and followed. The viewer, people and Pages they follow, friends and liked Pages, each on their own timeline.
- No source can starve another. A very active group cannot push every friend out of the candidate set, because it only ever gets its own branch's slots.
- The ranker works on a small set. Candidates are slim rows without post bodies, so ranking in memory is cheap and its cost is predictable no matter how large the network is.
- Nothing is counted. There is no total, no offset and no page number. A feed that does not know its own length never has to compute it.
From candidates to the first page
Illustrative valuesThe score is a product
Five factors multiplied: if any one is small, the whole score is small.
- base combines engagement and age. Engagement is damped with a logarithm so the first few reactions matter much more than the thousandth, and an exponential age decay lets new posts overtake old ones. Different engagement kinds count for different amounts.
- affinity is how much the viewer engages with this author, normalised, plus a flat bonus for friends. The constant keeps the factor above zero, so a stranger is demoted, never erased.
- interest is how well the post's hashtags and category match what the viewer has engaged with lately; sets how much it can lift a score.
- penalties multiply the score down for a post the viewer has already been shown, one they already reacted to, and one from outside their network.
- retention applies to videos only and is neutral until a video has enough views; see below.
Lifetime counts of each engagement kind, weighted and log-damped, times an exponential age decay.
For a suggestion the lifetime sum is swapped for its current heat h, so a post taking off now beats an old pile of reactions.
Anatomy of one score
Illustrative valuesFinal score, side by side
A strong suggestion still lands below an ordinary friend's post. That is intended, and it is the root of the starvation bug below.
export function scoreOf(c: RankInput, s: RankSignals): number {
let score =
baseOf(c, s) *
retentionFactor(c) *
(RANK.affinityBase + affinityOf(c.author, s)) *
(1 + RANK.interestBoost * interestOf(c, s))
if (s.seen.has(c.id)) score *= RANK.penalty.seen
if (c.reacted) score *= RANK.penalty.reacted
if (c.source === 'suggested') score *= RANK.penalty.suggested
return score
}Retention as a shrunk rate
Few views keep the estimate near the prior mean; many views let the data speak. The ratio to the mean is clamped, so retention can only nudge a score.
Heat: forward decay in a sorted set
// An engagement of weight w at time t, against a landmark t0.
export function forwardWeight(w: number, t: number, t0: number, halfLifeMs: number): number {
return w * 2 ** ((t - t0) / halfLifeMs)
}
// The heat at time now of a stored score.
export function heatAt(stored: number, now: number, t0: number, halfLifeMs: number): number {
return stored * 2 ** (-(now - t0) / halfLifeMs)
}Writing: an engagement of weight w at time t is grown by how far it lies after the landmark, then added to the stored score.
Reading: dividing by one shared factor gives every engagement its ordinary decay since it happened.
ZREVRANGE; decaying the few scores that come back to the present is trivial arithmetic.Forward decay: stored scores stay, the reading moves
Illustrative values- Stored
- 38.8
- changes only on an engagement
- Read factor
- 0.06
- same for every post
- Heat now
- 2.42
- stored × factor
The landmark and the daily rebase
t0, so it would eventually overflow a double. A daily job moves the landmark forward to the start of the current UTC day and multiplies every score by the matching factor. The order is unchanged and so is every heat value; only the stored numbers get smaller. The same pass drops entries that have gone cold and trims the set to the hottest few thousand, so memory stays flat.Moving the landmark from t0 to t0' scales every stored score by the same f, and the heat read at any time is unchanged.
Atomic writes
local t0 = tonumber(redis.call('GET', KEYS[2]))
if not t0 then
redis.call('SET', KEYS[2], ARGV[5], 'NX')
t0 = tonumber(redis.call('GET', KEYS[2]))
end
local inc = tonumber(ARGV[2]) * 2 ^ ((tonumber(ARGV[3]) - t0) / tonumber(ARGV[4]))
local score = redis.call('ZINCRBY', KEYS[1], inc, ARGV[1])local factor = 2 ^ (-(new - old) / tonumber(ARGV[2]))
redis.call('ZUNIONSTORE', KEYS[1], 1, KEYS[1], 'WEIGHTS', factor)Deduplication
SET ... NX EX. The first writer wins and later ones are ignored. Comments are not deduplicated, since several real comments from one person are real engagement. Nothing ever decrements heat, so deleting an engagement cannot be used to game the order either.Top K with a bounded min-heap
export function topK<T>(items: Iterable<T>, k: number, compare: (a: T, b: T) => number): T[] {
if (k <= 0) return []
const heap = new MinHeap<T>(compare)
for (const item of items) {
if (heap.size < k) heap.push(item)
else if (compare(item, heap.peek() as T) > 0) heap.replaceTop(item)
}
return heap.toArray().sort((a, b) => compare(b, a))
}Top K with a bounded min-heap
Illustrative values- 42
- 17
- 63
- 8
- 55
- 71
- 23
- 90
- 12
- 48
- 66
- 35
Diversity, and the starvation bug
Never the same author twice in a row, a cap per author in a sliding window, and a cap on suggestions in a shorter window.
The bug: suggestions never reached the top
K was cut by score first, so with a healthy network the cut-off sat above every suggestion. The spacing rule in the diversity pass had nothing to space, because no suggestion survived to be placed.The fix: a reserved share and a slot rhythm
Twelve slots, before and after the fix
Illustrative valuesNetwork only
A healthy network fills every slot by score. Fine, until suggestions should appear too.
Before: suggestions starved
After: reserved share and a slot rhythm
The best suggestions are held before the cut, then placed on every r-th slot; the network still holds most of the page.
// Suggestions score below friends' posts by design, so hold a share of the top K for the best of them.
const held = topK(suggested, Math.ceil(k * RANK.suggestionShare), compareRanked)
const heldIds = new Set(held.map(c => c.id))
const rest = topK(scored.filter(c => !heldIds.has(c.id)), k - held.length, compareRanked)
return diversify([...held, ...rest].sort((a, b) => compareRanked(b, a)))
// In diversify(): on a suggestion slot, prefer the best suggestion that fits.
if (out.length % RANK.suggestionEvery === RANK.suggestionEvery - 1) {
index = remaining.findIndex(c => c.source === 'suggested' && fits(c, out))
}
if (index < 0) index = remaining.findIndex(c => fits(c, out))One privacy decision, applied last
decide, which knows about audiences, blocks, mutes, group secrecy and sandbox accounts. The SQL pre-filter inside the candidate query is only an optimisation and is built to be never stricter than decide, so it can only remove rows the decision would remove anyway.- A stale list is safe. If someone is blocked after a ranked list was stored, the next page re-checks every id and the blocked author's posts simply disappear. No cache invalidation is needed for correctness.
- Ranking code cannot leak. A bug in scoring may produce a worse feed. It cannot produce a visible private post, because scoring is not where visibility is decided.
- A failed lookup throws. If the relations needed for a decision cannot be loaded, the request fails; content is never shown on a guess.
Signed cursors and the ranked session
export type ChronoCursor = { m: 'c'; t?: string; id?: string; s?: string }
export type RankedCursor = { m: 'r'; s: string; i: number }
function sign(body: string, binding: FeedCursorBinding): string {
const bound = JSON.stringify([binding.feed, binding.viewer, binding.filter ?? ''])
return crypto.createHmac('sha256', cursorKey()).update(`${bound}.${body}`).digest('base64url')
}- Signed. A cursor carries an HMAC, so a client cannot edit it to jump into someone else's session or to read an arbitrary position.
- Bound. The signature covers the feed, the viewer and the media filter. A cursor from another feed or another person fails verification.
- Keyset, not offset. The chronological shape stores the last
(created_at, id)pair and asks for rows strictly below it. The timestamp keeps the database's own precision so rows sharing a millisecond are not skipped. - Expiring. A cursor stops working after a while and the client is told to start again.
Failure behaviour
} catch (error) {
logger.debug('Could not record post heat', { error, kind })
return false
}How the feed degrades
Ranked feedHealthy
Flag on, Redis answers: candidates, signals and heat are all readable.
- heat read fails
Ranked, without heatDegraded
Suggestions fall back to their lifetime-engagement base. The page is still ranked.
- Redis down or slow, a signal is missing, or the flag is off
Chronological feedFallback
Same endpoint, same response shape, newest first. Ranking was an improvement; this always works.
- privacy relations cannot be loaded (any path)
Request failsRefused
The relations for a visibility decision could not be loaded. Content is never shown on a guess.
| What failed | What the reader sees |
|---|---|
| Redis is down or slow | A chronological feed, same endpoint and same response shape |
| Heat cannot be read | Suggestions keep their lifetime-engagement base; the page is still ranked |
| The stored session expired | The feed restarts chronologically; the client drops repeats by id |
| The ranking flag is off | Chronological, the same as a Redis outage |
| The cursor is invalid or expired | A clear 400, and the client starts again without a cursor |
What we have not solved yet
- Negative feedback. There is a penalty for posts a reader has already seen or reacted to, but no first-class "show me less of this" signal feeding back into affinity and interest.
- Impression-based ranking. Engagement is measured against all posts, not against how often a post was actually shown. A post that was shown rarely and engaged with often is under-rewarded, and a post shown to everyone is over-rewarded.
- Tuning from real data. The weights, the share held for suggestions and the half-life began as reasoned starting points. They need to be fitted against real behaviour, with offline replay and controlled experiments, before we can say they are right.