X recommendation filters: before a post reaches ranking | Gyo
Before X scores your post
X’s candidate filters check identity, history, access and budgets before this ranking stage. Their order explains more than a list of weights can.
Gyo
Sometimes there is no ranking score to explain. A candidate was removed before the pipeline’s scorers ran, and all the author sees is a post that went nowhere.
The previous article looked at X’s ranking coefficients: likes, copied links, negative feedback and the switches around them. Those numbers apply to candidates that reach the relevant scoring step. This article goes back one stage, to the filters deciding which candidates get that far.
It is tempting to call this the reason most posts fail. The code cannot supply that “most.” It shows the routes out of a candidate pool; measuring how often each route is taken would require serving data. There is still plenty to learn from the checks themselves.
Twenty-one filters, in a particular order
The Phoenix candidate pipeline registers 21 pre-scoring filters. The execution code retrieves and hydrates candidates, runs the filters, and passes the survivors to its scorers. Selection and another round of filtering follow later.
Here is the complete registration order. Names below omit the Filter suffix used by their Rust types.
Filter, in registration order
What it checks
1. DropDuplicates
Repeated copies of the same post ID
2. ZeroFavCodivert
Eligibility for a zero-favorite retrieval experiment
FAQ
Why did my X post stop getting views?
Low impressions alone do not identify the cause. In the examined pipeline, a candidate may be removed before scoring, ranked below other candidates, or dropped later. A serving trace would be needed to distinguish those paths.
Does X stop recommending posts after 48 hours?
This Phoenix candidate pipeline removes candidates older than its 48-hour maximum. That establishes a cutoff on this path, not the behavior of every recommendation surface, search result or profile visit.
Does deleting and reposting restore reach?
A genuinely new post has a new ID and creation time. That changes the inputs to identity and age checks, but retrieval, related-post history, access rules and ranking still apply. These functions do not establish a reach benefit.
Does duplicate filtering compare similar text?
The two deduplication functions examined here use post IDs and original-post IDs for reposts. They preserve the first occurrence and merge retrieval-source records. They do not compare the semantic similarity of separately published posts.
3. CoreDataHydration
Whether the candidate has a nonzero author ID
4. NewUserSoftNsfw
A content-safety rule for qualifying new viewers
5. Age
Candidate age against this pipeline’s maximum
6. SelfTweet
Whether the viewer is the author
7. OONRetweetReply
Out-of-network repost/reply eligibility and reply ancestors
8. OONNsfwSimclusters
A content-safety rule for the relevant retrieval path
9. RetweetDeduplication
Multiple candidates referring to the same original post
10. IneligibleSubscription
Whether the viewer has the required subscription
11. PreviouslySeenPosts
Seen IDs and Bloom-filter history
12. PreviouslySeenPostsBackup
Impression-history IDs
13. PreviouslyServedPosts
Served IDs, when enabled for this request
14. ViewerMutedKeyword
Text matching the viewer’s muted keywords
15. AuthorSocialgraph
Block and mute relationships, including related authors
16. Video
Video exclusion requested by the query
17. TopicIds
Topic inclusion and exclusion rules
18. NewUserMinEngagement
Engagement eligibility for qualifying new or returning viewers
19. InventoryHoldout
Configured holdout buckets by post kind
20. FavHoldout
A holdout percentage derived from favorite count
21. FairShareRanking
A candidate budget apportioned across served types
Twenty-one is the number registered, not a promise that every request executes all 21. The runner checks each filter’s enable method and applies the enabled filters in order. Each receives what the previous one kept.
The ordering matters most when a filter preserves the first occurrence of something. Once it has chosen that occurrence, a later scorer cannot go back and choose a different one.
There is another detail worth keeping in mind: “before scoring” means before this pipeline’s scorer stage. Candidates may already carry predictions or cached information from earlier work. The registration list is not a diagram of every calculation anywhere in X.
A duplicate is an identity, not a writing style
DropDuplicatesFilter uses candidate.tweet_id as its key. When the same ID appears again, it keeps the first candidate, merges the later candidate’s retrieval-source records into it, and removes the extra copy.
That is narrower than “X removes repetitive content.” Two posts with similar wording have different IDs; this function does not compare their meaning or phrasing. It is cleaning up a candidate that arrived through more than one source.
The first occurrence is affected by how sources are assembled. The source runner launches enabled sources concurrently, waits for their results, then appends those results in source-list order. It does not sort duplicate versions by content quality before choosing one. The duplicate’s source attribution is preserved through the merge, so the later source is not simply erased from the candidate’s history.
RetweetDeduplicationFilter uses a different key: the original post ID when the candidate is a repost, otherwise the candidate’s own ID. Several reposts of one original can collapse into one surviving candidate. Here too, the first occurrence is retained and retrieval-source records are merged.
Neither rule tells an author to rewrite a post to “escape duplicate detection.” These particular functions are looking at identity and repost relationships. Any claim about semantic repetition would need evidence from a different part of the system.
“Seen,” “impressed” and “served” are different records
The three history filters sit next to one another, but they read different inputs:
Filter
History input
Detail that matters
PreviouslySeenPosts
Explicit seen IDs plus Bloom filters
A match in either can remove a candidate
PreviouslySeenPostsBackup
impressed_post_ids
An empty list leaves the candidates unchanged
PreviouslyServedPosts
served_ids
Its enable condition also considers request context
The main seen filter consults both an explicit ID set and Bloom filters. A Bloom filter answers whether an ID may be present; the configured false-positive rate is part of that mechanism. It is not the same lookup as a simple history list.
The backup filter checks impression IDs. Despite its name, its code does not wait for a special “main lookup failed” signal. It runs at its registered stage and examines the history it has. If that history is empty, it keeps everything passed to it.
The served filter checks a third list. Its switch can enable it for all requests; otherwise the request’s bottom-loading and foreground-truncate context matter. The published default enables the all-requests option in this snapshot.
All three use the same related-ID iterator: the candidate ID, a repost’s original ID, and the parent ID of a reply when present. A candidate can therefore be affected by history attached to a related post, not just by its own ID.
For a reader, the point is familiar: a feed should have some memory of what it has already shown. For an author, that memory is easy to misinterpret. Retrieving the same identity through another source does not erase its history for that viewer. Publishing a new ID changes the identity being checked, but replies and reposts still carry relationships, and every other eligibility rule remains in play.
Forty-eight hours is a cutoff on this pipeline
The age filter reads creation age from the post’s ID and compares it with MAX_POST_AGE. The constant is 48 * 60 * 60 seconds.
Within this check, the distinction is sharp. A candidate at or below the maximum is kept. An older candidate is removed. There is no reduced score for the later ranker to compensate for—the candidate has left this path.
That makes age a useful thing to investigate when looking at an old post. It does not prove that any particular drop in impressions was caused by expiry. The filter belongs to the Phoenix candidate pipeline examined here; search, profile visits and other recommendation paths are separate questions.
It also gives us no basis for promising that deleting and reposting will restore reach. A newly created post has a new ID and creation time, but it still needs to be retrieved, pass the remaining filters and compete in ranking. An edit that leaves the ID unchanged does not change the creation age used by this function. Those are the mechanics we can establish; whether reposting helps an account is an outcome to measure.
The audience is part of eligibility
Some checks are specific to the person receiving the feed. A post can be eligible for one viewer and absent from another’s candidates without any change to the post itself.
AuthorSocialgraphFilter checks whether the viewer has muted or blocked the author. It also checks whether the author blocks the viewer, whether a quoted author blocks the viewer, and whether the viewer blocks an author involved in a quote or repost.
The author-to-viewer direction matters. Blocking someone can affect eligibility for that person’s feed; it is not accurate to describe this code as checking only the viewer’s choices. The function describes these relationships for a particular viewer. It does not show that an author has received a platform-wide penalty, or tell us what every analytics panel reports about it.
Three other checks help make the scope concrete:
Check
What the implementation reads
Muted keywords
The viewer’s keyword list and tokenized candidate text
Subscription eligibility
The candidate’s required subscription author and the viewer’s subscriptions
Topic scope
Requested/excluded topic IDs and the candidate’s hydrated topic IDs
The keyword filter leaves the pool alone when there are no muted keywords. The subscription filter keeps unrestricted candidates and checks the relevant subscription for restricted ones. These are access and preference checks, not estimates of writing quality.
Topic filtering is more involved than “add the right hashtag.” It operates on internal filtered and unfiltered topic IDs, with expansion rules and different handling for topic requests and exclusions. The implementation does not establish a direct mapping from an author’s hashtags to eligibility. Clear writing may help a reader understand the subject; this source alone does not prove it changes those internal IDs.
The video filter is simpler: it activates when the query requests video exclusion. The new-user engagement filter adds another conditional check for qualifying new or returning viewers. It can keep in-network candidates while testing available engagement metrics for out-of-network ones. As in the first article’s new-viewer example, the age being tested belongs to the reader.
FairShare removes candidates before this ranking stage
The last registered pre-scoring filter is FairShareRankingFilter. Its position is easy to miss because “Ranking” is in the name. In this pipeline, it runs in the filter stage, before the later scorers and selector.
The allocation algorithm is public. It groups candidates by served_type, falling back to the first retrieval source’s served type when necessary. It then divides a candidate budget in proportion to the number of candidates in each group.
For example, suppose groups arrive with 44, 22 and 44 candidates, and the budget is 100. Their allocations are 40, 20 and 40. The upstream test uses the same proportions at ten times that scale: 440, 220 and 440 become 400, 200 and 400 under a budget of 1,000.
When proportional shares are fractional, the implementation first takes the integer parts, then assigns the remaining slots to groups with the largest remainders. Ties use group order. Within each group, it keeps the first allocated candidates in their existing order. It does not perform a fresh highest-score sort at this point.
This is a budget over retrieval/serving groups, not a quota for an author’s writing genre. The published budget parameter is PhoenixScoresResultSize, set to 2,800 here. FairShare also has enable conditions: its switch must be on, the request must not be using cached posts, a scoring sequence must be present, and the relevant kill switch must not be active.
A smaller pool is left intact. A larger one is reduced before the next stage can rank its survivors. The code explains that trade-off precisely; it does not tell an author which posting rhythm would improve their share.
The 50 and 35 constants discussed in the first article belong to later selection and result-size steps. They are not the FairShare budget, and they do not mean an entire request begins with 50 candidates and returns exactly 35. Dividing them into a fixed “survival rate” would mix stages together.
Holdouts are conditional, not a ready-made explanation for a bad day
Two holdout filters appear just before FairShare. Both have disabled defaults in this snapshot. Their presence in the registration array is not enough to conclude that either affected a request.
InventoryHoldout computes a deterministic bucket from post and viewer IDs, then compares it with a configured percentage. Originals, replies and reposts can use different percentages. With its switch off or all percentages at zero, this filter is skipped.
FavHoldout derives a percentage from favorite count and uses the same bucket function. Counts at or below one get a zero holdout percentage; larger counts use the published step table, reaching 15%. The percentages and bucket arithmetic are visible in the code.
There is also an earlier experiment-related entry, ZeroFavCodivert. When enabled for the viewer, it distinguishes candidates retrieved exclusively through the zero-favorite source and uses an author experiment bucket to decide which of those to retain. Leaving it out would turn the supposedly complete list of 21 into a list of 20.
These mechanisms give us concrete questions to ask about a serving configuration. They are not evidence that a particular author’s quiet day was an experiment. To make that explanation, we would need the request’s actual switches, bucket decisions and candidate trace.
What an author can learn from this
The source is useful for ruling out bad explanations. Duplicate-ID checks do not establish a ban on similar wording. A viewer’s mute is not a global score penalty. An old candidate can fail this age filter, but an impression drop alone does not identify that filter. A table of holdout percentages does not prove the holdout was enabled.
It is less generous with growth advice than a checklist might suggest. There is no demonstrated shortcut here involving posting frequency, hashtags or deleting and reposting. Each would need evidence connecting an author’s action to a changed candidate path and then to real outcomes.
If serving traces were available, a useful investigation would follow the candidate through retrieval, hydration, each enabled filter, scoring and final selection. The runner retains removed candidates and records filter-stage metrics; seeing only final impressions leaves most of that journey unresolved.
That is the connection to the ranking weights. The coefficients explain how surviving predictions become scores. The filters explain why some candidates never reach that calculation. Both are parts of the system, and neither, by itself, is an account-level diagnosis.
There is a question before either step: how did the post become a candidate for this reader? The next article, How X finds posts for your feed, follows the retrieval sources, engagement-derived seeds and cached candidates that feed into these checks.
Source and verification notes
Updated and source-checked on November 3, 2026. Links are pinned to revision 9106e9f. A targeted comparison with revision b220448, then upstream HEAD, found no changes in the pre-scoring registration list, the pre-scoring filter implementations or the shared pipeline runner discussed here. The changed video-carousel filter belongs to post-selection work and is outside this article’s analysis.
This review follows the source; it does not run X’s complete serving system or inspect private request traces. The FairShare example was recalculated from its published allocation formula and checked against the example in the upstream test. The presence of that test is not a claim that the full repository test suite was run here.