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.

REPO xai-org/x-algorithm ↗ · home-mixer · thunder · phoenix · grox · candidate-pipeline AUDITED 2026-07-08 METHOD 5 parallel code-audit passes + repo-wide signal sweep

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.”
— the question under audit
Explicit penalty · none found

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.

Emergent penalty · confirmed

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.

THUNDER · IN-NETWORK Posts from followed accounts pure recency sort — no signals PHOENIX RETRIEVAL · OON Two-tower embedding search dot-product top-K over corpus HOME MIXER Hydrate + filter no engagement gates PHOENIX RANKER 19 × P(action) per viewer × post WEIGHTED SCORE Σ wᵢ · P(actionᵢ) negatives push down SELECT OON × discount sort, take top-K
The scoring chain in 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.

WhereWhat it readsEffect 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.

Followers go inactive: engagement volume drops Fewer gradient updates to your author-ID rows in the continuously-retrained table Embedding goes cold; hash-collision neighbors overwrite it Lower predicted P(fav · reply · repost) → fewer feed slots, impressions SELF-REINFORCING LOOP mediated by the training pipeline, not by any feature or rule
No feature encodes any step of this loop — it emerges from training on engagement volume. With a still-engaged core the loop runs at low gain: update direction holds, only frequency drops. There is no threshold, so any decay is smooth and reverses if engagement returns.

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.

USER TOWER viewer ID ⊕ last 127 events (post · author · action · dwell) → Grok transformer → user vector CANDIDATE TOWER post-ID emb ⊕ author-ID emb → 2-layer MLP → post vector new post ⇒ vector ≈ author emb dot product → top-K only survivors reach the ranker EMBEDDING SPACE active-user query vectors you · before you · after drifts away — sampled as negative
Left: the two-tower retrieval model. Right: what happens to your author embedding when positive engagement pairs dry up — lower dot products, out of top-K, invisible to non-followers.

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.

Before — a marginal viewer’s recent history … 127 slots After — the departed cohort no longer reposts you into other feeds … 127 slots = an engagement with one of your posts (your author-ID enters this viewer’s sequence, matching future candidates from you)
When 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.

in-network · via Thunder, full score out-of-network · score × OonWeightFactor, gated by retrieval top-K Before active followers open the app OON spread After remaining core most reach must survive the discounted OON lane SHARES ILLUSTRATIVE — THE CODE SETS THE DISCOUNT, NOT THE MIX
Inactive followers don’t drag down delivery to active ones — they simply generate no feed requests. The cost is where the rest of your reach now has to come from.

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.