Code audit · x-algorithm · May 2026 open-source release
Do inactive followers hurt your reach on X?
What the open-sourced “For You” feed code actually does when a large share of an author’s audience goes quiet — and where the real penalty hides.
Every file:line reference below is clickable — it opens the exact source from this repository, with the cited lines highlighted.
“Many of the people who followed me left for Bluesky and are now inactive on X. Is there a penalty? My guess is there is no explicit penalty — but there is probably an emergent one.”
Nothing in the code reads follower activity
A repo-wide sweep found zero hits for follower last-active time, dormancy, engagement-per-follower rates, or any author reputation score. Follower count appears in a handful of places — none penalize feed reach, and ghost followers still count anyway.
Four mechanisms, all engagement-mediated
The system optimizes predicted engagement, learned from live engagement events. Lost engagement volume weakens the training signal that keeps an author’s ML representation warm — and when a loyal core keeps engaging, the cost concentrates on out-of-network discovery. Gradual, no cliffs, reversible.
Background
How the pipeline ranks a post
Candidates come from two lanes: Thunder serves recent posts from accounts the viewer follows (in-network), and Phoenix retrieval discovers out-of-network posts by embedding similarity. Everything is then scored by the Phoenix transformer — a Grok-based model that predicts 19 engagement probabilities per (viewer, post) pair — and combined into a single weighted score. The release states it has “eliminated every single hand-engineered feature,” and the model’s input schema bears that out.
home-mixer: PhoenixScorer → RankingScorer → (external VMRanker) → top-K selection. No filter in the chain drops a post for low engagement, low follower count, or author inactivity.The audit
Every place follower data actually goes
These are all of the follower-related signals in the shipped code, and what each one can affect. Note what is absent: no field anywhere carries follower activity, last-active timestamps, dormancy, or engagement normalized by audience size.
| Where | What it reads | Effect on your reach |
|---|---|---|
gizmoduck_hydrator.rs:104 |
Author follower count, fetched from the user service | PASS-THROUGH Feeds only the two rows below |
tweet_type_metrics_hydrator.rs:56-76 |
Follower-count buckets (0–100 … 1M+) | LOGGING ONLY Observability bitset; never read by any scorer or filter |
vm_ranker.rs:102 |
Raw author_followers_count sent to the external VMRanker service |
OPAQUE Service not in the release; a count, not an activity measure |
grox/tasks/task_filters.py:55-201 |
Follower thresholds on the account being replied to | GATES LLM REVIEW Routes replies to spam-screening or “blast radius” ranking — about reply targets, not your posts |
following_replied_users_hydrator.rs:35-40 |
The viewer’s own follower count (≥ 1,000) | UI ONLY Enables a “friends who replied” facepile |
phoenix/recsys_model.py:126-144 |
The ranking model’s full input schema | NO FOLLOWER FIELD No follower count exists anywhere in the model inputs |
| Repo-wide sweep | last-active · dormant · engagement-per-follower · tweepcred · reputation | ZERO HITS No such concept exists in this codebase |
The model’s field of view
What Phoenix sees — and what it can’t
The ranker’s entire input is identity and behavior: hashed IDs embedded in learned tables, plus the viewer’s recent action sequence. Author identity enters only as an ID embedding — which is exactly where the emergent penalty lives.
Model inputs (complete list)
- Viewer ID (2 hashes → 1M-row embedding table)
- Viewer’s last 127 engagement events: post ID ⊕ author ID ⊕ 19-action vector ⊕ surface ⊕ dwell seconds
- Candidate post ID + candidate author ID (hashed embeddings)
- Product surface; post age in 60-minute buckets
Not in the schema
- Follower or following counts
- Like / repost / reply counts on the candidate (hydrator exists but is unwired dead code)
- Post text or media content
- Account age, activity, or verification
- Any aggregate over the author’s audience
Mechanism 1 of 4 — the training core · fully bites only if engagement collapses
Author-embedding starvation under continuous training
The model is trained continuously on live engagement. Every like, reply, or repost of your posts is a training example that pulls your author-ID embedding toward regions the model’s heads decode as “high engagement probability.” When engagement volume drops, those updates arrive less often — and because accounts share a 1M-row hash table, a low-volume author’s rows are progressively overwritten by whoever else collides into the same buckets.
An important refinement: if a loyal core keeps engaging, the updates keep their positive direction — you lose only their frequency, which means slower adaptation and more collision noise, not a downward pull. The full self-reinforcing spiral below engages when engagement volume collapses toward zero. For a partial exodus with a still-active core, this is the mildest of the ML mechanisms, not the strongest.
refs phoenix/recsys_model.py:139, 303-311 · run_pipeline.py:355-360 · home-mixer/scorers/ranking_scorer.rs:146-170 · README.md:29-30
The key distinction
Rate vs. volume — why a partial exodus is different
“But I still get engagement — not all of my followers left.” That matters, because inactive followers never open the app, so they generate no impressions — and training examples are built from impressions. Ghosts contribute neither positives nor negatives; they cannot dilute anything. What a partial exodus removes is total engagement volume, and the mechanisms split cleanly by which of the two quantities they care about.
Rate — what the ranker learns
- Training pairs come from served posts: shown-and-engaged is a positive; shown-and-ignored is a negative. Never-shown is nothing.
- A loyal core that keeps engaging keeps the direction of your embedding updates positive — you lose only frequency.
- Survivorship can even raise your per-impression rate: the followers who stayed are the ones who engage.
- Cost of lower volume alone: slower adaptation, more hash-collision noise — not a spiral.
Volume — what discovery needs
- Retrieval training works on absolute counts: each engagement pulls your embedding toward active users.
- Negative sampling never slows down — half the engagement means half the pull against the same push.
- History seeding scales the same way: half the volume, half the ripples into other viewers’ sequences.
- So mechanisms 2 and 3 bite even at a constant engagement rate; mechanism 1 mostly bites when volume collapses.
Mechanism 2 of 4 — the discovery channel · the biggest cost when a loyal core remains
Retrieval two-tower demotion
For out-of-network discovery, a post’s entire retrieval representation is MLP(post_emb ⊕ author_emb). A brand-new post has a cold post-ID embedding — so the author embedding is essentially the whole prior. Contrastive training pulls that embedding toward active users’ query vectors only when real engagement pairs occur; meanwhile your posts keep getting sampled as negatives. Without engagement volume, your vector drifts away from every active-user cluster and your posts fall out of top-K — they never even reach the ranker for non-followers. This channel is volume-sensitive: it degrades even if your remaining followers engage at the same rate.
refs phoenix/recsys_retrieval_model.py:316-324, 381-386 · run_pipeline.py:248-275 (contrastive-loss fingerprints in the checkpoint)
Mechanism 3 of 4 — the personalization channel
Vanishing from viewers’ history sequences
The only personalization input Phoenix has is each viewer’s own 127-event action sequence, which carries the author IDs of posts they engaged with. Your departed followers’ likes and reposts used to spread your posts into other people’s feeds — and thus into their histories. Without that seeding, your candidate posts match nothing in a marginal viewer’s sequence, and lose the strongest identity-match signal the transformer has. This channel scales with volume too: half the engagement means half the ripples, no matter how loyal the remaining rate.
candidate_author_hashes matches nothing in the viewer’s history, the transformer’s attention has no identity anchor for you — scores drop among exactly the marginal viewers who drive growth.refs phoenix/recsys_model.py:135-136 · grok.py:39-71 · home-mixer/query_hydrators/scoring_sequence_query_hydrator.rs:44-45
Mechanism 4 of 4 — the structural shift
From the in-network lane to the discounted lane
Thunder — the in-network path — is completely penalty-free: a pure recency sort over each viewer’s own following list, with no edge weights and no engagement signals. Your remaining active followers’ odds of seeing you are mathematically unchanged by the inactive majority. But everything beyond them must travel the out-of-network lane, where scores are multiplied by an explicit discount (OonWeightFactor) and must first survive Mechanism 2’s retrieval top-K. Losing active followers structurally shifts your impression mix into the harder lane.
refs thunder/thunder_service.rs:334-339 · home-mixer/scorers/ranking_scorer.rs:220-239, 272-274 · candidate_hydrators/in_network_candidate_hydrator.rs:33
Mitigations
What does not happen
- No cliffs or thresholds. Because no feature encodes follower activity, there is nothing to trip. The entire effect is gradual embedding drift — and it reverses if engagement returns.
- Your loyal audience is insulated. Thunder serves them by recency regardless of anything; and since scoring is per-(viewer, post), a follower whose own history is full of your posts keeps scoring you highly.
- No social-proof snowball feature. The engagement-counts hydrator exists but is unwired dead code in this release; raw like/repost counts are not a model input. The mutual-follow Jaccard signal is computed but consumed only by a metrics histogram.
- Ghost followers aren’t dead weight. There is no engagement-rate-per-follower feature they could dilute. An account with 5,000 active followers and 95,000 ghosts ranks essentially like one with just the 5,000. Better still: ghosts produce no impressions, so they add no negative training examples either — and with a loyal core remaining, your measured per-impression engagement rate can even improve.
The upshot
The penalty attaches to lost engagement volume, not to the ghosts themselves. Inactive followers generate no impressions, so the algorithm cannot even see them — they dilute nothing and add no negative signal. If a loyal core still engages, your in-network delivery and the ranker’s impression of you stay essentially intact; what thins is the absolute engagement volume that powers discovery. The cost therefore concentrates on growth — out-of-network retrieval and reaching new viewers — scales smoothly with how much volume was lost, and reverses if engagement returns. The original guess was right on both counts: no explicit penalty, and a real emergent one.
Caveats
This is a curated open-source release. The feature-builder that assembles the actual Phoenix request (phoenix_request.rs), the VMRanker service internals, the Grok prompt templates, and several thresholds (redacted to "" in source) are not shipped. “No explicit penalty” is airtight for the code in this repo; it cannot be extended categorically to X’s full production system.