If you have been in the social media space long enough, you have certainly heard about the omnipresent concept of “the algorithm”. Long story short, the truth is that there is no such thing as “the algorithm”, and what we really have on these platforms are complex (and often very diffuse) systems trying to optimize a single metric. Sometimes that metric is watch time; sometimes it is clicks; sometimes it is purchases. Only the developers of the platforms know exactly what they are trying to accomplish with the recommendation engine.
That’s why, in the end, those social media or marketing “experts” who claim to know exactly how the algorithm works are usually just very well-spoken scams. But that’s a story for another day.
However, these days X finally open-sourced the code of the system they use for the (infamous?) “For You” section. In this post, I will try to explain it to you. Not because I want to give advice on how to become viral (if you want to use these learnings for that, go ahead), but because it is a fun learning exercise.
I want to break this post into a few pieces so you can better understand some of the ideas behind it. I won’t dig deep into the engineering part of the solution, but will focus mostly on the algorithm itself, just to understand what lies at the heart of the system.
One thing I want to say before jumping into the explanation of the structure of the algorithm (and I will expand on this discussion in section 5) is that this new system is optimized solely to align with your preferences. Not to challenge them, not to open your mind; it wants you to see what you are interested in.
With that in mind, let’s get started.
1. Getting the posts to show
At the moment you access the For You section, the system quickly puts together two different pieces. First, it gathers information about the user’s activity and some features of the account itself. It then uses that information as input to retrieve candidate posts through two different approaches: posts from people you follow, and posts from people you don’t follow. Let’s dig deeper into that.
Getting User Data
In order to gather all the information from a user who is about to spend a few minutes reading posts on X, two specific actions happen at this point. First, the system extracts the sequence of your most recent actions. That is, it identifies the interactions you have had during a certain period of time and caps them to the last N events if you performed more within that time window. Then, the entire sequence is converted into a binary Protobuf format.
let mut aggregated_actions = self.aggregator .run(&filtered_actions, p::UAS_WINDOW_TIME_MS, 0);// Truncate to max sequence length (keep last N items)if aggregated_actions.len() > p::UAS_MAX_SEQUENCE_LENGTH { let drain_count = aggregated_actions.len() - p::UAS_MAX_SEQUENCE_LENGTH; aggregated_actions.drain(0..drain_count);}
What kind of actions does the system track? Looking at different pieces of the code, we can infer the action types: likes (favorites), replies, reposts, quote tweets, clicks on posts, clicks on profiles, video views, photo expansions, shares (including via DMs and copy link), dwell time (how long you looked at a post), and even negative signals such as marking something as “not interested”, blocking, muting, or reporting. Each of these becomes a data point in your engagement history.
Secondly, the system retrieves user features that are not behavior-related. Concretely, the features the system extracts are the following:
pub struct UserFeatures { pub muted_keywords: Vec<String>, pub blocked_user_ids: Vec<i64>, pub muted_user_ids: Vec<i64>, pub followed_user_ids: Vec<i64>, pub subscribed_user_ids: Vec<i64>,}
As you can see in this section, the information about the user is pretty straightforward: what you have done within a given time window (it would be interesting to see how large this window is, but I don’t expect it to be very long), which users or topics you don’t want to see, and which users you follow.
One kind of surprisingly fact: the system does not consider at all what you write. It doesn’t matter what your style is when it comes to writing posts; it only considers your last actions.
Candidate posts data
The candidates are the posts that can potentially be shown to you. In turn, there are two main sources from which candidate posts are retrieved: posts from your network (that is, posts from people you actually follow) and out-of-network posts that might be relevant to you.
For in-network posts, the system first checks the user’s following list, applies a hard limit to its length (surely for performance reasons), and then sorts it by recency. This means that the system prioritizes posts from the accounts you follow that are more recent. Additionally, the system explicitly removes post IDs that the user has already seen within the same session.
The in-network posts come from a service called Thunder, which is an in-memory post store that ingests posts from Kafka in real time. Thunder keeps posts from all users in memory, organized by author, and can return posts from the accounts you follow. Internally, it maintains three separate stores: one for original posts, one for replies and reposts, and one specifically for video content.
Things get a lot more interesting for the out-of-network posts. At this step, the system uses all the information collected from the user that we discussed previously and calls the service phoenix_retrieval_client.retrieve(). This retrieves a new list of posts that may be relevant to you but do not come directly from users you follow. This is, in a way, the exploration step of the system.
let response = self .phoenix_retrieval_client .retrieve(user_id, sequence.clone(), p::PHOENIX_MAX_RESULTS) .await .map_err(|e| format!("PhoenixSource: {}", e))?;
Of course, there is a whole set of interesting logic behind the idea of “posts that can be relevant to you”, which we will dig deeper into in section 2.
Hidration of data
After candidates are retrieved from both sources, the system After candidates are retrieved from both sources, the system needs to “hydrate” them, which essentially means fetching additional metadata that wasn’t available during retrieval. This includes:
- Core post data: the actual text, media attachments, and creation time
- Author information: username, verification status, and profile information
- Video duration: for video posts (used later in scoring)
- Subscription status: whether the post is behind a paywall
- Visibility information: whether the post has been flagged for any safety issues
All of these hydrators run in parallel to minimize latency. If any hydration step fails, the system logs an error but continues with whatever data it was able to fetch.
2. How to get posts from the out-of-network space?
Here’s where the real machine learning comes into play. X uses a system called Phoenix. This recommendation system has two main components: the retrieval part, which is used when fetching out-of-network candidate posts, and the ranking part, where all candidates are scored and mixed together. Let’s focus first on the retrieval part.
Retrieval system: Two Tower model
Like most modern retrieval systems, Phoenix uses a Two-Tower architecture to encode and retrieve posts. In this model, there are two different towers: the candidate tower (that is, the tower for posts) and the user tower. The final goal of this architecture is to project both representations into the same vector space, so the system can perform similarity search over candidates at inference time. Let’s dig deeper into the two towers.
Candidate Tower
The candidate tower is a pretty simple one: it is just an MLP that takes the concatenated post and author embeddings (read from a lookup embedding table), passes them through two projection layers using the SiLU activation function, and then normalizes the resulting embedding. This is the original code that implements this:
class CandidateTower(hk.Module): """Candidate tower that projects post+author embeddings to a shared embedding space. This tower takes the concatenated embeddings of a post and its author, and projects them to a normalized representation suitable for similarity search. """ emb_size: int name: Optional[str] = None def __call__(self, post_author_embedding: jax.Array) -> jax.Array: """Project post+author embeddings to normalized representation. Args: post_author_embedding: Concatenated post and author embeddings Shape: [B, C, num_hashes, D] or [B, num_hashes, D] Returns: Normalized candidate representation Shape: [B, C, D] or [B, D] """ if len(post_author_embedding.shape) == 4: B, C, _, _ = post_author_embedding.shape post_author_embedding = jnp.reshape(post_author_embedding, (B, C, -1)) else: B, _, _ = post_author_embedding.shape post_author_embedding = jnp.reshape(post_author_embedding, (B, -1)) embed_init = hk.initializers.VarianceScaling(1.0, mode="fan_out") proj_1 = hk.get_parameter( "candidate_tower_projection_1", [post_author_embedding.shape[-1], self.emb_size * 2], dtype=jnp.float32, init=embed_init, ) proj_2 = hk.get_parameter( "candidate_tower_projection_2", [self.emb_size * 2, self.emb_size], dtype=jnp.float32, init=embed_init, ) hidden = jnp.dot(post_author_embedding.astype(proj_1.dtype), proj_1) hidden = jax.nn.silu(hidden) candidate_embeddings = jnp.dot(hidden.astype(proj_2.dtype), proj_2) candidate_norm_sq = jnp.sum(candidate_embeddings**2, axis=-1, keepdims=True) candidate_norm = jnp.sqrt(jnp.maximum(candidate_norm_sq, EPS)) candidate_representation = candidate_embeddings / candidate_norm return candidate_representation.astype(post_author_embedding.dtype)
That sounds great, but… how do they get the post and author embeddings? Well, there are, again, two independent pieces here. Essentially, both posts and authors are represented using hash-based embeddings. Instead of having a unique embedding for each post ID or author ID (which would be impossible to scale given the billions of posts and users), the system uses multiple hash functions to map IDs to positions in embedding lookup tables.
But where do the actual embedding values come from? This is a bit obscure, to be honest. The Phoenix model expects embeddings to already be looked up before being passed in. That means there is separate infrastructure that stores massive embedding tables and serves lookups, and we don’t really know exactly how those embeddings are computed.
User Tower
The User Tower is significantly more complex. Instead of a simple neural network, it uses the same transformer architecture that powers Grok. Yes: the system that decides which posts you see uses the same underlying technology as the model you usually see answering questions in posts.
As you can imagine, the user tower uses all the information we discussed in section 1 — that is, the user features and the sequence of recent actions. All of this information is then concatenated and projected into a unified representation for each history item:
def block_history_reduce( history_post_hashes: jnp.ndarray, history_post_embeddings: jnp.ndarray, history_author_embeddings: jnp.ndarray, history_product_surface_embeddings: jnp.ndarray, history_actions_embeddings: jnp.ndarray, num_item_hashes: int, num_author_hashes: int, embed_init_scale: float = 1.0,) -> Tuple[jax.Array, jax.Array]: """Combine history embeddings (post, author, actions, product_surface) into sequence. Args: history_post_hashes: [B, S, num_item_hashes] history_post_embeddings: [B, S, num_item_hashes, D] history_author_embeddings: [B, S, num_author_hashes, D] history_product_surface_embeddings: [B, S, D] history_actions_embeddings: [B, S, D] num_item_hashes: number of hash functions for items num_author_hashes: number of hash functions for authors emb_size: embedding dimension D embed_init_scale: initialization scale Returns: history_embeddings: [B, S, D] history_padding_mask: [B, S] """ B, S, _, D = history_post_embeddings.shape history_post_embeddings_reshaped = history_post_embeddings.reshape((B, S, num_item_hashes * D)) history_author_embeddings_reshaped = history_author_embeddings.reshape( (B, S, num_author_hashes * D) ) post_author_embedding = jnp.concatenate( [ history_post_embeddings_reshaped, history_author_embeddings_reshaped, history_actions_embeddings, history_product_surface_embeddings, ], axis=-1, ) embed_init = hk.initializers.VarianceScaling(embed_init_scale, mode="fan_out") proj_mat_3 = hk.get_parameter( "proj_mat_3", [post_author_embedding.shape[-1], D], dtype=jnp.float32, init=lambda shape, dtype: embed_init(list(reversed(shape)), dtype).T, ) history_embedding = jnp.dot(post_author_embedding.astype(proj_mat_3.dtype), proj_mat_3).astype( post_author_embedding.dtype ) history_embedding = history_embedding.reshape(B, S, D) history_padding_mask = (history_post_hashes[:, :, 0] != 0).reshape(B, S) return history_embedding, history_padding_mask
Then, the entire sequence (user embedding plus history embeddings) is passed through the Grok transformer. The output is averaged across all valid positions and normalized to produce a single user representation vector:
def build_user_representation( self, batch: RecsysBatch, recsys_embeddings: RecsysEmbeddings,) -> Tuple[jax.Array, jax.Array]: """Build user representation from user features and history. Uses the Phoenix transformer to encode user + history embeddings into a single user representation vector. Args: batch: RecsysBatch containing hashes, actions, product surfaces recsys_embeddings: RecsysEmbeddings containing pre-looked-up embeddings Returns: user_representation: L2-normalized user embedding [B, D] user_norm: Pre-normalization L2 norm [B, 1] """ config = self.config hash_config = config.hash_config history_product_surface_embeddings = self._single_hot_to_embeddings( batch.history_product_surface, # type: ignore config.product_surface_vocab_size, config.emb_size, "product_surface_embedding_table", ) history_actions_embeddings = self._get_action_embeddings(batch.history_actions) # type: ignore user_embeddings, user_padding_mask = block_user_reduce( batch.user_hashes, # type: ignore recsys_embeddings.user_embeddings, # type: ignore hash_config.num_user_hashes, config.emb_size, 1.0, ) history_embeddings, history_padding_mask = block_history_reduce( batch.history_post_hashes, # type: ignore recsys_embeddings.history_post_embeddings, # type: ignore recsys_embeddings.history_author_embeddings, # type: ignore history_product_surface_embeddings, history_actions_embeddings, hash_config.num_item_hashes, hash_config.num_author_hashes, 1.0, ) embeddings = jnp.concatenate([user_embeddings, history_embeddings], axis=1) padding_mask = jnp.concatenate([user_padding_mask, history_padding_mask], axis=1) model_output = self.model( embeddings.astype(self.fprop_dtype), padding_mask, candidate_start_offset=None, ) user_outputs = model_output.embeddings mask_float = padding_mask.astype(jnp.float32)[:, :, None] # [B, T, 1] user_embeddings_masked = user_outputs * mask_float user_embedding_sum = jnp.sum(user_embeddings_masked, axis=1) # [B, D] mask_sum = jnp.sum(mask_float, axis=1) # [B, 1] user_representation = user_embedding_sum / jnp.maximum(mask_sum, 1.0) user_norm_sq = jnp.sum(user_representation**2, axis=-1, keepdims=True) user_norm = jnp.sqrt(jnp.maximum(user_norm_sq, EPS)) user_representation = user_representation / user_norm return user_representation, user_norm
So, to summarize: the User Tower is a full transformer that reads the user’s recent engagement history and outputs a single normalized vector representing something like “what this user is interested in right now.”
Retrieval
Now the system has both towers producing normalized vectors in the same space. The Candidate Tower has pre-computed embeddings for all posts in the corpus (this surely happens offline at certain moments). The User Tower computes the representation on the fly when the user opens the “For You” section.
Retrieval is then extraordinarily simple: it just computes the dot product between the user vector and all candidate vectors, and returns the top-K highest scores. Just what you would have imagined.
3. Deciding the order of the posts
At this point, the system has candidates from two different sources: in-network posts from people you follow, and out-of-network posts from Phoenix Retrieval. Now, how does the system rank them?
Essentially, this stage of the recommendation pipeline uses the very same transformer architecture that powers the User Tower to score the different candidates. One of the most important details here is that a mask is applied so that candidates cannot attend to each other. The authors refer to this as Candidate Isolation. This is the exact diagram they published:
Keys (what we attend TO)
─────────────────────────────────────────────▶
│ User │ History (S positions) │ Candidates (C positions) │
┌────┼──────┼─────────────────────────────┼───────────────────────────────┤
│ U │ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✗ ✗ ✗ ✗ ✗ ✗ │
├────┼──────┼─────────────────────────────┼───────────────────────────────┤
│ H │ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✗ ✗ ✗ ✗ ✗ ✗ │
├────┼──────┼─────────────────────────────┼───────────────────────────────┤
│ C │ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✓ ✗ ✗ ✗ ✗ ✗ ✗ │
│ a │ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✓ ✗ ✗ ✗ ✗ ✗ │
│ n │ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✗ ✓ ✗ ✗ ✗ ✗ │
│ d │ ✓ │ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ ✗ ✗ ✗ ✓ ✗ ✗ ✗ │
└────┴──────┴─────────────────────────────┴───────────────────────────────┘
In a nutshell, each candidate attends to itself but not to other candidates. This means that post A will get the exact same score whether it is evaluated alongside post B or post Z. As a result, the score is deterministic and cacheable. This is the code that creates this:
def make_recsys_attn_mask(seq_len, candidate_start_offset, dtype=jnp.float32): # Start with causal mask causal_mask = jnp.tril(jnp.ones((1, 1, seq_len, seq_len), dtype=dtype)) # Zero out candidate-to-candidate attention (bottom-right block) attn_mask = causal_mask.at[:, :, candidate_start_offset:, candidate_start_offset:].set(0) # Add back self-attention for candidates (diagonal only) candidate_indices = jnp.arange(candidate_start_offset, seq_len) attn_mask = attn_mask.at[:, :, candidate_indices, candidate_indices].set(1) return attn_mask
Multi-Action Prediction
This part is really interesting. The ranking model doesn’t predict a single “relevance score.” Instead, it predicts probabilities for 15+ different actions you might take on each post: favorite, reply, repost, quote, click, profile_click, photo_expand, share, and follow_author. It also computes probabilities for negative actions such as not_interested, block_author, mute_author, and report.
This is fundamentally different from systems that optimize for a single metric like “click-through rate” or “dwell time.” The model is trained to understand the full spectrum of possible reactions, including negative ones.
These logits are then converted into probabilities via a sigmoid function, giving us the predicted probability of each action for each candidate.
Putting it all together: the final score
So, as you can imagine, the simplest way to merge all those 15+ probabilities together is through a simple weighted sum:
let combined_score = Self::apply(s.favorite_score, p::FAVORITE_WEIGHT) + Self::apply(s.reply_score, p::REPLY_WEIGHT) + Self::apply(s.retweet_score, p::RETWEET_WEIGHT) + Self::apply(s.photo_expand_score, p::PHOTO_EXPAND_WEIGHT) + Self::apply(s.click_score, p::CLICK_WEIGHT) + Self::apply(s.profile_click_score, p::PROFILE_CLICK_WEIGHT) + Self::apply(s.vqv_score, vqv_weight) + Self::apply(s.share_score, p::SHARE_WEIGHT) + Self::apply(s.share_via_dm_score, p::SHARE_VIA_DM_WEIGHT) + Self::apply(s.share_via_copy_link_score, p::SHARE_VIA_COPY_LINK_WEIGHT) + Self::apply(s.dwell_score, p::DWELL_WEIGHT) + Self::apply(s.quote_score, p::QUOTE_WEIGHT) + Self::apply(s.quoted_click_score, p::QUOTED_CLICK_WEIGHT) + Self::apply(s.dwell_time, p::CONT_DWELL_TIME_WEIGHT) + Self::apply(s.follow_author_score, p::FOLLOW_AUTHOR_WEIGHT) + Self::apply(s.not_interested_score, p::NOT_INTERESTED_WEIGHT) + Self::apply(s.block_author_score, p::BLOCK_AUTHOR_WEIGHT) + Self::apply(s.mute_author_score, p::MUTE_AUTHOR_WEIGHT) + Self::apply(s.report_score, p::REPORT_WEIGHT);
Here’s the interesting part: all the weights are hidden in configuration files. Without knowing them, we don’t really know how different actions are weighted. Technically, computing such a large set of probabilities allows them to experiment almost infinitely without changing anything in the architecture.
The structure also tells us something very relevant: negative signals have negative weights. If the model predicts that you are likely to block the author, that post’s score gets pushed down.
This is actually more thoughtful than pure engagement optimization. A post that makes you rage-click and then block the author might have high engagement, but the block prediction would penalize it, at least in theory.
Author Diversity
After weighted scoring, there’s one more step: author diversity. If the same author appears multiple times in your feed, their subsequent posts get attenuated (the way this is done is very elegant, by the way):
fn multiplier(&self, position: usize) -> f64 { (1.0 - self.floor) * self.decay_factor.powf(position as f64) + self.floor}
4. Filtering out some posts
Even though the system already has final scores for the posts, there is one last step before showing them: filtering out content you don’t want to see. These are just binary decisions (nothing very fancy) simply keeping or removing posts.
In the post-scoring stage (there is also a pre-scoring filtering step that removes some candidates even before ranking), the system applies several filters. These include removing posts containing muted words, posts from accounts you’ve blocked, very old posts, and similar cases. At this stage, there are essentially two additional filters: posts containing violence, gore, or policy violations (yes, even X applies these), and multiple posts from the same conversation thread.
After all of this, you end up with the final set of posts you will see.
5. Some final (non-technical) thoughts
There are a lot of interesting takeaways from studying this system in depth. First of all, it seems to be a very simple and fairly standard architecture when compared to other state-of-the-art recommendation engines that different platforms use. This is actually pretty interesting: either they are working on something fancier right now, or this semantic/sequence-based system works well enough for the moment.
Secondly, it is clear why many people criticize this system: it is designed in a way that makes creating echo chambers overwhelmingly easy. The system literally tries not to show you things that can bother you (which is clear from the fact that the probability of negative effects decreases the final weight of a post). In that sense, you will almost always end up seeing things that reinforce your beliefs.
But this leads me to my final thought, as someone who works every day on recommendation systems: a natural (and inevitable) question is whether this is a consequence created by design. In other words, is this a malevolent plan from Elon to create even louder echo chambers and keep hurting democracy? Or is it just an MVP system that happens to be very simple (from a data science point of view, it actually is) because they didn’t have much time to deeply redesign the previous Twitter algorithm?
Recommendation engines are known for one simple goal: trying to optimize something and, therefore, bring more revenue somehow. There might be caveats, but this is pretty much it. And here comes the painful and uncomfortable part: people are not willing to see what they don’t like. People don’t want their opinions to be challenged. People want to think they are right. In that sense, it is very natural that systems like this are simply trying to optimize whatever keeps you engaged. Because they want to make money. They want you to stay.
I won’t completely exonerate people like me, who are trying every day to improve these systems, from the unpredictable consequences of recommending what people want to see (because, in the end, claiming that we are “just doing our job” is just a modern and techy variation of Hannah Arendt’s banality of evil). But I also won’t say that users don’t have a considerable portion of responsibility for letting a system model their opinions. Let me put this in a more direct way:
you also have responsibility!
We can all have our own opinions. And, as you saw in this post, it is enough to manually read a few posts from someone you don’t like for the system to start showing you more of that. The system is not considering what you write, it only considers what you are doing. Not being in an echo chamber starts with our own individual decisions in the digital space.
Now let’s hope the system decides this post is relevant enough to show it to you.


