X's Phoenix Ranker: A Transformer That Cannot See Its Own Batch
When X open-sourced the-algorithm in 2023, the interesting part was the plumbing: a Scala service graph, a light ranker, a heavy ranker, and a set of hand-tuned engagement weights. The model at the centre was modest enough to describe in a paragraph. The plumbing was the system.
xai-org/x-algorithm inverts that. The plumbing is still there — home-mixer/ is a clean Rust candidate pipeline, and it is worth reading — but it is now scaffolding around a single model called Phoenix, and on 13 August 2026 the repository stopped shipping a demonstration version of that model and started shipping the real one. The phoenix/README.md says it plainly: earlier releases contained a sample transformer ported from the Grok-1 release; this one contains "the real model code, the real training step, and the real Rust serving engine, exported from the internal tree."
I read phoenix/ end to end as a study exercise, because a production recommender at this scale is rarely legible from the outside and almost never legible at the kernel level. This is the summary I wish I had started with: what the model actually is, which handful of design decisions do the real work, and which widely-repeated claims about "the algorithm" the code does not support. Everything below is from the tree at main as of mid-August 2026, Apache 2.0.
TL;DR
- Phoenix is one transformer trunk with two heads. Retrieval (a two-tower model) and ranking (a scorer) share the same 8-layer trunk, the same hashed-embedding machinery and the same feature-prep stage. They differ in what comes out, not in what goes in.
- The model is about 99.9% lookup table. The transformer trunk is roughly 0.34 B parameters. The embedding table it reads from is 240,000,512 rows × 1024 — about 246 B parameters, some 700× larger than the network on top of it.
- Candidate isolation is a kernel bound, not a mask. Candidates cannot attend to each other, and the mask implementing that is never materialised: it is four integers passed into a FlashAttention kernel that simply never visits the candidate×candidate blocks. The rule buys batch-invariance and turns the attention cost from quadratic in the whole sequence into linear in the candidate count.
- Every candidate gets the same position. Right-anchored RoPE puts the newest history event at a fixed index whatever the history length, and pins every candidate to one index after it. Permutation invariance over candidates is exact, not approximate.
- Posts have two identities. A hashed post ID (two affine hashes, no vocabulary to maintain) and a semantic ID — a 6-level × 256-way residual-quantised code over the post's multimodal embedding. Same-topic posts share SID prefixes, which is how a post nobody has engaged with yet is representable at all.
- 64 sigmoid heads, and one of them is not a user action.
post_unexploredis a synthetic label computed from follower count and view velocity. The exploration policy is trained into the model as a prediction, then added to the score with a weight of0.02. - The weights everyone quotes are stage two of five. Weighted sum, then a sign split, then author decay and an out-of-network discount and a one-post cold-start bandit, then a determinantal point process. And the weights multiply predicted probabilities, never counts.
- What is missing is the interesting part. No checkpoint, no data, no production optimiser, no Grox prompts, some Botmaker rules withheld. You can run the mechanics end to end on synthetic data; you cannot reproduce the model.
Two models, one trunk
The two-stage shape is conventional and worth stating so the rest has a frame. Retrieval narrows millions of posts down to a shortlist by scoring a user vector against a precomputed index. Ranking scores those survivors with something far more expressive. What is unconventional is how much the two share.
The retrieval user tower "uses the same transformer trunk and input machinery as the ranking model", per the README, and the code bears that out: xrex/models/recsys_two_tower_model.py and xrex/models/recsys_model.py both build the same TransformerConfig over the same RecsysAttentionConfig, and both consume the same feature-prep stage in xrex/models/recsys_feature_prep.py. The retrieval model differs in three ways — its candidate side is a separate tower, its identity for a candidate is the semantic ID rather than the hashed post ID, and its loss is contrastive rather than multi-label.
Production geometry, from xrex/configs/xrecsys.py:
Ranking (home_direct_packed) |
Retrieval (two-tower) | |
|---|---|---|
| Transformer layers | 8 | 8 |
| Trunk width | 2560 | 1024 |
| Query / KV heads (GQA) | 20 / 4 | 16 / 4 |
| Attention key size | 128 | 128 |
| FFN | widening 2, not gated | widening 2, not gated |
| History length | 1022 | 1023 |
| Candidates per pass | 64 | — |
| Embedding-table width | 1024 | 1024 |
| Hashes per entity | 2 | 2 |
| Semantic IDs | 6 × 256, input feature | 6 × 256, candidate identity |
| Output | 64 sigmoid + 8 continuous heads | normalised vector, dot product |
Eight layers. Not eighty. That is the first thing that surprises anyone arriving from language modelling, and the next section explains why it is enough.
The model is mostly a lookup table
_make_cfg() in xrex/configs/xrecsys.py builds one flat embedding table by summing the vocabularies:
input_vocab_size = (
user_vocab_size # 100_000_000
+ item_vocab_size # 100_000_000
+ author_vocab_size # 30_000_000
+ ip_vocab_size # 10_000_000
+ ACTION_TYPE_MAP_LEN
+ 1
)
input_vocab_size = _round_up_to_multiple(input_vocab_size, INPUT_VOCAB_K)
That is 240,000,512 rows after rounding, and make_embedding_table() in recsys_model.py allocates it as one array of (input_vocab_size, emb_table_width) with emb_table_width = 1024. Multiply it out: 245.8 billion parameters, roughly 492 GB in bf16.
Now price the transformer. Eight layers at width 2560, GQA with 20 query heads and 4 KV heads at key size 128, and a plain (non-gated) FFN at widening factor 2. Per layer that is 15.7 M in attention projections and 26.2 M in the FFN — about 42 M — so 336 M for the stack, plus the feature-prep projections, the SID codebooks and a 2560 × 64 unembedding. Call it 0.34 B.
The ratio is about 700 to 1. Almost everything Phoenix knows is stored as a row in a table; the transformer is the small, cheap thing that composes those rows. This is the structural fact that separates industrial recommenders from language models, and it drives every other decision in the tree — including how the model is sharded. The table's partition spec is P(None, "expert"), meaning it is cut along its width, not its rows, across the embedding-parallel mesh axis. With ep = 256 on H100 each device holds a 4-column slice of all 240 M rows, about 1.9 GB; the GB300 config uses ep = 64 and about 7.7 GB per device.
Lookups are asynchronous by design. xrex/utils/recsys_async_emb_lookup.py exposes lookup_start and lookup_done as separate JAX operations so the all-to-all that gathers a batch's rows overlaps with compute instead of blocking it. When your parameters outweigh your FLOPs by three orders of magnitude, hiding the gather is the optimisation.
Everything becomes a token
Feature prep is where a post stops being a database row and becomes a vector. The rule is uniform and worth internalising because it is simpler than most published recsys architectures: project, then sum. Every feature gets its own learned projection into the trunk width, and the projections are added together into one token. There is no concatenation, no field-aware factorisation, no explicit crossing layer. From _build_user_features_token:
result = jnp.zeros((B, D), dtype=fprop_dtype)
if config.enable_user_country and user_cat is not None:
codes = _cast_jax(user_cat)[:, UserCategoricalFeature.userCountryCode]
result = result + _embed_categorical(
codes, config.num_countries, "user_feat_country_emb", config
).astype(fprop_dtype)
if config.enable_user_language and user_cat is not None:
...
Feature interaction is left entirely to attention. That is a real bet — the DLRM lineage spends most of its architecture budget on explicit feature crosses — and it is the bet that lets retrieval and ranking share a trunk at all.
What actually goes into each token, from the production FeaturePrepConfig:
- User prefix (2 tokens). Hashed user ID, hashed IP address, then country, language, latitude/longitude through a Fourier-feature location embedding, gender, age bracket and a multi-hot of installed apps. (US state and DMA code have projections in the code but are off in this config.)
- History positions (1022). Hashed post ID, hashed author ID, semantic ID, the action taken, dwell time, and context: timezone, local hour of day (dithered by 10% during training), product surface, post age bucketed to the hour.
- Candidate positions (64). The same, minus the action and dwell — those are the targets.
Two details in that list are worth pausing on. The IP address is a 10 M-row hashed embedding, which is a real feature carrying real signal about coarse network locality. And hour_of_day_dither_fraction = 0.1 is a small, honest piece of regularisation: jitter the hour so the model learns a smooth diurnal pattern rather than memorising clock ticks.
Hashing instead of a vocabulary
Every ID lookup runs through _hash_ids_batch in xrex/models/recsys_embedding.py, which is a textbook universal hash:
raw = (ids[i] * scales[j] + biases[j]) % modulus
out[i, j] = 0 if ids[i] == 0 else ((raw % (num_buckets - 1)) + 1)
Two hashes per entity with independent scale/bias pairs, bucket 0 reserved for padding, and each entity type offset into its own region of the shared table (offset_user = 1 + 64, then item, author, IP — the first 65 rows are padding plus the action taxonomy). The two lookups are projected and summed, so a collision in one hash is usually rescued by the other.
The consequence is the one the README highlights: there is no vocabulary service, no ID-to-index map to keep warm, no cold-start gap between a post being created and the model being able to address it. A post ID minted a millisecond ago hashes to two rows immediately. Those rows have not learned anything about this post yet — which is the entire reason the next section exists.
Semantic IDs: the content half
A hashed post ID is a pure identity: it tells the model nothing until the post accumulates gradient. For a feed where most candidates are hours old, that is a serious hole. Phoenix fills it with semantic IDs.
A SID is a residual-quantised code over a post's multimodal embedding: 6 levels, 256 centroids per level. reference/sid_codebook.py trains the codebooks with iterative k-means on residuals — cluster the vectors, subtract the assigned centroid, cluster what is left, repeat six times. The result is a 6-token code where the first token is the coarsest content bucket and each subsequent token refines it.
That prefix structure is the payoff. Two posts about the same subject share leading codes, so their SID embeddings overlap even when their hashed IDs are unrelated. The model gets compositional generalisation over content for free, and a brand-new post inherits whatever the model has learned about its neighbourhood the moment its embedding is quantised.
The two identities are used asymmetrically, which I found the most interesting design choice in the retrieval model. In ranking, SIDs are one input feature alongside hashed IDs. In retrieval, the README states that since the SID migration candidates are represented by their semantic IDs plus hashed author IDs — "rather than by hashed post IDs alone." A retrieval index keyed on content codes generalises to posts it has never indexed; one keyed on ID hashes does not.
Worth noting for accuracy: the production ranking config sets multimodal_embedding_type: None. The dense multimodal vector is not an input to the shipped ranker; only its quantised codes are. The raw embedding is enabled only on the xrecsys_seqpack training config. Content reaches the ranker as 6 discrete tokens, not as a 1024-dimensional vector.
Candidate isolation is a kernel bound, not a mask
Here is the design decision the README leads with, and the one whose implementation is more interesting than its description.
The stated rule: during inference, candidates may attend to the user prefix and the history, but not to each other. The stated benefit: a candidate's score does not depend on which other candidates share its batch, which makes scores consistent and cacheable.
The README draws this as a mask matrix. The code never builds one. xrex/pallas/ranker_attention_utils.py computes the mask inside the attention kernel, per tile, from four scalars:
q_is_history = (q_pos >= history_lower_bound) & (q_pos < history_upper_bound)
q_is_candidate = (q_pos >= candidate_lower_bound) & (q_pos < candidate_upper_bound)
kv_is_history = (kv_pos >= history_lower_bound) & (kv_pos < history_upper_bound)
kv_is_candidate = (kv_pos >= candidate_lower_bound) & (kv_pos < candidate_upper_bound)
history_mask = kv_is_history & (q_is_history | q_is_candidate)
candidate_self_mask = q_is_candidate & kv_is_candidate & (q_pos == kv_pos)
return history_mask | candidate_self_mask
Those bounds arrive as a 4-tuple — (0, history_upper, candidate_lower, candidate_upper) — assembled in xrex/models/recsys_attention.py and handed to the kernel. And the kernel's KV loop uses them to skip work, not merely to zero it:
hist_k_start = lax.div(history_lower_bound, block_kv)
hist_k_end = pl.cdiv(history_upper_bound, block_kv)
cand_start = jnp.maximum(candidate_lower_bound, q_tile_base)
cand_end = jnp.minimum(candidate_upper_bound, q_tile_end)
A query tile visits every history KV block plus only the candidate blocks that intersect its own span. The candidate×candidate region is never loaded, never multiplied, never softmaxed. On Blackwell the same structure is expressed even more explicitly: build_dense_block_sparse_layout in xrex/cutedsl/ranker_attention_fa4.py precomputes a block-sparse layout where every query block gets all h_blocks history blocks as "full", and candidate blocks additionally get exactly one diagonal block.
So candidate isolation is not a modelling constraint the system pays for — it is a modelling constraint that pays. Attention over 1022 history positions and 64 candidates costs on the order of (S + C) × S instead of (S + C)², which is why you can raise the candidate count without the attention bill compounding. It is unusual and pleasing to find a fairness-and-consistency property and a performance property served by the same four integers.
One more thing the mask makes explicit: causal=False. This is a bidirectional encoder over the user's action sequence, not a decoder. History position 400 attends to position 900. There is no next-token objective anywhere in the ranking model.
Right-anchored RoPE
Candidate isolation removes candidate-to-candidate information flow through attention. Positional encoding could smuggle it back in — if candidate 0 and candidate 7 held different RoPE positions, their scores would differ by slate order. right_anchored_rope_positions() in recsys_model.py closes that:
positions = jnp.where(
(history_start <= idx) & (idx < history_end),
history_end - history_len[:, None] + idx - history_start,
idx,
)
positions = jnp.where(idx >= history_end, history_end, positions)
Two properties, both load-bearing. History is right-anchored: the most recent event always lands at history_end - 1 regardless of whether the user has 40 events or 1022, so "how long ago" is encoded consistently instead of being shifted by history length. And every candidate is clamped to exactly history_end — one shared position for all 64. Combined with the mask, permutation invariance over the candidate set is exact.
There is a matching structural constraint in _validate_params: user prefix plus history must be a power of two. With two user tokens (one user-ID token, one user-features token) and 1022 history positions, that is 1024, and the candidates start at a tile boundary the attention kernels can bound cheaply. The geometry is chosen for the kernel.
64 heads, and one of them is not an action
The ranking model's output is [B, num_candidates, num_actions] plus regression heads. OUTPUT_VOCAB_K = 64 sets the discrete taxonomy to 64 slots (60 defined actions rounded up), with 8 continuous slots for dwell-style values.
The loss is multi-label, not multi-class — optax.sigmoid_binary_cross_entropy per head in xrex/models/loss_recsys.py, masked and normalised by valid tokens. A post can be favourited and replied to and reported; nothing forces the head probabilities to sum to anything. Continuous heads get their own losses, including a Tweedie option with p = 1.5, which is the right family for a zero-inflated positive quantity like dwell time and a nice sign that someone thought about the distribution rather than defaulting to MSE.
The taxonomy in xrex/data/recsys/constants.py is broader than the README's summary suggests. Beyond the obvious engagements there is ClientTweetTakeScreenshot, ClientTweetTranslateClick, ClientTweetClickGrokAnalyze, ClientTweetUndoSeeFewer, and an entire ladder of external-link dwell buckets from LessThan3Sec through MoreThan60Sec. Ads conversions and notification events share the same head space under different metric groups.
And then there is ServerTweetPostUnexplored, which is not a user action at all. compute_post_unexplored_labels synthesises it:
exploration_view_target = (
EXPLORATION_REACH_TARGET_FRACTION # 0.03
* follower_counts
* (1.0 - np.exp(-decay * clamped_age))
/ saturation
)
under_explored = view_counts < exploration_view_target
return is_original & in_exploration_window & under_explored
A post is labelled "unexplored" if it is original, under 24 hours old (EXPLORATION_WINDOW_HOURS), and has fewer views than a target that ramps toward 3% of the author's follower count on an 8-hour half-life. The model is trained to predict this, and RankingScorer then adds P(unexplored) to the score with weight 0.02, in-network only by default.
That is a genuinely elegant move. Exploration is usually bolted on as an ε-greedy hack or a separate bandit layer. Here the exploration policy is expressed as a label, learned by the same network as everything else, and combined by the same linear rule. Whether it is well calibrated is a different question the code cannot answer — but the mechanism is clean.
Retrieval: the same trunk, a different head
The two-tower model earns a short section because two of its choices are unusual.
First, production retrieval carries no learned per-user ID embedding (use_user_embedding=False). Beyond a handful of coarse profile features on the user-features token, a viewer is nothing but the sequence of things they interacted with. That is a strong statement about where the signal lives, and it means a user with no history is genuinely a cold user rather than a stale row.
Second, the candidate index lives inside the checkpoint. At every save, the trainer runs the candidate tower over the configured corpus (max_posts = 10.24 M, 28.67 M on the combined config) and stores the resulting post_embeddings in the checkpoint itself. Serving loads it from there; nothing embeds a corpus at boot. It is an unfashionable choice — no vector database, no separate index build, no index/model skew — and it makes "which index was this model serving?" unanswerable in the wrong direction, which is usually what you want.
Training is contrastive with in-batch negatives plus 64 sampled global negatives per example, favourites as the positive signal, and a log-Q correction to undo the popularity bias that in-batch sampling introduces:
logq_correction = jnp.log(sampling_weight)
batch_correction = logq_correction[:, :C] * logq_correction_scale
global_correction = logq_correction[:, C:] * logq_correction_scale
Standard, and correctly done — an uncorrected in-batch softmax systematically under-retrieves popular items, and the fix is cheap.
From 64 probabilities to one position
Everything above produces probabilities. Turning them into a feed happens in Rust, in home-mixer/, and this is the half of the system that draws public attention.
compute_weighted_parts in home-mixer/scorers/ranking_scorer.rs is a flat array of probability × weight terms, split into a positive and a negative sum. Then offset_score does something I have not seen elsewhere:
pub(crate) fn offset_score(combined_score: f64, w: &ScoringWeights) -> f64 {
if w.total_sum == 0.0 {
combined_score.max(0.0)
} else if combined_score < 0.0 {
(combined_score + w.negative_sum) / w.total_sum * NEGATIVE_SCORES_OFFSET
} else {
combined_score + NEGATIVE_SCORES_OFFSET
}
}
NEGATIVE_SCORES_OFFSET is 0.001. Positive totals are shifted above it; negative totals are compressed into the band below it, preserving their order among themselves. A post whose predicted negatives outweigh its positives can never outrank a post whose do not, no matter how large the margins are. It is a monotone remap that makes the sign of the total a hard partition rather than a matter of degree.
Three slate-level adjustments follow, and they are per-request, not per-post:
- Author diversity.
(1 - floor) * decay^k + floorwithdecay = 0.5andfloor = 0.25, wherekis how many higher-scoring posts by the same author already appear in the pool. Second post ×0.625, third ×0.4375, asymptotically ×0.25. - Out-of-network discount. ×0.75 for posts from accounts the viewer does not follow — and, when enabled, for replies and reposts from accounts the viewer does follow. Topic requests use ×0.5.
- Author cold start.
author_cold_start.rspicks exactly one eligible post per request — original, low-impression (under 1000 views by default), inside the top slots — and raises its score to a target position. Optionally the pick is a Thompson sample from a Beta posterior rather than the argmax. A bandit, one arm pulled per timeline refresh.
Then VMRanker calls out to vm-ranker/, which runs a greedy MAP determinantal point process over the top 150 by score. dpp.rs builds the kernel L_ij = q_i q_j cos(e_i, e_j) with q_i = exp(α · normalised_score) and α = θ / (2(1-θ)), θ = 0.65 — the standard Chen et al. formulation — and selects greedily using an incremental Cholesky, taking at each step the item with the largest residual volume.
One detail that the README's phrasing ("reorders them") undersells: after selection, dpp_selected is re-sorted by the original score. The DPP is a subset selector. It decides which posts survive from a near-duplicate-heavy pool; it does not shuffle the survivors. Score order inside the kept set is preserved exactly.
The weights, read correctly
These are the numbers that get screenshotted, so here they are with the caveat the code itself now carries in a 30-line comment block:
| Action | Weight | Action | Weight | |
|---|---|---|---|---|
| Reply, mutual follow, original post | +20.0 | Report | −234.0 | |
| Share via copy link | +20.0 | Mute author | −58.8 | |
| Reply | +5.0 | Not interested | −43.2 | |
| Share via DM | +5.0 | Block author | −31.2 | |
| Quote | +5.0 | Not dwelled | −0.02 | |
| Follow author | +4.0 | |||
| Share | +2.0 | Dwell time (continuous) | +0.004 | |
| Repost | +1.0 | Post unexplored | +0.02 | |
| Favourite | +0.5 | Photo expand / video open / VQV | +0.05 | |
| Click | +0.4 | Open link | +0.2 |
(That first row is ReplyWeight 5.0 plus BidirectionalFollowReplyWeightBoost 15.0. reply_weight_for() adds the boost only when the candidate is an original post — not a reply, not a repost — from an author who follows the viewer back.)
The ratio between report and favourite is 468, and the repository added a comment specifically because people read that as "one report cancels 468 likes." It does not, for a reason that is structural rather than rhetorical: these multiply predicted probabilities, not observed counts. The term is weight × P(you report this), and X's own note says the base rate of a report is more than 1000× lower than a favourite — so the large coefficient exists to bring a tiny probability onto a comparable scale, not to let a handful of reports dominate.
The second-order consequence follows from the same fact and is the more useful one to understand: because P(report) is predicted for the viewer, brigading does not propagate the way people assume. Reports from a coordinated cluster mostly move predictions for viewers who resemble that cluster. And the comment adds a mechanical constraint — engagement only counts toward ranking if it happened on a post served in the Home timeline, so navigating to a post directly from a group chat contributes nothing.
I find this a fair reading of the code. It is also worth noting what it does not claim: nothing here says the weights are right, only that a common arithmetic interpretation of them is wrong. And param.rs defaults are synced from production by cron, which means they are a snapshot of the primary value, not a guarantee about what any given request ran — EnableMpnScoring defaults to false, ValueModelMode defaults to "weighted", and there is a whole alternative "dwell regret" value model behind a per-user logistic gate whose 19 coefficients are checked in as a config string. That gate decides which scoring function a given viewer gets. It is off by default in the repo. Whether it is off in production is not something the repository can tell you.
One change, followed end to end
The single most persuasive file in the repository is not code. docs/BIDIRECTIONAL_BOOST_CHANGE.md traces one widely-discussed timeline change through its actual diffs, and it is worth reading as a template for what "you can audit this" would have to mean.
The change is the mutual-follow boost. On 10 July 2026 an A/B test assigned a small share of users a boost value of 5, 10, 15 or 20, with most users at 0. On 13 July, after good early results, a value of 20 went out broadly while other arms kept running. On 24 July it was reduced to 15 — and the stated reason is specific enough to be checkable: the World Cup was on, and people were reporting that they were not seeing enough discussion of it because much of that discussion came from accounts they did not follow.
The mechanism reads exactly as you would hope. A new BidirectionalFollowHydrator asks the social graph which of the candidate authors the viewer follows also follow the viewer back, and tags the candidate. ScoringWeights gains one field. reply_weight_for() adds the boost when the candidate is an original post from a mutual. Two tests assert that replies and reposts from mutuals do not get it and that a boost of 0.0 is a no-op. Then the 24 July diff is a single-line parameter change from 20.0 to 15.0.
Three things stand out. The lever is a weight on a predicted probability — X boosted mutual-follow posts by raising how much it cares about your predicted likelihood of replying to them, not by adding a flat multiplier. The tuning was reactive to public complaint on a two-week timescale. And the whole thing is one number in param.rs, which is precisely why the cron that syncs those defaults from production is the most load-bearing transparency mechanism in the repository — more so than the model code, which changes far more slowly than the values do.
What training actually looks like
Some scattered facts from TRAINING.md and the configs that fill in the picture:
Scale. total_samples: 1e11. One hundred billion training samples on the schedule.
Optimisers, split by parameter type. Dense parameters use AdamW; embedding rows use a separate sparse row-wise AdaGrad, applied only to the rows a batch touched. Each step deduplicates the rows used, computes gradients for both, applies the two optimisers, and skips the update if any gradient is non-finite. This split is standard for large recsys and mandatory at 246 B parameters — you cannot keep Adam moment estimates for a table that size.
One disclosed substitution. TRAINING.md states that the shipped dense optimiser is standard Optax AdamW, while "the internal deployment uses a tuned RMS-normalized-Adam derivative in that optimizer slot." That is an unusually specific admission, and I would rather have it than not.
μP. emb_size = 512 is the base width, where the width-dependent learning-rate and initialisation multipliers are exactly 1. The nano configs sit at that width, which is what makes them meaningful as a scaled-down twin rather than a toy.
Sequence packing. Multiple variable-length user sessions are packed into one row and trained with a variable-length attention kernel, with lengths drawn from BetaLengthDistribution(min_len=126, max_len=1022, mean_len=510, block_size=128). A throughput mechanism only — the serving contract is unchanged — but it is why the varlen kernels exist alongside the dense ones.
Kernels, plural. The tree ships four ranker attention paths: Pallas FA3 for H100, Pallas v2 for everything else, a Pallas varlen kernel for packed training, and CuTeDSL FA4 for GB200/GB300 (itself in dense and varlen flavours). The Blackwell path requires qk_norm=True and forbids logit softcapping, which is exactly what the production ranking config sets (qk_norm: True, attn_logit_cap: -1). Model configuration and kernel constraints are not independent here.
What is not in the box
Being precise about this matters more than the architecture summary, because "X open-sourced its algorithm" is doing heavy lifting in most coverage.
You cannot reproduce the model. There is no checkpoint and no data. What ships instead is reference/world_snapshots.py and reference/dump_gen.py, which generate a deterministic synthetic world, and reference/train_synth.py, which trains either model on it. TRAINING.md is admirably blunt: the synthetic data "is intended to verify mechanics, not model quality," and a rehearsal showing decreasing loss "is only a check that training runs. It is not a quality or convergence claim."
You can, however, run the real mechanics. The nano ranking config is geometry-identical to production, keeps its losses, checkpoint format and serving contract, and trains in minutes on one GPU. The gRPC path is the real Rust engine. Between "here is a diagram" and "here is the system" this sits much closer to the latter than any prior release of its kind — the retrieve-then-rank loop in reference/retrieve_then_rank.py composes the same two services production composes.
Some content-understanding code is deliberately withheld. The Grox LLM prompts (the .j2 files) and some Botmaker rules are not published, with gaming as the stated reason. X's counter-offer is the Under the Hood tool, which shows you the visibility labels on your own account. Code plus outputs instead of code alone — a reasonable trade to propose, and one you can only evaluate by using it.
Defaults are not deployments. The cron that syncs param.rs to primary production values is a real transparency mechanism and also a limited one. Experiments run at under the ~10% threshold X commits to publishing are invisible, and a parameter's default in the repo tells you nothing about which experiment arm a given request landed in.
Ranking and visibility remain separate systems. visibility-filtering/ decides whether a post may be shown at all, and it runs after selection, and it can only drop or interstitial — never promote. That separation is architecturally clean, and it also means no amount of reading phoenix/ tells you why a specific post did not appear. That answer lives in rules/registry.rs and the labels, not in the model.
What scales down
Most of this is machinery for a problem almost nobody has. But four ideas cost close to nothing and are worth stealing at any scale.
Make your scoring constraint pay for itself. Candidate isolation was chosen for score consistency and cacheability. Because the constraint is structural, it collapses into four integers a kernel can use to skip work, and the same decision that makes scores reproducible also makes attention cheaper. When a constraint is expressible as a bound rather than a mask, it usually is.
Give entities two identities: one for memory, one for meaning. A hashed ID that can be addressed instantly but knows nothing, and a quantised content code that generalises from day one. Neither alone handles both a five-year-old account and a five-second-old post; together they do, and the model just sums them.
Turn policy into a label. The exploration rule could have been a multiplier applied after scoring. Making it post_unexplored — a synthetic target the model learns to predict — means it is calibrated against real outcomes and combined by the same linear rule as everything else, instead of being a knob nobody dares touch.
Separate what you predict from what you value. Phoenix emits 64 probabilities and has no opinion about which are good. All the judgement lives in 30 lines of Rust constants that anyone can read, diff and argue about. That separation is why the weight table is quotable at all — and a system whose values are inspectable and whose predictions are not is a much better transparency story than the reverse.
The last one is the real lesson. X could not have published a legible weight table if the model produced a single relevance score, because there would have been nothing to publish. Multi-action prediction was an architectural decision made for accuracy, and it is what made this release explicable years later. Designing for a separation between prediction and value is usually sold as a modelling convenience. It turns out to also be what makes a system possible to argue with.