0x00 한

Research / Service canon

X (Twitter)

Summary

As of the 2026-10-20 commit (74d49a4, Gen4+45), X For You gathers the latest in-network posts from Thunder and OON candidates from Phoenix retrieval, SimClusters, and a popular posts source that has been on by default since 2026-10-07, while the legacy tweet-mixer and SID retrieval sources are off by default. Phoenix, a Grok-based Transformer, predicts per-action engagement probabilities for each candidate, and since 2026-09-24 the shared crate xai-value-model applies an author-diversity decay and an OON multiplier to the weighted sum of these probabilities. There are 20 filters before scoring and 5 after selection, and the sorted candidates then pass through VMRanker DPP slate re-ranking (θ=0.65, K=150, skipped for topic requests) and blending of ads and follow recommendations.

Request — get_for_you_feed / _urt If recommendation is refused, demote to in_network_only · Following is a flag of the same pipeline 11 candidate sources — parallel (join_all) · failed sources silently vanish (fail-open) Thunder in-network In-memory · 2-day retention 3 Phoenix sources retrieval · MoE · topics (two-tower) SimClusters (revived 2026-08) OON · popular list on (10-07) · tweet-mixer off cached · scored posts Cache reuse ads·WTF·prompts·push Non-organic sources · video carousel (off) Cache gate Enough cache → skip sources 18 filters — before scoring, fixed order (5 new in 2026-08, incl. Brazil2026) VF (safety) filter is fail-closed — no verdict, removed Phoenix — Grok-1 ported Transformer ranker 24 prediction heads (Engagement 7 / Clicks 6 / Attention 5 / Author 1 / Negative 5) ranking_scorer — Σ w·P(head) weighted sum Decay (decay 0.5·floor 0.25) · OON ×0.75 · new-author boost +0.02 (in-network only) · mutual-follow original reply +15 Weights published (2026-08-13): reply 5.0 · favorite 0.5 · retweet 1.0 · share copy 20.0 · report −234 VMRanker — external DPP slate service (gRPC · on by default · θ=0.65 · K=150) Overwrites scores entirely · skipped silently on failure (fail-open · VMRankerEnableFallback=false) top-K selection → 3 post-selection filters → blender → feed Ad insertion: organic posts ≥5 · spacing {request 3, min 2} · up to half the safety gap Blender overrides framework ordering and inserts by position Grox — nearline annotation Kafka-based · off request path slop score · PTOS safety · topic Multimodal embedding Results go to filters/ad adjacency: Posts without a safety score MediumRisk (fail-closed) Ranking Transformer input is still ID embeddings only
X For You pipeline — canon summary for the 2026 Rust generation

↑ Contents

Released source up to Gen4 — read this first to keep the rest straight

X's recommendation code release is divided into four generations. Gen1 is Scala/Java and Gen2 is Scala, both from the Twitter repo, while Gen3 and Gen4 are Rust/Python code from the xAI repo. From Gen4 on, the weight parameter mirror, the safety pipeline, and the training code were also released verified.

Generation Repo Released Language Status
Gen1 twitter/the-algorithm @ ef4c5eb…72eda9a 2023-03-31 ~ 2023-07-07 Scala/Java 99% of the "Twitter algorithm" analyses circulating in the world look at this generation
Gen2 same repo @ c54bec0 2025-09-03 Scala 988 files, +65,319/−3,195. Introduced tweet-mixer; Grok and Phoenix entered
Gen3 xai-org/x-algorithm @ aaa167b·e414c17·0bfc279 2026-01-20·2026-05-15 Rust/Python Complete rewrite. Zero code duplication with Gen1 and Gen2. 216 files in total (79 initially, 137 added and 50 modified in e414c17 (including 59 grox), and 1 LFS pointer replaced in 0bfc279)
Gen4 same repo @ 47c1bcd (.gitattributes cleanup in a389166) 2026-08-13 Rust/Python +363,246/−11,640 (300+ files). Introduced the weight params mirror and 15 visibility-filtering and labeling sub-repos, included phoenix/ training code and synthetic data, revived SimClusters, and began the practice of documenting parameter diffs in docs/BIDIRECTIONAL_BOOST_CHANGE.md

A direct check of the git log on 2026-09-10 showed that xai-org/x-algorithm had 18 commits (+1 from the previous day: 49815da) and that twitter/the-algorithm was unchanged at 31 commits verified.

These notes number the xai-org/x-algorithm commits that came after the Gen4 release (47c1bcd, 2026-08-13) sequentially, starting from Gen4+1. Per-commit details are in the change log at the end of the file.

Four facts matter most:

  1. There is a 26-month gap between Gen1 and Gen2. There was not a single commit between 2023-07-07 and 2025-09-03 verified.

  2. Gen2 deleted nothing. There were 0 deletions and 0 renames. The 2023 file set of 6,906 files is a proper subset of the 2025 file set of 7,667 files. In other words, cr-mixer/, navi/, ann/, simclusters-ann/, src/, product-mixer/, and others remain exactly as they were, with not a single byte changed since 2023-04. The only top-level paths the 2025 commit touched are home-mixer/, tweet-mixer/, and README.md, with 603, 384, and 1 files respectively verified.

    • The command used for the check is git show --name-only --pretty='' c54bec0 | awk -F/ '{print $1}' | sort | uniq -c.

    A file in the repo does not mean the code is in use now. Deprecation shows up only as a line removed from the README, not as a file deletion. cr-mixer is exactly such a case. The only documentation change in the 2025 commit replaced the service name in the README's cr-mixer row with tweet-mixer and left the wording as is verified — git show c54bec0 -- README.md.

  3. Gen3 is a separate repo. The first 3 commits of xai-org/x-algorithm (aaa167b→e414c17→0bfc279) rewrote the code and added grox/, reaching 216 files verified.

  4. Gen4 changed the nature of Gen3. Through Gen3, only the shape of the logic was released, while values, wiring, and safety rules were not public, but Gen4 released the following three things verified — README.md #latest-update--august-13th-2026.

    • Released a production cron mirror of the weights and constants as home-mixer/params/param.rs verified — same README.
    • Released the entire safety pipeline through visibility-filtering/ and 15 labeling sub-repos verified — same README.
    • Made the system runnable end to end by including training code and synthetic data in phoenix/ verified — same README.

    The detailed analysis is in daily/2026-08-15.md.

↑ Contents

Glossary

Name Meaning Code location Evidence section
Gen4+N Number these notes assigned sequentially to xai-org/x-algorithm commits that came after the Gen4 release (2026-08-13) — Released source Gen4, change log
canon Refers to this document (the X canon). 'The earlier canon version' means an earlier version of this document — Generation comparison
cron mirror A parameter file published in the repo that syncs production feature-switch defaults daily. The time of the last sync is written in the file header home-mixer/params/param.rs Weights
home-mixer The service that receives For You and Following feed requests. The candidate source, scorer, filter, and blender code lives under it home-mixer/ Feed surfaces
Thunder An index that holds the latest in-network posts in memory (2-day retention). Newly built in 2026 thunder/ Candidate generation — current
Phoenix A Transformer ported from Grok-1. It puts the user history and the candidates into one sequence and predicts engagement probabilities for each candidate. There is also Phoenix retrieval, which finds OON candidates phoenix/ Phoenix
OON out-of-network. Posts from accounts the viewer does not follow. The OON multiplier applies in score calculation OonWeightFactor Candidate allocation
SID semantic ID. An ID that represents a post as a sequence of codewords in a coarse → fine hierarchy SidSource, sid_k1 / sid_k2 / sid_k3 Candidate generation — current, Phoenix
VQV video quality view. X's behavior label for whether a video was actually watched for at least a set time. It belongs to the Attention category among the Phoenix prediction heads VqvWeight Phoenix
VMRanker (VM-Ranker) The slate re-ranking service. It keeps the legacy name but is responsible for DPP slate re-ranking home-mixer/scorers/vm_ranker.rs, vm-ranker/ Diversity
DPP Determinantal Point Process. One of the diversity mechanisms; it selects candidates by optimizing them as a set DppTheta, DppMaxSelectedRank in vm-ranker/params.rs. In the Gen4 home-mixer param, VMRankerDppTheta, VMRankerDppMaxSelectedRank Diversity, commit summary table
VF (visibility-filtering) The last stage of the Labeling Path. For each post it issues a verdict on whether it may be shown to this viewer, and home-mixer drops or keeps the candidate according to that verdict visibility-filtering/, home-mixer/filters/vf_filter.rs Safety
grox A Grok-based nearline annotation pipeline. It produces slop scores, safety labels, topics, reply scores, and multimodal embeddings grox/ Grox
PTOS (PtOS) The layer in the grox safety pipeline that determines violation categories based on policy grox/flows/ptos/ Grox → PtOS
Strato An internal X system. The code looks up or stores safety annotations, AI trends, and reply ranking scores through Strato StratoVfClient, grox/flows/reply_spam/strato_loader.py Grox → PtOS
TES Tweet Entity Service. X's internal tweet metadata lookup service TesHydrator, TESClient TesHydrator cache removal in the change log
shadow traffic A path that picks some real requests and also sends them to an experimental Phoenix cluster for scoring, keeping those scores only in logs. It does not affect serving results home-mixer/util/shadow.rs, PhoenixExperimentsSideEffect Open questions
UTH (Under the Hood) A transparency tool where users look up the visibility-impacting labels attached to their own account and posts. A daily batch collects them and the serving layer aggregates them under-the-hood/ Under the Hood

↑ Contents

Feed surfaces

This document mainly covers For You, the feed surface the algorithm recommends. The vertical video carousel inside For You was introduced, off by default, in the 2026-10-08 commit (Gen4+38) verified (Gen4+38 in the change log); in the 2026-10-15 commit (Gen4+42), a VQV prediction term from the immersive surface model was added to the carousel ranking score, but its default weight is 0.0, so default behavior is unchanged verified (Gen4+42 in the change log). Since the 2026-10-17 commit (Gen4+44), the three dedup checks inside the carousel are on by default, and the carousel is not inserted when the same response has a Who-to-Follow module verified (Gen4+44 in the change log). Since the 2026-10-20 commit (Gen4+45), the carousel excludes videos whose media already appears in the top 35 of the main feed, and the carousel itself is still off by default as of that commit verified (daily 2026-10-21); the video-only path implemented in Thunder is turned on by a separate surface that is not public (Reels equivalent) inferred.

  • For You is the algorithm-recommended feed this document mainly covers, and its gRPC methods are get_for_you_feed and get_for_you_feed_urt verified 2026 — home-mixer/server.rs:273-346
  • A vertical video carousel module, which places 3–5 vertical videos in the 6th slot of the For You feed, was introduced off by default in the 2026-10-08 commit (Gen4+38) verified — home-mixer/util/video_carousel.rs
    • Off by default means EnableVideoCarousel = false in home-mixer/params/param.rs.
    • It is enabled only on For You requests and appears again only after 60 minutes (VideoCarouselFatigueMinutes) have passed since the last carousel impression.
    • The slot is the 6th position in the feed (VideoCarouselPosition). A vertical video is one whose width-to-height ratio is below 1.0.
    • Since the 2026-10-15 commit (Gen4+42), the ranking score is the video_open weight plus the VQV prediction weight of the immersive surface model (GalleryPage). Because the VQV weight defaults to 0.0, default behavior is still ordering by video_open verified — home-mixer/util/video_carousel.rs:104-123.
    • Since the 2026-10-17 commit (Gen4+44), the three dedup checks inside the carousel (same author, first 3 SID levels matching, multimodal embedding cosine of 0.72 or higher) are on by default verified — params/param.rs:1095-1112.
      • From the same commit, the carousel is not inserted when the same response has a Who-to-Follow module (no parameter) verified — selectors/blender_selector.rs:50, 156-161.
      • The carousel itself (EnableVideoCarousel) is still off by default in the 2026-10-20 commit (74d49a4) verified — params/param.rs:1102.
    • Since the 2026-10-20 commit (Gen4+45), the carousel skips videos that have the same media ID or MD5 hash as the top 35 of the main feed, and videos with the same media as one picked earlier within the carousel (no parameter) verified — util/video_carousel.rs:52-108.
    • At introduction (Gen4+38, 2026-10-08), vertical videos were grouped in order of predicted video_open probability.
    • The detailed selection path is in Gen4+38 in the change log.
  • Following handles only in-network posts and, as of the 2026-10-17 commit (862a123), has a gRPC service and candidate pipelines separate from For You verified — home-mixer/lib.rs
    • The services are FollowingFeedService and RankedFollowingFeedService, and the pipelines are following_candidate_pipeline.rs, ranked_following_candidate_pipeline.rs, and reverse_chron_posts_pipeline.rs. The Gen4 release (47c1bcd) already had the same structure verified — home-mixer/server.rs:522-680, home-mixer/candidate_pipeline/
    • Both Following services set in_network_only to true after building the query verified — home-mixer/server.rs:555, 658
    • Important: Even for a For You request, if the user has allow_for_you_recommendations = false, the server demotes the request to in_network_only verified — home-mixer/server.rs:96-97
    • The product_surface value passed to Phoenix also splits into HomeTimelineRankedFollowing and HomeTimelineRanking depending on this flag verified — home-mixer/scorers/phoenix_scorer.rs:86-90.
    • Gen3 (0bfc279) had no Following-specific service and was split only by the in_network_only flag of the same pipeline verified — home-mixer/server.rs:75-76
    • In the 2026-08-18 commit (Gen4+3), a VF hydrator dedicated to reverse-chron (Following) was branched off verified — home-mixer/candidate_hydrators/vf_following_candidate_hydrator.rs
      • The new hydrator is a new file of 95 lines.
      • Another evidence location is home-mixer/candidate_pipeline/reverse_chron_posts_pipeline.rs:141-144.
      • Previously, For You and Following used the same VFCandidateHydrator.
      • The new hydrator is connected only to the Following pipeline.
      • The should_drop_ancillary helper is reused from the existing file as pub(crate).
      • The new hydrator returns only the VF lookup results (visibility_reason, drop_ancillary_posts) and resets the remaining candidate fields with ..Default::default().
      • In other words, the Following pipeline's hydration output shrank to the two VF results, and the remaining fields are filled by other hydrators.
      • VF's SafetyLevel remains TimelineHome. Following and For You share the safety level.
      • Why it matters: With this commit, the hydration field sets of For You and Following were explicitly split; this is an optimization direction that avoids spending logic and resources on candidate fields Following does not use inferred
  • A topic feed is a request carrying topic_ids; if there are 7 or more topic ids (> 6), it is treated as a "bulk topic request" and the candidate sources differ verified 2026 — home-mixer/models/query.rs:201-203
  • Video surface: Thunder has a dedicated video ring buffer and an is_video_request path that are fully implemented, but the only public caller hardcodes is_video_request: false verified(code) — thunder/thunder_service.rs:287
    • Another evidence location is home-mixer/sources/thunder_source.rs.
    • Because the implementation is complete and not a stub, a separate surface that is not public (Reels equivalent) turns this flag on and uses it inferred.

↑ Contents

Candidate generation (retrieval)

Candidate generation (retrieval) is the stage that gathers posts from multiple sources to hand to ranking. The 2026 pipeline runs the sources in parallel and concatenates the results as is, with per-source caps instead of an allocation ratio between in-network and OON verified (Candidate allocation section). Since the 2026-10-07 commit (Gen4+37), the source that serves a popular posts list built in advance by an offline job is on by default verified (Gen4+37 in the change log). The SID generative retrieval source released in the 2026-10-02–03 commits (Gen4+34–35) is off by default verified (Gen4+34, Gen4+35).

Current (2026-08 Gen4)

As of the Gen4 release, For You has 12 candidate sources in total: 7 internal Phoenix pipeline sources and 5 external For You pipeline sources verified. The evidence is home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs, for_you_candidate_pipeline.rs, and the EnableXxxSource defaults in home-mixer/params/param.rs.

On the same basis, the retrieval sources confirmed on by default are Thunder, which serves the latest in-network posts, and Phoenix retrieval and SimClusters, which serve OON candidates verified. The SID source and the popular posts source in the table were added later, in the 2026-10-02–03 commits (Gen4+34–35) verified (details below). The table values are as of the Gen4 release, but the rows for these two sources and the cold slice cap of PhoenixSource are items added in later commits.

Source What Gate · default
ThunderSource Latest in-network posts (in-memory, 2-day retention) EnableThunderSource=true (on by default), ThunderMaxResults=1200
PhoenixSource OON candidates found by Phoenix two-tower retrieval (an embedding model split into a user tower and a post tower). Since the 2026-09-18 commit, the cold-start slice is distinguished separately (details below) EnablePhoenixSource=true, PhoenixMaxResults=1000. Cold slice cap PhoenixColdStartMaxResults=200 (since 2026-09-30, Gen4+32)
SimclustersSource SimClusters (revived 2026-08). OON candidates found by cluster similarity EnableSimclustersSource=true
TweetMixerSource Legacy OON aggregator (calls the Gen2 tweet-mixer over thrift) EnableTweetMixerSource=false off by default, TweetMixerMaxResults=800
PhoenixTopicsSource Phoenix retrieval for topic requests Topic requests
PhoenixMOESource Phoenix MoE retrieval EnablePhoenixMOESource
SidSource SID generative retrieval microservice (Gen4+34~35, released 2026-10-02 ~ 10-03, details below) home-mixer-side EnableSidSource=false off by default, SidSourceMaxResults=800, SidSourceMaxSeeds=50, SidSourceMaxPerSeed=100, SidSourceMinPrefixDepth=3 (as of the 2026-10-02 commit)
PopularPostsSource Popular posts list built in advance by an offline job (details below) EnablePopularPostsSource=true, on by default since the 2026-10-07 commit (Gen4+37) (param.rs:944-948). PopularPostsMaxResults=500
CachedPostsSource Redis warm cache has_cached_posts (the inverse condition of all the others)
ScoredPostsSource Runs the entire pipeline above recursively Always
AdsSource Ads EnableAdsSource && !is_preview
WhoToFollowSource Follow recommendations (up to 3 accounts) EnableWhoToFollowModule
PromptsSource Inline/cover prompts EnablePrompts
PushToHomeSource Pins a post that came from a push at position 0 push_to_home_post_id.is_some()

The details of the three sources shortened in the table are as follows:

  • In the 2026-09-18 commit (Gen4+25), a cold-start slice was wired into PhoenixSource as a first-class path via the dataset_type tag verified (Cold start section).
    • If a response candidate has dataset_type == HOME_COLD_DATASET_TYPE(=13), served_type is ForYouPhoenixRetrievalCold; otherwise it is the existing ForYouPhoenixRetrieval.
    • This slice's cap PhoenixColdStartMaxResults was introduced in Gen4+25 at 0 (off by default) and has been 200 since the 2026-09-30 commit (Gen4+32) verified — params/param.rs:16. It is still 200 in the 2026-10-17 commit (862a123).
  • SidSource calls the SID generative retrieval microservice released in the 2026-10-02–03 commits (Gen4+34–35) verified (Gen4+34, Gen4+35).
    • When the user's positive engagement post IDs are passed as seeds, the service builds candidates in three steps:
      1. Convert the seed into a 6-level code. If the seed post is in the local SID trie, look it up there; otherwise, look it up in the external SidLookupService.
      2. For each seed, pick candidates from the shared-prefix range in order of decreasing depth, from max_prefix_depth down to min_prefix_depth. Ranges already consumed at a deeper level are excluded.
      3. Round-robin interleave the candidate lists of all seeds, dedup, and return the result.
    • Seeds are extracted from user engagement signals by reusing the same function as simclusters_source::post_signal_ids.
    • Each Candidate carries shared_prefix_depth as its score.
    • Response candidates are labeled served_type=ForYouPhoenixRetrievalMoe. This is the same label as PhoenixMOE.
    • The call has a 300ms gRPC deadline, and the connection path has two routes: xDS P2C → DNS fallback.
    • The server polls the parquet snapshot (post_sid_snapshot.parquet, columns post_id/author_id/post_sid) every 30 seconds and reloads it in full.
    • The trie consists of two vectors: by_key(prefix range binary search) and by_post(seed lookup).
    • Server-side limits are max_results=800 / max_per_seed=100 / min_prefix_depth=3 / max_prefix_depth=6 / max_seeds=200, and the hard caps are MAX_RESULTS_CAP=5000 · MAX_PER_SEED_CAP=2000 · MAX_CALLER_LEN=64.
  • PopularPostsSource was created in the 2026-10-03 commit (Gen4+35), and its structure was replaced wholesale in the 2026-10-06 commit (Gen4+36) verified (Gen4+35, Gen4+36).
    • The replaced structure does not call Thunder at request time. It reads the popular posts list (up to 500) built in advance by two offline jobs from Manhattan KV every 60 seconds, removes only the posts the user has already seen (seen_ids), and returns the rest as candidates as is.
    • The first job, popular_authors_job (hourly), picks from a BigQuery snapshot the top 0.005% by follower count among authors who posted in the last 7 days (TOP_POSTING_AUTHORS_FRACTION=0.00005).
    • The second job, popular_posts_job (every 5 minutes), gathers those authors' original posts (excluding replies and retweets) from Thunder. It scores posts from the last 24 hours using the view counts from engagement counts, then trims to 5 per author and 500 overall.
      • The scoring formula is quality = views × (1−e^{−λ·24h}) / (1−e^{−λ·max(age,0.5h)}) (half-life 8 hours).
    • Since the 2026-10-16 commit (Gen4+43), the label is its own served type ForYouPopularPosts; before that it was ForYouPhoenixRetrieval (popular_posts_source.rs:13).
    • It is turned off for cache-reuse requests and in_network_only requests (popular_posts_source.rs:27-31).
    • PopularPostsTopAuthors, PopularPostsMaxPerAuthor, and PopularPostsMaxRepliesRepostsPerAuthor, which existed in Gen4+35, were deleted and moved to job CLI arguments (--popular-posts-per-author=5, --popular-posts-budget=500, --popular-posts-half-life-hours=8).
    • Storage format v1 is a 13-byte header followed by 24 bytes per post (post_id, author_id, quality).

Thunder is an in-network index newly built in 2026. It replaces the Earlybird search index inferred. The evidence is that the roles are the same and references to Earlybird disappeared.

  • Posts are held in DashMap<post_id, LightPost>, and each author has 3 ring buffers (original, reply/retweet, video) verified — thunder/posts/post_store.rs:20-53
  • LightPost has no text, no media, no engagement counts, and no safety labels verified — thunder/kafka/tweet_events_listener.rs:230-248
    • The only fields it holds are post_id, author_id, created_at, in_reply_to_*, is_retweet, is_reply, source_*, has_video, and conversation_id.
  • Ranking inside Thunder is deliberately a single line of recency sorting, because Phoenix re-scores anyway verified — thunder/thunder_service.rs:334-339
  • The default retention period is 2 days verified — post_store.rs:521-526
  • Under overload, it rejects requests immediately instead of queuing them verified — thunder_service.rs:160-171
    • If try_acquire fails, it becomes resource_exhausted.
  • The reply exposure rule is a product policy stated explicitly in code verified — post_store.rs:291-315
    • If the parent is an original post, the reply passes.
    • If the parent is not an original post, the reply passes only when the parent is a direct reply to the conversation root and the reply target is someone the viewer follows.
    • If the parent is not in the store, the reply is simply dropped. So when a thread root older than 2 days expires, all replies under it become invisible.
  • A user's own retweet of themselves is blocked by one line in the hot path verified — post_store.rs:287-289

The retrieval sources that survived from the 2023 generation are tweet-mixer and SimClusters. tweet-mixer is mirrored as off by default, and SimClusters was revived. ⚠ Claim changed (2026-08-13): the canon had long stated explicitly that "SimClusters, TwHIN, UTEG/GraphJet, FRS, Earlybird, Lists, Communities — none of these names appear anywhere in home-mixer/". The Gen4 release brought the simclusters/ sub-repo back as a home-mixer OON retrieval source (EnableSimclustersSource=true, on by default). TwHIN/UTEG/GraphJet/FRS/Earlybird/Lists/Communities are not in Gen4 either verified — home-mixer/params/param.rs, README.md #candidate-sources

The details of the SimClusters core algorithm (SVD pipeline, k, index format, update cycle) need a deep dive. For the simclusters/ sub-repo, only the file list was checked and the individual files were not read. The items that need a deep dive are in the list in daily/2026-08-15.md.

Candidate allocation — per-source caps, not ratios

"In-network 50% / out-of-network 50%" is not a quota.

The 2026 pipeline has no allocator, no ratio parameter, and no global cap verified — candidate-pipeline/candidate_pipeline.rs:257-272

  • It runs the sources in parallel with join_all and simply concatenates the results.
  • A source that errors out silently disappears.

The max_results value each source reads from a FeatureSwitch parameter was released as a production cron mirror in the Gen4 release (2026-08-13) verified — home-mixer/params/param.rs

  • ThunderMaxResults = 1200
  • PhoenixMaxResults = 1000
  • TweetMixerMaxResults = 800. However, it is off by default with EnableTweetMixerSource = false.
  • For PhoenixMOEMaxResults, the corresponding param must be checked.

In the 2026 pipeline, the means of adjusting the in-network versus OON share is a scoring multiplier, not retrieval verified — home-mixer/scorers/ranking_scorer.rs:220-239, 272-275

let final_score = match c.in_network {
    Some(false) => after_diversity * effective_oon,
    _ => after_diversity,
};

effective_oon is computed in three branches, and all of its values were released in the Gen4 release verified — home-mixer/params/param.rs:247,269,275

param default condition
OonWeightFactor 0.75 default OON multiplier
TopicOonWeightFactor 0.5 for topic requests
NewUserAgeThresholdSecs 0 new-user routing threshold. A value of 0 seconds means the new-user path is effectively mirrored as disabled inferred. The NEW_USER_OON_WEIGHT_FACTOR param was confirmed to be absent from param.rs
EnableOonRescoreForInNetworkRepliesRetweets true the OON multiplier also applies to in-network replies and retweets

A generalization note is in candidate-allocation.md.

Legacy (2025 Scala, tweet-mixer)

tweet-mixer appeared all at once on 2025-09-03 with 384 files. It has no README and not a single .md file, so every fact has to be confirmed from the code alone verified.

The retrieval families newly introduced in this generation are as follows:

  • 19 new DeepRetrieval-related files appeared (0 in the 2023 snapshot) verified
  • 20 new files that consume SimClusters appeared, and the SimClusters core itself is unchanged from 2023 verified — git show --diff-filter=M c54bec0 | grep -ci simclusters = 0
    • The new files include SimclusterColdPostsCandidateSource.scala.
    • The core is src/scala/com/twitter/simclusters_v2 and simclusters-ann/.
    • That is, the engine was left untouched and only the points of consumption were added.
  • 15 new files that consume Earlybird appeared, and the index itself (src/java/com/twitter/search) did not change verified
  • Hydra already existed in the 2023 cr-mixer as a thrift client, and in 2025 it was promoted to a scorer and an embedding generation service. "Hydra is new in 2025" is wrong verified

How SimClusters, TwHIN, and DeepRetrieval each generate embeddings, build indexes, and do lookups, along with the code defects found in them, is summarized in embedding-retrieval.md.

↑ Contents

Ranking

As of the Gen4 release (2026-08-13), Phoenix, the ranking model of X For You, predicts the probability of actions such as like, reply, and report for each candidate post verified(Phoenix section). As of the same release, the score starts from the sum of these probabilities multiplied by per-action weights, and after an offset it applies author-diversity decay, the OON multiplier, and the new-author boost in that order verified(Phoenix section, new-author boost section). Since the 2026-09-24 commit (Gen4+28), the shared crate xai-value-model does this calculation, and home-mixer's ranking scorer file was deleted verified(Phoenix section). In the params of the same release, VMRanker's DPP slate (the bundle of candidates sent out to the user) reordering is also on verified(VMRanker table in the Phoenix section). Since this release, the weight values can be read in the repo as a cron mirror that copies the production defaults daily, and some mirror values changed up to the 2026-10-14 commit (Gen4+41) verified(weights section, Gen4+41 in the change log).

Phoenix (2026, current) — Gen4 release update

Phoenix is a Transformer ported from Grok-1. It puts the user history and the candidates into one sequence and scores them in a single pass. The structure, masks, and parameter distribution are summarized in sequence-transformer-ranking.md.

There are 24 prediction heads by the documentation, divided into 5 categories verified — README.md #scoring-and-ranking (2026-08-13). The table follows this README; weight terms removed or added since then are in the weights table.

category head param symbol (weight)
Engagement (7) favorite · reply · repost · quote · share · share via DM · share via copy link FavoriteWeight · ReplyWeight · RetweetWeight · QuoteWeight · ShareWeight · ShareViaDmWeight · ShareViaCopyLinkWeight
Clicks (6) post · profile · link · photo expand · video open · quoted post ClickWeight · ProfileClickWeight · OpenLinkWeight · PhotoExpandWeight · VideoOpenWeight · QuotedClickWeight
Attention (5) video quality view · dwell · dwell time · click dwell time · active seconds VqvWeight · DwellWeight · ContDwellTimeWeight · ContClickDwellTimeWeight · ContActiveSecs5mResidualNormWeight
Author (1) follow author FollowAuthorWeight
Negative (5) not interested · mute author · block author · report · not dwelled NotInterestedWeight · MuteAuthorWeight · BlockAuthorWeight · ReportWeight · NotDwelledWeight

The click dwell time head and the dwell time head, which deal with dwell time, had their training approach or ranking term changed after the release verified(Gen4+22 in the change log, Gen4+23, Gen4+38).

  • In the 2026-09-16 commit (fad2f71, Gen4+22), the click dwell time head began to be trained as sigmoid BCE (threshold=10s) binary classification verified(Gen4+22 in the change log). The public training code before that had no loss setting for this head verified — 2d4a03c phoenix/xrex grep.
    • The head name is click_dwell_time.
    • The serving head remains a probability [0,1]. _rescale_sigmoid_heads_for_inference skips restoring the original unit through a binary exception.
  • In the 2026-09-17 commit (42266f3, Gen4+23), the ranking term for click dwell was redefined verified(Gen4+23 in the change log).
    • EnableClickDwellLowFavRatePenalty, which had existed since the Gen4 release (47c1bcd), was deleted entirely (param.rs:387-416). This heuristic used 4 tuning params (baseline, alpha, floor, cap) and (fav/baseline)^alpha to lower the dwell prediction when fav was low.
    • A new parameter EnableCdwellOnImpr (off by default) took its place. When on, it computes click_dwell_term = click_dwell_time × click_score. This redefines the click dwell term as expected dwell per impression.
    • ContClickDwellTimeWeight did not change in the mirror.
  • In the 2026-10-08 commit (35650fb, Gen4+38), the training loss of the dwell_time head changed from Tweedie and MAE to CREAD verified — phoenix/xrex/configs/xrecsys.py:272-275, 715-742.
    • The newly enabled CREAD loss trains dwell time as binary classification of whether it exceeds each of 30 log-spaced thresholds (2 seconds up to the cap), then converts back to an expected value.
    • In the default home training config, two CREAD losses were turned on at 0.1 each. The cap is 120 seconds for regular surfaces and 240 seconds for gallery/immersive surfaces.
    • In the same config, the Tweedie (0.1) and MAE (0.1) weights became 0.
    • The serving head name and the ranking weight (ContDwellTimeWeight) are unchanged.

There are 2 more params that contribute to the score but are not in the README categories. The total number of effective weight params is 26.

  • QuotedVqvWeight is the video quality view weight of a quoted post. It is not in the README categories, but the param exists.
  • PostUnexploredWeight is the new-author boost. Details are in the new-author boost section.

The canon's long-standing observation that "the head count splits three ways within the repo (public checkpoint 19 / ranking scorer 22 / VMRanker proto 21)" is a fact up to Gen3. The Gen4 README states 24 by the documentation, and the weight symbols in home-mixer/params/param.rs are also organized as 25 (prediction head) + 1 (post_unexplored heuristic). However, whether the dead 19-field PhoenixScores struct in candidate_pipeline/candidate.rs observed in Gen3 was cleaned up in Gen4 requires a deep dive. verified (documentation) / inferred (whether the code was cleaned up)

The generalized notes on head design are in multi-task-ranking.md.

As of the Gen4 release (47c1bcd), the final score was computed in the following order verified — home-mixer/scorers/ranking_scorer.rs.

  1. Take the weighted sum of 26 weights × P(head). negative_sum is subtracted with its sign flipped.
    • Only for original posts from mutual follows, add BidirectionalFollowReplyWeightBoost=15.0 to the reply weight. The condition is !in_reply_to && !retweeted && is_mutual_follow_author.
  2. Apply offset_score. If the score is negative, it is (score + negative_sum) / total_sum * NEGATIVE_SCORES_OFFSET; if positive, it is score + NEGATIVE_SCORES_OFFSET.
  3. Multiply by author-diversity decay. The k-th appearance of the same author is multiplied by (1-0.25)*0.5^(k-1) + 0.25, so the first appearance is 1.0×.
    • The exponent is the number of times that author has appeared earlier (ranking_scorer.rs:614-615, 635).
    • The settings are AuthorDiversityDecay=0.5, AuthorDiversityFloor=0.25, and EnableAuthorDiversity=true.
  4. Multiply by the OON multiplier OonWeightFactor=0.75. For topic requests, use TopicOonWeightFactor=0.5.
  5. Apply the new-author boost.

The normalize_score normalization stage that existed in the Gen3 code (0bfc279) is not in ranking_scorer.rs of the Gen4 release verified — ranking_scorer.rs:4, 259.

In the 2026-09-24 commit (Gen4+28, 44d37eb), ranking_scorer.rs was deleted, and the weighted sum, offset, author-diversity decay, and OON multiplier calculations moved to the shared crate xai-value-model verified — xai-value-model/scoring.rs.

  • As of the 2026-10-17 commit (862a123), the scorer chain is PhoenixScorer, ImmersiveVideoScorer, VMRanker in that order verified — phoenix_candidate_pipeline.rs:487-488.
  • As of the same reference, home-mixer's VMRanker scorer first computes a local weighted sum (fuse_heads) and the new-author adjustment, then overwrites them with the score returned by the vm-ranker service verified — home-mixer/scorers/vm_ranker.rs:59-131.

The heuristic that sets the vqv weight to 0 depending on the video length and the querying user's follower count has been in the repo's home-mixer/util/candidates_util.rs since the Gen4 release verified — candidates_util.rs:19-60.

  • The related identifiers are vqv_weight, quoted_vqv_weight, and the MinVideoDurationMs threshold. vqv_weight uses the weight as is when the video length exceeds MinVideoDurationMs and query.user_features.follower_count is not 10,000 or more. It also uses the weight when the follower count is empty (candidates_util.rs:4, 25-38).
  • In the Gen4 release, ranking_scorer.rs:230 called this function.

The VMRanker and DPP settings are now public verified — home-mixer/params/param.rs:579-618. The values in the table are those at the time of the Gen4 release, except VMRankerClusterId, which is the 2026-10-06 value.

param default meaning
EnableVMRanker true direct answer to the canon's open question "Are VMRanker/DPP on?"
VMRankerValueModelId "dpp" selects Determinantal Point Process
VMRankerDppTheta 0.65 DPP mixing parameter
VMRankerDppMaxSelectedRank 150 slate size cap
VMRankerEnableFallback false if the xDS call fails, it does not retry through the DNS path. Since the 2026-09-24 commit (Gen4+28), an RPC failure emits the local weighted sum regardless of this value (paragraph below)
VMRankerClusterId "Experiment1" (since 2026-10-06, Gen4+36) serving cluster. This is the value as of the cron mirror. Only the experiment cluster name changed, and what differs is not public
VMRankerSendHeadWeights false per-head weights are not sent to VMRanker
  • VMRankerClusterId started at "Experiment3" and changed several times in between. It became "Experiment6" in Gen4+35 and "Experiment1" in Gen4+36 (2026-10-06) verified.
  • In the 2026-09-24 commit (Gen4+28), the DPP settings were removed from home-mixer's param.rs and moved to vm-ranker/params.rs. As of the 2026-10-17 commit (862a123), the values in that file are DppEnabled=true, DppTheta=0.65, DppMaxSelectedRank=150, and TopicDppEnabled=false verified — vm-ranker/params.rs:235-248.

The VMRanker service itself has been in vm-ranker/ since the Gen4 release verified — vm-ranker/ranker_service.rs.

  • The same directory has main.rs, dpp.rs (the DPP implementation), and scoring/dpp_model.rs verified — vm-ranker/.
  • The 2026-09-23 commit (Gen4+27) added scoring/value_model.rs, params.rs, and config_sync.rs verified — vm-ranker/.
  • The structure in which the VMRanker response overwrites the candidate's score has been unchanged since Gen3 verified — vm_ranker.rs:116-131.
  • If the gRPC call fails, since the 2026-09-24 commit (Gen4+28) it emits the local weighted-sum score without DPP and counts the failure with the VMRanker.local_score_fallback metric verified — vm_ranker.rs:107-113, 170-178.

The 2026-08-18 commit (Gen4+3) newly wired 7 SID (semantic ID) fields into the VMRanker request verified — home-mixer/scorers/vm_ranker.rs:194-206:

VMRanker request field source field meaning
sid_known s.sid_known whether this candidate's SID is known/valid (bool)
sid_k1 / sid_k2 / sid_k3 s.sid_k_l1 / sid_k_l2 / sid_k_l3 codeword of each level of the 3-level hierarchical semantic ID
sid_gap1 / sid_gap2 / sid_gap3 s.sid_gap_l1 / sid_gap_l2 / sid_gap_l3 neighbor gap in each level's codebook (proxy for confidence/sparsity inferred)
  • With these fields passed per candidate source alongside the existing fields, VMRanker can now see not only the collaborative pool rank but also the semantic ID hierarchy position in its item diversity decisions.
    • The existing fields are k, pool_rank, and pool_rank_gap.
    • VMRanker keeps the legacy name but is now responsible for DPP slate reordering.
    • This is the X-side connection corresponding to the SID family observed in Kuaishou HD-Rec/PushDualGen inferred.
  • Why it matters: VMRanker's diversity judgment now has three axes of information: per-candidate-source rank, the 3-level semantic ID codeword, and the neighbor gap.
    • The existence of a 3-level codebook hierarchy (coarse → fine) is itself newly confirmed architecture information.
    • This wiring is where the rank/rerank side consumes the same SID infrastructure as the SID window in Phoenix retrieval. The SID window (video_14day, video_48h, etc.) is in the Gen4+1 entry of the Phoenix training and index section.
  • Undisclosed: The following are not public:
    • The SID codebook definition (vocabulary size and training approach of each level)
    • The exact formula for sid_gap
    • How these fields are used in VMRanker's response logic

New-author boost — the third adjustment factor documented in Gen4

The new-author boost is an adjustment factor that lifts posts from authors whose impressions are below a threshold toward a target position. The README describes it as follows:

"New-Author Boost: posts from authors whose impressions are below a threshold are lifted toward a target position."

The new README states three scoring adjustment factors verified — README.md #scoring-and-ranking.

  1. Author Diversity Decay
  2. Out-of-Network Discount
  3. New-Author Boost

The params that set this boost are in the table below verified — home-mixer/params/param.rs:351-374, #coldstart-threshold.

param default meaning
PostUnexploredWeight 0.02 → home-mixer mirror 0.015 (Gen4+37, 2026-10-07). vm-ranker/params.rs stays at 0.02 additive value of the new-author boost
PostUnexploredWeightInNetworkOnly true applied to in-network only (not attached to OON)
EnableMultiplicativePostUnexplored false additive by default, multiplicative form disabled
MultiplicativePostUnexploredAlpha 0.0 exponent used when the multiplicative form is enabled
ColdStartImpressionThreshold 200 (since 2026-09-30, Gen4+32) The README describes it as targeting authors whose impressions are below the threshold, but the code treats a post as a cold-start slot candidate only when its home impression count is below this value verified

ColdStartImpressionThreshold was lowered from 1000 to 200 in the 2026-09-30 commit (Gen4+32) verified — params/param.rs:459(Gen4+32 in the change log). It is also 200 in the 2026-10-17 commit (862a123).

  • The cold-start slot selector that uses this threshold picks only candidates whose post view_count_on_home is below the threshold verified — author_cold_start.rs:293-294.
  • The Gen4 release code made the same comparison using the candidate post's view_count verified — author_cold_start.rs:179.

The canon's observation in the cold start section that "new items: the 2026 code has no mechanism at all" is a fact up to Gen3. Gen4 documented a new-author (account-level) boost. As of the Gen4 release, there was no boost attached to an individual new post (item level), and the hash embedding collision problem was unchanged verified. The later post-level mechanisms are in the cold start section.

Weights — values were released with the Gen4 release (⚠ mass claim change, 2026-08-13)

Conclusion (updated 2026-08-15): The weight values were released as a production cron mirror. The mirror file is home-mixer/params/param.rs. The earlier conclusion, "weight values have no primary source in any generation," was overturned by the Gen4 release. This section is the most important one in this document.

The new conclusion is that a mirror that syncs the production primary values daily exists, and the last sync time is stated in the file header.

The file header defines the nature of this mirror verified — home-mixer/params/param.rs:1:

// mirrored from config feature-switch defaults; last sync 2026-08-12T04:09:22Z

The README states what mirroring means verified — README.md #experiments-and-configuration:

"To help people understand the production defaults, we run cron scripts that set the defaults in this repository's code to be the primary production values"

The engagement head weights are in the table below (in file order, 2026-08-12 sync) verified.

head default note (vs. the 2023-04-05 snapshot)
favorite 0.5 (the home-mixer mirror is 0.6 in Gen4+40 and 0.66 since Gen4+41. The vm-ranker mirror is 0.5) 0.5 → matches
reply 5.0 13.5 → large decrease. However, for mutual-follow originals the effective value is 20 with the +15 boost
retweet 1.0 1.0 → matches
photo_expand 0.05 no counterpart in the snapshot
video_open 0.05 (at Gen4+39 both mirrors were 0.07. The home-mixer mirror has been 0.02 since Gen4+40, and the vm-ranker mirror is 0.07) no counterpart in the snapshot
click (post) 0.4 (since Gen4+31 (2026-09-29), 0.3 in both mirrors) scale differs greatly from good_click 11 / good_click_v2 10 — possibly a different head inferred
open_link 0.2 no counterpart in the snapshot
profile_click 0.0 good_profile_click 12 → removed entirely
vqv (video quality view) 0.05 (0.0 since Gen4+8 (2026-08-25). As of the 2026-10-17 commit, 0.0 in both mirrors) 10× increase over video_playback50 0.005
share 2.0 no counterpart in the snapshot
share via DM 5.0
share via copy link 20.0 highest positive individual head
dwell 0.0 (0.05 since Gen4+8 (2026-08-25). As of the 2026-10-17 commit, 0.05 in both mirrors) As of the 2026-08-12 sync, the actual dwell signal was the continuous cont_dwell_time_weight = 0.004
quote 5.0
quoted_click 0.05
quoted_vqv 0.0
follow_author 4.0
post_unexplored 0.02 (the home-mixer mirror is 0.015 since Gen4+37) new-author boost, adjustment factor section above
cont_dwell_time 0.004 (the home-mixer mirror is 0.006 since Gen4+40, the vm-ranker mirror is 0.004)
cont_click_dwell_time 0.0 (0.4 in both mirrors since Gen4+31 (2026-09-29))
cont_active_secs_5m_residual_norm — Removed in Gen4+36 (2026-10-06, e62790c). HOME_VIDEO_CONTINUATION_SECS took over proto slot 3
video_continuation 0.0 Introduced in Gen4+36. Enters as the product P(video_open) × E[continued seconds | open]
user_video_continuation 0.0 Introduced in Gen4+36. Uses the open-weighted average seconds over all candidates in the request instead of the candidate's own
profile_visit_secs 0.0 Introduced in Gen4+36. P(profile_click) × E[profile dwell seconds | click]
react_with_video 0.0 Introduced in Gen4+40. Action SERVER_TWEET_REACT_WITH_VIDEO (ActionName 44, in the slot of the former CLIENT_CARD_CLICK)

The mutual-follow boost was introduced in 2026-07 and adjusted on 2026-07-24 verified — docs/BIDIRECTIONAL_BOOST_CHANGE.md.

param default condition
BidirectionalFollowReplyWeightBoost 15.0 mutual-follow original posts
BidirectionalFollowDwellWeightBoost 0.0 code exists but not deployed

The reply boost value was settled in three stages verified — same file.

  • 2026-07-10: Started an A/B test at 5/10/15/20 verified — same file.
  • 07-13: Rolled out 20 verified — same file.
  • 07-24: Lowered from 20 to 15. The adjustment reflected user feedback during the World Cup season verified — same file.

Whether a post is a boost target is decided by the function below verified — home-mixer/scorers/ranking_scorer.rs:

fn bidirectional_boost_eligible(candidate: &PostCandidate) -> bool {
    candidate.in_reply_to_tweet_id.is_none()
        && candidate.retweeted_tweet_id.is_none()
        && candidate.is_mutual_follow_author == Some(true)
}

In other words, only for original posts does the reply weight go from 5.0 to 20.0. Replies and retweets are not boost targets.

The negative head weights are in the table below verified.

head default vs. the 2023-04-05 snapshot
not_interested −43.2 (the home-mixer mirror was −47.52 in Gen4+31 and has been −52.272 since Gen4+41. The vm-ranker mirror is −47.52) no counterpart
block_author −31.2 no counterpart
mute_author −58.8 no counterpart
report −234.0 report −369 → toward milder
not_dwelled −0.02 no counterpart

The file's own comment defines the meaning of the weight signs and magnitudes verified:

"These weights reflect a combination of how much an action is valued in ranking and typical propensities of these actions across the X network (e.g. negative feedback is overall rare)."

In other words, the magnitude of report −234 is not "the relative importance of one report" but the product of value × 1/propensity. Here propensity means how often that action typically occurs across the whole X network. Because reports are so rare, a large absolute value is attached.

The 2026-08-14 commit (Gen4+1) stated this interpretation rule explicitly in a code comment and the README verified — home-mixer/params/param.rs:281-306, home-mixer/scorers/ranking_scorer.rs:415-446, README.md #scoring-and-ranking:

"Each weight multiplies the predicted probability of that action (P(favorite), P(repost), …) or a continuous value e.g. watch time -- the weights do not multiply raw engagement counts. One common misinterpretation is that you can read these weight ratios as count equivalences, e.g. the incorrect statement that 'one report cancels 468 likes' -- this is incorrect because the weights apply to the predicted probabilities rather than raw counts. And the baseline probability of a Report is more than 1000x lower than a Like, so it's weighted more to allow the prediction to affect the final ranking at all." — home-mixer/params/param.rs:283-292

The same comment also rejects the misconception that "mass reporting uniformly suppresses reach," for two reasons verified — same comment block.

  1. The score multiplies your predicted probability, so reports from bot accounts mainly affect recommendations for users similar to those bots (personalization) verified — same comment.
  2. Only actions taken on posts served in the Home Timeline count as ranking signals. Reactions that appear from a link shared in a group chat do not affect ranking verified — same comment.

Five caveats of the weights mirror

There are five things to watch when reading this mirror.

  1. The cron sync is a mirror that follows once a day, so it lags. Production changes after the sync time in the header may not be reflected yet. verified
  2. Sign and magnitude must be read with propensity in mind. The file's own comment states "value = worth × 1/propensity". The ratio of absolute values (report −234 is 468× favorite 0.5) is not the relative importance as is.
  3. Since the 2026-09-23 commit (Gen4+27), the weights mirror has been split into two files, and as of the 2026-10-17 commit the weight values differ between the two files.
    • Gen4+27 introduced vm-ranker/params.rs, which holds 56 parameters, so the mirror became two files together with the existing home-mixer/params/param.rs for the home-mixer process verified.
    • The next day (Gen4+28, 2026-09-24), 22 non-weight ranking parameters were deleted from the home-mixer param.rs verified — comparison of param.rs in 1b3fec20 and 44d37eb.
      • The deleted parameters include the author diversity, OON, new-user, weight perturbation, VMRanker DPP, and value model switch families; the only weight is ContActiveSecs5mResidualNormWeight.
    • Per-action weights remain in both files, and as of the 2026-10-17 commit (862a123), five values differ: favorite, video_open, cont_dwell_time, post_unexplored, and not_interested verified — home-mixer/params/param.rs, vm-ranker/params.rs.
    • The shared crate xai-value-model was released as an actual file, so both processes compute the value model with the same code verified. However, each process reads the weights from its own parameter file verified — home-mixer/scorers/value_model.rs:8-51, vm-ranker/scoring/value_model.rs:68.
    • The phoenix training hyperparameters and the botmaker rule constants are still outside the repo. crate::util, which the Gen3 canon treated as outside the repo, has been released as home-mixer/util/ since the Gen4 release verified — home-mixer/lib.rs.
  4. The Phoenix training code is "trainable", not "the same as production". The README states the difference in scale: "a small model can be trained and served end-to-end".
  5. Some botmaker rules are not published to prevent gaming. The README states this explicitly. For some of the labels that visibility-filtering references, the rules are not open.

Practice of documenting parameter changes — introduced in Gen4

The practice of documenting parameter changes as code diffs began in Gen4. docs/BIDIRECTIONAL_BOOST_CHANGE.md is a model for how parameter changes will be documented as code diffs going forward verified:

"Going forward, you'll see future algorithm updates published to this open source repository, and you should be able to understand what changed by checking the diffs."

The same document reproduces the 3-stage rollout of the mutual-follow boost (A/B start → broad rollout → parameter adjustment) with 3 code diffs. No other company (Meta, Netflix, or YouTube) has had an example of this documentation approach so far.

The value of tracking the X commit log daily in this repository grows considerably. What changes in which param in the next commit becomes a lagging indicator of A/B results.

Generation comparison — why the earlier canon stated "values undisclosed"

In Gen1, Gen2, and Gen3 the values were indeed not released, and Gen4 newly opened them.

  • Gen1 (2023 Scala): object ModelWeights at ScoredTweetsParam.scala:151 has 17 parameters. The defaults are 1.0 for 10 and 0.0 for 7, and they are not actual production values. Range bounds (e.g., ReportParam −20000..0) are valid only as relative-scale hints.
  • Gen2 (2025 Scala): Moved to HomeGlobalParams.scala:786, and the parameters grew from 17 to 30. All 30 were intentionally removed, with default = 0.0. Only relative-scale hints remain.
  • Gen3 (2026-01 Rust): The params:: module itself was dropped from the repo. Symbols such as FavoriteWeight had only call sites and no definition.
  • Gen4 (2026-08 Rust): ⚠ Released the real production mirror as home-mixer/params/param.rs.

The widely quoted "reply 13.5" table is still exactly the 2023-04-05 snapshot. Only favorite 0.5 and retweet 1.0 match the Gen4 mirror, and the rest was overhauled substantially over three years. verified — twitter/the-algorithm-ml projects/home/recap/README.md

  • The values in that table are as follows:
    • Positive items: fav 0.5 / retweet 1.0 / reply 13.5 / good_profile_click 12.0 / video_playback50 0.005 / reply_engaged_by_author 75.0 / good_click 11.0 / good_click_v2 10.0
    • Negative items: negative_feedback_v2 −74.0 / report −369.0

Some constants are restored across generations. Gen3 offset_score is a literal translation of Gen2 Scala RerankerUtil.weightedScoresSum. The Gen2 counterpart of NEGATIVE_SCORES_OFFSET, which was hidden in crate::params in Gen3, is val Epsilon = 0.001. Whether NEGATIVE_SCORES_OFFSET was also exposed in param.rs in Gen4 needs a grep. Details are in multi-task-ranking.md.

In the Gen2 code, X can inject multiplicative Beta-distribution noise seeded per user×date into the ranking weights verified — HeavyRankerWeightsQueryFeatureHydrator.scala, seed Objects.hash(userId, startOfDay). Whether this noise injection was kept in Gen4 needs a deep dive.

Legacy heavy ranker (2023–2025)

The legacy heavy ranker is a structure in which a multi-task neural network predicts engagement probabilities and sorts by their weighted sum. Compared with the 2026 Phoenix, the objective has the same form and the input differs. The legacy ranker uses handcrafted features as input, and Phoenix uses ID embeddings.

In the 2025 generation, two rankers run side by side verified — ScoredTweetsRerankingScoringPipelineConfig.scala:35-39.

  • Navi is the production heavy ranker and has 15 heads verified — home-mixer/.../model/PredictedScoreFeature.scala:294-311.
  • Phoenix is a Transformer rescorer (a model that rescores the scores assigned by the preceding ranker) and has 13 heads. It is attached after Navi through ParamGatedScorer(EnablePhoenixScorerParam, …) and shares the same ModelWeights as Navi verified — PhoenixPredictedScoreFeature.scala:174-189.

The Transformer ranker did not replace the existing heavy ranker in one step. It first joined alongside as a rescorer switched on by a param, and then fully replaced the heavy ranker in 2026. verified — the evidence is that all 8 files were newly created in c54bec0 (git log --diff-filter=A) and the pipeline wiring above.

The 2025 Phoenix call specification is below verified — home-mixer/.../util/PhoenixUtils.scala.

  • Candidates are cut to 1400 (MaxCandidates = 1400 :36). There is also a config value TopLogProbsNum = 50 (:35).
  • Retries are 2, 500ms per attempt, 1 second in total (:119-121). The channel is picked at random again on every attempt (:108-109).
  • Because the response is a logprob (log probability), the probability is restored with math.exp(logProb) (:151-158).
  • Only 5 booleans travel with a candidate: isRetweet, isForYouPage (always true), isPromotedTweet (always false), isReply, isQuote :48-68.

The score combination formula and the full per-generation head lists are in multi-task-ranking.md. This formula does not multiply the negative heads but handles them with a threshold constant penalty, and finally compresses with Epsilon = 0.001.

There is a caveat in the 2025 code. There are two files named PredictedScoreFeature, with different head sets. The 15 in model/ are actually used, and the 17 in product/scored_tweets/scorer/ are legacy. Writing the head list from the latter is wrong. verified — import path in RerankerUtil.scala:4

Grok does not contribute arithmetically to the 2025 ranking score. It enters only through filters (4 kinds, including GrokGoreFilter) and topic tags. No Grok head appears anywhere in PredictedScoreFeatures. verified — repo-wide, ScoredTweetsRecommendationPipelineConfig.scala:543-546

There is no ranking boost for verified authors. All 10 files that reference AuthorIsBlueVerifiedFeature and similar are hydration, logging, caching, or marshalling. However, it does enter as an input feature of the trained model (GizmoduckFeaturesAdapter.scala:12-17). There is no rule-based bonus; the model uses it on its own. verified — repo-wide grep

↑ Contents

Re-ranking, diversity, and filters

As of the 2026-10-17 commit (862a123), X For You has 25 filters: 20 run before scoring and 5 run after candidate selection verified (filters section). As of the Gen4 release, diversity has three layers: author-diversity decay, VMRanker's DPP slate reordering, and conversation dedup verified (diversity section, Phoenix section). Since the 2026-10-14 commit (Gen4+41), VMRanker skips DPP diversity re-ranking by default for topic requests verified (Gen4+41 in the change log). As of the 2026-10-08 commit (Gen4+38), the final arrangement is not in score order: the blender inserts ads, prompts, follow recommendations, and the video carousel (off by default) in turn and pins push-to-home posts at the front verified (final arrangement section).

21 filters (2026-08, Gen4+1) — 18 before scoring + 3 after

As of the 2026-10-17 commit (862a123), 20 filters run before scoring, in the order below verified — phoenix_candidate_pipeline.rs:419-451. As of the 2026-08-14 commit (Gen4+1), there were 18. The evidence is README.md #filtering and phoenix_candidate_pipeline.rs.

  • DropDuplicatesFilter
  • ZeroFavCodivertFilter (new in Gen4+44, 2026-10-17)
  • CoreDataHydrationFilter
  • AgeFilter
  • SelfTweetFilter
  • OONRetweetReplyFilter (new in Gen4)
  • OONNsfwSimclustersFilter (new in Gen4)
  • RetweetDeduplicationFilter
  • IneligibleSubscriptionFilter
  • PreviouslySeenPostsFilter
  • PreviouslySeenPostsBackupFilter
  • PreviouslyServedPostsFilter
  • ViewerMutedKeywordFilter (replaced MutedKeywordFilter in the 2026-08-19 commit (Gen4+4))
  • AuthorSocialgraphFilter
  • Brazil2026ElectionFilter (new in Gen4+1, 2026-08-14)
  • VideoFilter
  • TopicIdsFilter
  • NewUserMinEngagementFilter (new in Gen4, replaced NewUserTopicIdsFilter)
  • InventoryHoldoutFilter (new in Gen4)
  • FavHoldoutFilter (new in Gen4+23, 2026-09-17)

As of the 2026-10-17 commit (862a123), the filters that run after selection are 5: VFFilter, AncillaryVFFilter, AncestorVideoFilter, DedupConversationFilter, and VideoCarouselFilter verified — phoenix_candidate_pipeline.rs:518-524.

  • Up to the 2026-08-14 commit (Gen4+1), there were 3, VFFilter, AncillaryVFFilter, and DedupConversationFilter, the same as Gen3 verified.
  • VideoCarouselFilter was added in the 2026-10-08 commit (Gen4+38) and AncestorVideoFilter in the 2026-10-13 commit (Gen4+40) verified — phoenix_candidate_pipeline.rs in 35650fb and e1a0375.

The first 4 of the bullets below are the filters newly introduced in the Gen4 release.

  • OONRetweetReplyFilter filters retweets and replies from accounts the user does not follow, as well as replies without a parent. It is separate from RetweetDeduplicationFilter.
  • OONNsfwSimclustersFilter was introduced together with the SimClusters revival. It filters posts from adult-content accounts that came from the SimClusters source out of the feeds of users who do not follow those accounts.
  • NewUserMinEngagementFilter removes OON posts to be shown to new users that fall below the engagement threshold. It has 6 related params.
    • NewUserMinEngagementThreshold, NewUserMinEngagementFilterMetric, NewUserMinEngagementFilterUseRatio, NewUserMinEngagementFilterMaxAccountAgeSecs, NewUserMinEngagementFilterMaxResurrectionAgeSecs, EnableNewUserMinEngagementFilter
  • InventoryHoldoutFilter holds out inventory at the 3-tier ratios through deterministic user×post hashing, and its params were confirmed in the 2026-09-17 commit (Gen4+23, 42266f3) verified — home-mixer/filters/inventory_holdout_filter.rs
    • The hashing goes through splitmix64-family double mixing and is split into 100-modular buckets.
    • There are 3 params, InventoryHoldoutOriginalsPercent, InventoryHoldoutRepliesPercent, and InventoryHoldoutRetweetsPercent, each clamped with .min(100).
    • The post type is determined by retweeted_tweet_id.is_some() → Retweet and in_reply_to_tweet_id.is_some() → Reply, and anything else is treated as Original. A quoted post is treated as Original.
    • The gate opens when EnableInventoryHoldout=true and at least one of the three values is greater than 0.
    • A retweet is decided against get_original_tweet_id() (the original), so it shares the holdout decision with the original.
  • FavHoldoutFilter was introduced in the 2026-09-17 commit (Gen4+23, 42266f3) and holds out by fav count tier. It is off by default with param EnableFavHoldout=false verified — home-mixer/filters/fav_holdout_filter.rs
    • The tier table is the following 23-tier one:
      • 10K+ fav = 15%, 5K+ = 15%, 3K+ = 15%, 2K+ = 14%, 1.5K+ = 13%, 1K+ = 13%, 750+ = 12%, 500+ = 11%, 300+ = 10%, 200+ = 9%, 150+ = 8%, 100+ = 7%, 75+ = 7%, 50+ = 6%, 30+ = 6%, 20+ = 5%, 15+ = 5%, 10+ = 5%, 7+ = 4%, 5+ = 4%, 3+ = 3%, 2+ = 3%, 1+ = 2%, ≤1 = 0%
    • The holdout decision reuses InventoryHoldoutFilter::is_held_out(post_id, viewer_id, percent) (the same splitmix64 hash), so it is deterministic per viewer.

Gen4+1 (2026-08-14, c65aa17) added one new filter.

  • Brazil2026ElectionFilter filters out posts tied to accounts that Brazil's Electoral Court (TSE) acted on in connection with the Brazil 2026 general election, unless the viewer follows the account verified — home-mixer/filters/brazil_2026_election_filter.rs
    • The account list is a FxHashSet<u64> with 665 accounts hardcoded.
    • The fields checked on a candidate are author_id, retweeted_user_id, quoted_user_id, and ancestor_users, and if any one of them is in the set, the candidate is targeted.
    • In the pipeline it sits right after AuthorSocialgraphFilter and right before VideoFilter. Another evidence location is phoenix_candidate_pipeline.rs:356-368.
    • The source materials are the Brazilian Electoral Court open dataset and X's announcement (@XBR, 2026-08-14). The README states: "in accordance with Brazilian electoral law, For You now runs Brazil2026ElectionFilter, which removes posts from accounts reported to Brazil's Electoral Court for the 2026 election, unless the viewer explicitly follows the account."
    • In the 2026-10-14 commit 17c96bf (Gen4+41), the list was cut to 25 accounts tied to candidates in the October 25 runoff (the relevant diff range went from 5,654 lines to 63 lines) verified. It cites TSE official letter numbers in comments as evidence verified — brazil_2026_election_filter.rs:17-74
    • The code leaves @username as a comment next to each account ID ("obfuscated ID + username alongside for transparency"). Examples are 40053694 // @FlavioBolsonaro, 21069302 // @marcelvanhattem, and 48298703 // @eduardopaes.
    • The follow exception is based on the user's personal following graph, so it is symmetric regardless of political spectrum. The decision formula is is_excluded_author(user_id, followed_user_ids) = LIST.contains(user_id) && !followed_user_ids.contains(user_id).
    • The list size is the code constant 665. The code has this unusual comment: "OmarAzizSenador deleted his account at the time this code was written."
    • This filter is the first case in which a jurisdiction filter is observed directly in code. Until now, responses to regulation such as DSA, GDPR, and NetzDG were seen mostly only in regulatory filings (transparency reports). If this code-release practice continues, similar filters can be confirmed in code for each country's election cycle.

The notable points among the filters are as follows:

  • AuthorSocialgraphFilter checks 6 conditions verified — filters/author_socialgraph_filter.rs:8-61
    • mute, block, author blocks me, quoted author blocks me, I block the quoted author, I block the retweet's original author
  • VFFilter is still fail-closed, but since Gen4+18 (2026-09-09, 75d93d9) the basis of the decision moved from matching FilteredReason variants to an explicit Action enum verified — home-mixer/filters/vf_filter.rs:16-21
    • should_drop_action classifies Drop(_), Tombstone, and NotEvaluated as drop, and Allow, Interstitial, Avoid, and Downrank as keep.
    • The upstream StratoVfClient converts non-SafetyResult reasons (mute, block, null-cast, exclusive, etc.) to Drop at the client layer, and converts NotEvaluated inside a SafetyResult to Allow verified — visibility-filtering-client/vf_client.rs:178-187. So on this path, a missing verdict is not dropped.
    • Conversely, the Rust VF client fills posts missing from the response with NotEvaluated, and VFFilter drops them verified — vf_client.rs:360-378. Fail-closed applies to missing results on this path.
    • As of the 2026-10-17 commit (862a123), the two handlings and should_drop_action are the same verified — vf_client.rs, vf_filter.rs.
  • In-network and OON get different safety levels (TimelineHome vs TimelineHomeRecommendations). Ancestor posts and quoted posts always get the stricter one verified — vf_candidate_hydrator.rs:62-67, 76-90
  • TopicIdsFilter is a 572-line file containing about 90 topic constants and a hand-built supertopic hierarchy. This part sits squarely against "remove all handcrafted features". verified — filters/topic_ids_filter.rs:109-515
  • PreviouslySeenPostsBackupFilter is permanently a no-op. The ImpressedPostsQueryHydrator that would fill its input is only constructed, bound to _, and not registered in the pipeline. It runs on every request but filters nothing verified — phoenix_candidate_pipeline.rs:234-236

Gen4+4 (2026-08-19, aad7179) added a mute keyword filter dedicated to the Following pipeline and the quoted-post text hydrator that supports it verified — home-mixer/filters/following_viewer_muted_keyword_filter.rs (65 new lines), home-mixer/candidate_hydrators/quoted_post_text_hydrator.rs (106 new lines). This commit continues the flow in which the Following pipeline branched to VFFollowingCandidateHydrator in the 2026-08-18 commit (Gen4+3).

  • FollowingViewerMutedKeywordFilter was split into a separate file from the existing MutedKeywordFilter (for the For You pipeline).
    • It tokenizes muted_keywords with TweetTokenizer and builds the matcher with MatchTweetGroup.
    • It filters a candidate if any one of three text sources matches: the candidate's tweet_text, quoted_tweet_text, or ancestor_texts.values().
    • This matching is CPU-bound work wrapped in tokio::task::block_in_place.
    • A peculiarity is the possibility that the existing For You filter looked only at tweet_text. The evidence is that the 2026-08-18 commit (Gen4+3) had no quoted-text wiring.
    • The mute targets have now widened to quoted posts and ancestors (reply ancestors). This extension narrows the route of bypassing a mute through quotes or replies.
  • QuotedPostTextHydrator is the hydrator that prepares this filter's input. For the filter to match quoted text, this field must be filled first.
    • For candidates with quoted_tweet_id, it does a bulk lookup with TESClient.get_tweet_core_datas and then fills the quoted_tweet_text field.
    • It removes duplicates with a HashSet and calls gRPC in batches.
  • Why it matters: Now For You and Following use different components in the filter and hydrator stack. Paired with the VF hydrator branch in the 2026-08-18 commit (Gen4+3), the Following pipeline is being rebuilt as a parallel structure rather than a subset of the For You pipeline inferred

Gen4+5 (2026-08-20, d0cef2f) added an "AI trend" feedback context channel to For You.

A second context channel opened alongside the existing topic feedback context (a context label for inferring a user's topics of interest) verified — home-mixer/candidate_hydrators/ai_trend_feedback_context_hydrator.rs (157 new lines). In the pipeline it is wired in right after TopicFeedbackContextHydrator, side by side with it verified — home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs:424-428. Another evidence location is home-mixer/util/urt/post_marshaller.rs:72-110.

  • The gate is 3-part. The switch EnableAiTrendFeedbackContext=false is off by default, and the other two conditions are !query.is_topic_request() and !query.in_network_only. That is, it does not overlap with topic surfaces or Following surfaces and targets only For You OON candidates.
  • Candidates are filtered by a 4-part standalone original post condition. In other words, the AI trend label is attached only to original posts, not to replies, retweets, posts with conversation ancestors, or targets that a followed account replied to.
    • The condition expression is in_reply_to_tweet_id.is_none() && retweeted_tweet_id.is_none() && ancestors.is_empty() && following_replied_user_ids.is_empty().
  • At most 2 AI trend labels are attached per page, and each trend is attached to only one candidate. This limit is a suppression device to avoid flooding the same label across multiple candidates of the same trend.
    • The selection approach is the top-2 trends and one random candidate per trend.
    • The trend_ids of the candidates are looked up with Strato batch_get_tweet_ai_trend, and the top 2 trends are picked from the frequency histogram (TOP_TREND_COUNT = 2). On a tie, the smaller trend_id takes priority.
    • For each trend, one candidate labeled with that trend is picked at random, and the display name is looked up with Strato batch_get_ai_trend_name.
  • At render time, topic feedback takes priority. In post_marshaller, topic wins if topic feedback exists, and AI trend enters as a fallback only when it does not.
    • Both go out in URT as type ContextType::TOPIC.
    • AI trend fills landing_url = "https://x.com/i/trending/{trend_id}" and TopicFeedbackContext.topic_id is None.
    • That is, tapping the chip goes to the trending surface rather than the topic feedback page.
  • Why it matters: This channel widens the context and signal sources upstream of ranking. The user's reaction of tapping, dismissing, or ignoring this chip becomes a label source for "does this user care about this AI trend".
  • Undisclosed: The following are not public:
    • The definition of AI trend (tweet clustering with Grok? human curation?)
    • The location of the batch_get_tweet_ai_trend upstream pipeline
    • Whether EnableAiTrendFeedbackContext is actually deployed

Diversity

X For You has three layers of diversity mechanisms.

  1. Author-diversity decay was applied inside the ranking scorer at the Gen4 release; since the 2026-09-24 commit (Gen4+28), the vm-ranker service computes it with the shared crate xai-value-model verified (final score calculation order in the Phoenix section).
  2. VMRanker DPP optimizes candidates as a set, and the service itself has been in vm-ranker/ since the Gen4 release verified (VMRanker paragraph in the Phoenix section).
    • Gen4+20 (6bb4594, 2026-09-12) added an optional seed_tweet_id argument.
    • If an embedding exists for this id, the seed is pinned to the first slot of the greedy DPP and filtered out of the final result, so it does not consume the top_k budget while only candidates similar to the seed receive a penalty.
    • In other words, the diversity axis can now extend from within the set to relative to a reference tweet.
    • Which production surfaces carry this field is not yet public.
  3. Conversation dedup keeps only the top-scoring one per thread verified — filters/dedup_conversation_filter.rs:6-48
    • The id that groups a conversation is conversation_id = min(ancestors) or the tweet itself.

The feed goes out without DPP even if the VMRanker call fails. Since the 2026-09-24 commit (Gen4+28), if gRPC fails it emits the local weighted-sum score and counts the failure with the VMRanker.local_score_fallback metric verified — vm_ranker.rs:107-113.

  • Before that (Gen3 to Gen4+27), it returned Err for every candidate, and the pipeline's update_all skipped the Err without logging. As a result, a score-ordered feed lacking DPP diversity went out with no indication verified — candidate-pipeline/scorer.rs (default update_all implementation), vm_ranker.rs:44-66

X made the mechanism that measures how many new topics the feed shows the viewer off by default. In the 2026-10-20 commit (Gen4+45), a path was added that counts the first and second SID levels of posts the viewer recently engaged with as topics and records, as a metric, the share of response posts whose topic is not in that history verified — side_effects/serendipity_stats_side_effect.rs.

  • History topic computation is off because the switch HistoryTopicsEnabled is false by default; when it is on, the metric is measured against a 28-day history verified — params/param.rs:508-529.
  • A switch that sends the same history topics in the VMRanker request was also added, off by default verified — same file. How VMRanker uses this value is not in the public code verified.
  • There is only a metric and no code that uses it for re-ranking verified. Given that measurement was added first, we infer this is preparation to test history-based topic exploration as a diversity goal inferred. Details are in daily 2026-10-21.

The 2025 Scala generation worked differently from today. MMR gave a reward for distance and the SimClusters category penalty gave a penalty for the same thing, and the two sat side by side, param-gated. There were six dedup filters. A comparison of the four approaches (decay, MMR, DPP, dedup) and the full per-generation list are in feed-diversity.md.

The final placement is not score order

The blender that handles final placement intentionally disables the framework's sorting verified. The evidence is home-mixer/selectors/blender_selector.rs:25-114. In this file's BlenderSelector, score() returns the constant 0.0 verified. The blender then runs the following stages.

  • Inserts prompts at positions 0..n verified — same file
  • Inserts Who-to-Follow (WTF) at designated positions verified — same file
  • Pins the push-to-home post always at position 0 verified — same file
  • Runs the ads blender. As of the 2026-10-17 commit (862a123), AdsBlenderType picks one of partition_organic_low_risk, safe_gap, multi_risk, and time_gap, and the default has been multi_risk since the 2026-09-01 commit (Gen4+13) verified — blender_selector.rs:55-72, params/param.rs:817
  • Since the 2026-10-08 commit (Gen4+38), inserts the video carousel module, off by default, at the VideoCarouselPosition (default 6th) slot. The execution order is ads blending → prompt → WTF → carousel → push-to-home pinning → frame → survey verified — home-mixer/selectors/blender_selector.rs:72-85, 137-153

↑ Contents

Cold start

Cold start is the question of how recommendations treat users, authors, and posts that have not yet accumulated history or impressions. As of the Gen4 release, the X For You code had mechanisms for new users and new authors, but none for individual new posts verified (sections New users, new-author boost, and New items). Since the 2026-09-30 commit (Gen4+32), the Phoenix retrieval cold slice, which collects new posts with few impressions and likes separately, has been on, and the cold-start slot selector picks only posts with fewer than 200 home impressions verified (section New items). The new-author boost on in-network posts of new authors has been 0.015 in the home-mixer mirror and 0.02 in the VM-Ranker mirror since the 2026-10-07 commit (Gen4+37) verified (change log Gen4+37); Thompson sampling in the cold-start slot selector and the cold-start slice of Phoenix retrieval came in off by default and were turned on together in the commit that tuned the home-mixer cold-start parameters to production values verified.

New users (account level)

New users whose history length is below a threshold are sent to a separate model fleet for both ranking and retrieval verified. The evidence is phoenix_scorer.rs:29-43 and phoenix_source.rs:25-39. The threshold is Phoenix*NewUserHistoryThreshold, and the fleet these users go to is Phoenix*NewUserInferenceClusterId.

  • In the 2026-09-18 commit (Gen4+25), the Phoenix retrieval cold-start slice was wired in as first-class verified — home-mixer/sources/phoenix_source.rs.
    • This slice is distinguished by the dataset_type axis carried in the Phoenix retrieval response.
    • Candidates tagged HOME_COLD_DATASET_TYPE=13 have their served_type mapped to ForYouPhoenixRetrievalCold.
    • record_phoenix_retrieval_cold_stats emits the PhoenixRetrievalColdTweets.* stat block.
    • A separate cap param for this slice, PhoenixColdStartMaxResults, was introduced at 0 (off by default).
    • Unlike the routing above, which splits model fleets by the user's history threshold, this slice splits the candidate set returned by the same Phoenix retrieval RPC using the dataset_type tag.
    • When introduced, it was off by default, so it had no production share.
    • This cap was later turned on at 200 in the commit that tuned the home-mixer cold-start parameters to production values verified — params/param.rs:16.
  • As of the Gen4 release, the new-user routing threshold is NewUserAgeThresholdSecs = 0 verified. Because it is 0 seconds, we infer the new-user path is mirrored as effectively disabled inferred.
    • This param was dropped from the home-mixer param.rs in the 2026-09-24 commit (Gen4+28), and it is also 0 in vm-ranker/params.rs as of the 2026-10-17 commit (862a123) verified.
  • NewUserMinEngagementFilter, the OON engagement-threshold filter new in Gen4, removes OON posts that fall short of the engagement threshold.
  • NEW_USER_OON_WEIGHT_FACTOR, NewUserTopicIdsFilter, and EnableNewUserTopicRetrieval, which existed in Gen3, are not in the Gen4 param.rs. A deep dive is needed.

New authors (account level)

New authors are identified by impression count, and their in-network posts get the new-author boost verified (new-author boost section of ranking). The boost and threshold params were documented in Gen4, and the full set of boost params is in the table in that section.

  • The new-author boost has been 0.015 in the home-mixer mirror and 0.02 in the VM-Ranker mirror since the 2026-10-07 commit (Gen4+37). It applies only to in-network posts.
    • The value documented in Gen4 is PostUnexploredWeight = 0.02.
  • The Gen4 README describes authors whose impressions are below a threshold as "new authors", and the threshold param at that time was ColdStartImpressionThreshold = 1000. The code applies this threshold to the home impression count of the candidate post, not to the author verified (new-author boost section).

Cold-start slot selector

The cold-start slot selector chooses "which candidate fills a slot" within the set of cold-start target candidates. In the 2026-08-14 commit (Gen4+1), this selection approach was split into two branches, greedy max-score and Beta-reward Thompson sampling verified — home-mixer/scorers/author_cold_start.rs:151-190. Thompson sampling was off by default at first, and it has been on by default since the commit that tuned the home-mixer cold-start parameters to production values verified.

The param definitions and the defaults as of Gen4+1 are in param.rs verified — home-mixer/params/param.rs:687-724.

param default as of meaning
EnableColdStartThompsonSampling true commit that tuned the home-mixer cold-start parameters to production values Switch that decides whether to pick slots with Thompson sampling instead of greedy
ColdStartBetaAlpha0 0.75 2026-08-14 commit (Gen4+1) α of the prior Beta(α₀, β₀). Prior observations of likes
ColdStartBetaBeta0 49.25 2026-08-14 commit (Gen4+1) β₀. Prior observations of non-likes. The sum of the two values, 50, is the "prior effective sample size"
ColdStartTsTopK 5 2026-08-14 commit (Gen4+1) Pick the top K by Beta sampling, then tie-break by the actual score
ColdStartImpressionScale 1.0 2026-08-14 commit (Gen4+1) view_count scale. When sampling, n = scale × view_count
ColdStartTrackedIds "" 2026-08-14 commit (Gen4+1) Comma-separated list of account/post IDs. Targets for serving-stats instrumentation

EnableColdStartThompsonSampling is a switch added for A/B. Its default in the 2026-08-14 commit (Gen4+1) is false, and the default is still greedy (current behavior unchanged). That it is off by default matters. At this release point (2026-08-14), the cold start shown to real users is still max-score greedy, and for Thompson only the A/B infrastructure is wired up.

  • Thompson sampling draws one sample per candidate from the posterior, picks the top K, and then picks the candidate with the highest original score among them.
    • For each cold-start candidate i, set n_i = scale × view_count_i and x_i = min(fav_count_i, n_i).
    • The posterior is Beta(α₀ + x_i, β₀ + max(n_i − x_i, 0)), and one sample is drawn from it.
    • In other words, it does Bayesian exploration (uncertainty about which candidate will be good), but the final choice is decided by tie-break on the original score.
  • α₀=0.75, β₀=49.25 set a prior of "roughly 0.75 likes per 50 impressions". The values are meant to let new authors whose measured like rate is higher than this be picked generously.
  • As of the 2026-08-14 commit (Gen4+1), the instrumentation hook record_tracked_ids is an individual observation hook that lets A/B manually watch specific accounts.
    • This hook counts how many times a tweet_id/author_id registered in ColdStartTrackedIds appeared from which source of the cold-start pipeline.
    • The counter is home_mixer.cold_start_tracked_ids_total{id, source}.
  • In the 2026-08-18 commit (Gen4+3), the aggregation key of this instrumentation was simplified verified — home-mixer/scorers/author_cold_start.rs:92-108.
    • The changed function is count_tracked_ids.
    • Previously, tweet_id and author_id were aggregated as separate entries.
    • Afterward, a candidate whose tweet_id or author_id is in the tracked list is counted under its single tweet_id. The separate author_id entry is gone.
    • The tracked-list filter remains, so if the list is empty nothing is counted. The function in the 2026-10-17 commit (862a123) is the same verified — author_cold_start.rs:151-185.
  • In the 2026-09-04 commit (Gen4+16), the caller of record_tracked_ids and the param wiring were confirmed verified — home-mixer/scorers/author_cold_start.rs. This tracked ID observation hook is an always-on observation channel independent of A/B.
    • ColdStartParams::read(query) reads all params at once and puts them in params.tracked_ids.
    • record_tracked_ids(candidates, &params.tracked_ids) is always called regardless of EnableViewerColdStart gating.
  • The same 2026-09-04 commit (Gen4+16) pinned the impression symmetry of the codivert experiment with a regression test verified — same file.
    • A pair of YAML rules was added so that the two arms of the new experiment moe_codivert_viewer split symmetrically as enable_viewer_cold_start_boost=true/false.
    • The experiment params are PhoenixMoeCodivertViewerIsControl and PhoenixMoeCodivertViewerIsTreatment.
    • apply() changed its execution order to call viewer_arm(query) and author_corpus(...) before the enabled gating.
    • Each of the two arms leaves exactly 3 moe_codivert_viewer bucket impressions and 2 moe_exp bucket impressions, which target authors. The tests both_arms_log_the_same_codivert_impressions and both_viewer_arms_log_the_same_author_impressions pin this.
    • Triggered-analysis compares only users who entered the pipeline, while intent-to-treat compares all assigned users. A methodology that guards against the mismatch between the two definitions with a regression test came in as code.
  • In the same 2026-09-04 commit (Gen4+16), the candidate filter of the Treatment arm was narrowed to apply only to Phoenix MoE candidates verified — same file.
    • The condition && is_phoenix_moe(c) was added to cold_start_corpus_eligible(Treatment).
    • As of this commit, the cold-start candidates of a Treatment viewer were only those that are posts by treatment corpus authors and have served_type == ForYouPhoenixRetrievalMoe.
    • The test name also changed to treatment_viewer_cold_starts_treatment_moe_only.
    • Cold start and Phoenix MoE are bundled into one codivert experiment and isolated so that the two effects are not confounded.
    • This MoE condition was removed from the Treatment branch in the 2026-09-17 commit (Gen4+23), and cold_start_corpus_eligible in the 2026-10-17 commit (862a123) has no MoE condition verified — author_cold_start.rs in 42266f3 and 862a123.
  • In the same 2026-09-04 commit (Gen4+16), engagement counts hydration was separated from cold-start gating verified — home-mixer/candidate_hydrators/engagement_counts_hydrator.rs.
    • enable() drops the dependency on EnableViewerColdStart and decides whether to turn on solely by EnableEngagementCountsHydration || is_shadow_traffic.
    • It also removed the !query.has_cached_posts gating of enable(). The has_cached_posts branch on the side that decides per candidate whether to look up remained until the 2026-10-01 commit (Gen4+33) and was removed in the 2026-10-02 commit (Gen4+34) verified — engagement_counts_hydrator.rs:123, same file at 76843a5.
    • The tight coupling of the initial Gen4+1 (2026-08-14) wiring, where cold-start Thompson sampling depended on view_count_on_home and fav_count, is loosened.

New items (post level)

As of the Gen4 release, there was no separate mechanism that boosts a new post itself regardless of its author verified. As of the 2026-10-17 commit (862a123), two mechanisms work on post-level conditions.

  • In the 2026-09-24 commit (Gen4+28), HOME_COLD (13) and HOME_HOT (14) datasets were added to the Phoenix retrieval training data. HOME_COLD keeps only recent posts with fewer than 500 views and fewer than 8 likes verified — retrieval_dataset.py:280-281, cold_pool_filter.py:26-46.
    • The home-mixer has separated retrieval candidates carrying this tag (13) as a cold slice since the 2026-09-18 commit (Gen4+25), and has accepted up to 200 since the 2026-09-30 commit (Gen4+32) verified (current candidate generation).
  • The cold-start slot selector picks one of the original posts whose author has 50,000 followers or fewer, that was posted within the last 2 hours, and that has fewer than 200 home impressions, and raises its ranking verified — author_cold_start.rs:296-309, params/param.rs:507-531.
    • This selector has used the candidate post's impression count as a condition since the Gen4 release, and the threshold then was 1000 (new-author boost section).

The hash embedding of a new post is the value the continually trained table currently holds, and for an ID seen for the first time it is the average of other posts mixed in by hash collisions inferred.

The 2025 Scala code had SimclusterColdPostsCandidateSource.scala. Because SimClusters was revived in Gen4, whether this source is alive in Gen4 simclusters/ needs a deep dive (open questions).

↑ Contents

Safety / visibility-filtering / labeling pipeline (released in full in Gen4)

The Gen4 release (2026-08-13) published the whole path by which X creates safety labels and uses them to decide post visibility. According to the Gen4 README, this path has four stages: content understanding, labeling rules, storage, and visibility filtering (VF) verified (4-stage Labeling Path). As of the 2026-10-15 commit, VF uses five SafetyLevels verified (2026-10-15 commit in the change log).

Through Gen3, VFFilter and AncillaryVFFilter were applied fail-closed in home-mixer/. However, who attaches the labels those filters consult, and when and how, was a complete black box. The Gen4 release added 15 new visibility-filtering and labeling sub-repos verified (released source Gen4).

4-stage Labeling Path verified — README.md #labeling-path

Stages 1–3 create and store labels, and stage 4 makes decisions with those labels on the request path verified.

Content Understanding → Labeling Rules → Storage → Visibility Filtering

Stage 1 (Content Understanding) is a nearline stage that runs outside the request path. Stage 1 of the README diagram lists six: grox, media-model-proxy, clip, agatha, bdsm, and user-cred-v2 verified. The related sub-repos are in the table below.

Sub-repo Target Role
grox/ Posts, media Grok-based nearline classifier. Also partially released in Gen3
media-model-proxy/ Images, video Serving proxy for NSFW, violence/gore, hate symbols, topics, and known-media matching
clip/ Images, text CLIP embedding model training (input to media classifiers)
adult-content/ Adult media Classifier training and calibration
pnsfwmedia/ Adult media Discriminator combining CLIP embeddings and account scores
agatha/ Accounts Offline batch. Labels based on how other accounts reacted to this account's posts (favorite ratio against blocks and reports), and spam-suspension and adult labels
bdsm/ Accounts Extracts inauthentic/abusive signals from time-series behavior sequences (Rust, proto, training, tests subdirectories)
user-cred-v2/ Accounts Account trust score computed with PageRank over follow and engagement edges

Stage 2 (Labeling Rules) turns events and scores into labels verified.

  • scarecrow/ is a rule runner that runs in response to events, and it embeds botmaker/ as the rule engine verified.
  • botmaker/ is the rule DSL, compiler, and runtime verified.
  • botmaker-rules/ holds the actual rules that scarecrow loads verified. According to the README, some were not released to prevent gaming verified.
  • abuse-enforcement-service/ takes account actions (labeling, challenge, suspension) based on model scores verified.
  • safety-label-user-agg/ aggregates labels of posts owned by an account to the account level verified.

In stage 3 (Storage), labels are written to storage and read on the request path.

Stage 4 (Visibility Filtering) runs on the request path verified — visibility-filtering/rules/registry.rs. The client library is in visibility-filtering-client/ verified.

As of the 2026-10-15 commit (92f0918), there are five SafetyLevels, and the number of rule constant entries per level is as follows verified — visibility-filtering/rules/levels.rs.

SafetyLevel Rule constant entries (as of the 2026-10-15 commit)
FILTER_ALL 1
TIMELINE_HOME 39
TIMELINE_HOME_RECOMMENDATIONS 65
TIMELINE_HOME_HYDRATION 87
IMMERSIVE_EXPANDED_RECOMMENDATIONS 47

Since the 2026-09-22 commit (Gen4+26), the verdict has been a tree split into withhold and display, rather than the three-way Allow/Interstitial/Drop verified — visibility-filtering/models/verdict.rs (Gen4+26 row of the commit summary table).

  • The withhold side is Drop and Tombstone.
  • The display side has media and engagement slots, and a notice slot was added in the 2026-10-06 commit (Gen4+36) verified — verdict.rs (Gen4+36 in the change log).
  • Before that, the 2026-09-09 commit (Gen4+18) changed the verdict axis from FilteredReason to a flat Action enum (Gen4+18 in the change log).

At the Gen4 release (2026-08-13), there were three SafetyLevel policies verified — Gen4 registry.rs.

  • FilterAll drops everything verified.
  • TimelineHome is the policy for following targets and consists of 28 base rules verified.
    • The 28 rules of base_home_rules() consist of 5 account-state rules, 3 viewer-relation rules, 8 tweet-label rules, 4 tweet-state rules, 3 NSFW viewer-gating rules, 1 excluded-tweet rule, and 4 NSFW interstitial rules verified.
  • TimelineHomeRecommendations is the policy for recommendation targets and consists of 54 rules, the 28 base rules plus 26 OON-only rules verified.
    • The 26 OON-only rules consist of 2 DMCA/geo-restricted media rules, 4 NSFW account rules, 9 rules for tweets labeled NSFW/violence/DoNotAmplify/spam, and 11 NSFW/spam/impersonation/deactivated/adult user-label rules verified.

In the Gen4 registry.rs, evaluation ends at the first drop verdict (short-circuit) verified. The OON-only rule group can only drop and issues no interstitial or allow verdict verified. The verdicts are three: Allow, Interstitial(reason), and Drop(FilteredReason) verified. Interstitials are handed off to UI handling, and this repo has no code that draws interstitials verified.

Verifying canon caveat list #1 and #2 (partial)

Both items were partially verified, and each leaves one question that needs a deep dive.

  • VFFilter fail-closed behavior: The Gen3 observation that VFFilter unconditionally drops every FilteredReason variant that has no verdict is still valid. How the visibility-filtering/ sub-repo handles a return without a verdict (error, timeout) needs a deep dive.
  • Different safety levels for in-network and OON: The Gen4 Policies struct exposes the three levels FilterAll, TimelineHome and TimelineHomeRecommendations as they were. Whether the Gen3 rule of applying the stricter level to ancestor and quote posts (vf_candidate_hydrator.rs) still holds in Gen4 needs a deep dive.

Under the Hood transparency tool — introduced alongside Gen4 verified

Under the Hood is a transparency tool where users look up the visibility-impacting labels attached to their own account and posts verified. A daily batch collects the labels, and the serving layer aggregates them verified. X stated that the purpose is to let users audit "why was my post treated this way" by checking against the code in this repo verified.

↑ Contents

Feedback signals

The X recommendation code uses positive actions such as like, reply and repost and negative actions such as not interested, block, mute and report as feedback signals, and production also counts not dwelling on a post as a negative signal verified. Phoenix's history encoding feeds an action not taken as −1 rather than 0, and masks history entries that have no action at all verified. Already-seen posts are handled in six layers, and the user-side features include demographics such as age and inferred gender verified. In the commit that introduced the vertical video carousel module as off by default, the Phoenix feature that feeds history dwell time as a bucket embedding became on by default verified (change log).

  • Positive signals are actions such as like, reply, repost, quote, click and dwell verified — phoenix/runners.py:233-253
    • The full list is favorite, reply, repost, quote, click, profile_click, photo_expand, video quality view, share (including DM and link copy), dwell, dwell_time and follow_author.
  • Negative signals are not interested, block author, mute author and report, and production counts even not_dwelled (did not dwell) as a negative signal verified — ranking_scorer.rs:83
    • The identifiers of the four actions are not_interested, block_author, mute_author and report.
  • In the history encoding, no action is −1, not 0 verified — phoenix/recsys_model.py:404-432
    • The formula is actions_signed = 2*actions - 1.
    • That is, "did not like this post" enters as an explicit negative signal.
    • However, a history entry with no action at all is masked as a whole entry verified — same file.
  • Already-seen posts are handled in six layers verified — each file in query_hydrators/ and side_effects/
    • The six layers are the seen_ids sent by the client, the impression bloom filter, the server-side served history (last N minutes/M items), the Redis candidate cache, the Phoenix request cache and the URT cursor.
  • Impressions reported by the client are sent back out to Kafka verified — side_effects/publish_seen_ids_to_kafka_side_effect.rs
  • User-side features include demographics such as age, inferred gender and IP location verified — home-mixer/models/query.rs:24-95
    • The fields included are user_age_in_years, user_inferred_gender, ip_location and subscription_level.
    • The topic and starter pack fields are followed_grok_topics[32], inferred_grok_topics[32] and followed_starter_packs[20].
    • user_inferred_gender has a dedicated GenderPredictionGrpcClient.
    • The hydrator-side evidence is phoenix_candidate_pipeline.rs:210-231.

↑ Contents

Ads

In the public code, X For You inserts ads between organic posts and decides ad slots by looking at the brand safety grade of the posts next to an ad verified. The default ads blender has been the multi-risk blender since the Gen4+13 commit (2026-09-01, 7ba7768) verified (Gen4+13 in the change log). This blender places no ad next to a HighRisk post, places next to a MediumRisk post only ads whose advertiser allows that context, and gives such ads the MediumRisk slots first verified (Gen4+10 in the change log). As of the 2026-10-15 commit, the V2 verdict switch for the brand safety verdict and the fallback switch that substitutes the author-level label when there is no post-level Grok safety label are both on by default verified (2026-10-15 commit in the change log).

Ads blending and brand safety verdict

The public code contains the entire ads blending. This is the unexpected part of this release.

  • At least 5 organic posts are required before ads can be attached verified — home-mixer/ads/util.rs:10
  • The default ad spacing is {requested: 3, min: 2}, and the minimum requested spacing is 3 verified — ads/util.rs:12-17
  • Ads are inserted only up to half of the "safety gap" verified — ads/partition_organic_blender.rs:35
    • The formula is max_from_safe = safe_count / 2.
  • If the last item is an ad, it is trimmed verified — ads/util.rs:164-198
  • Brand safety is an adjacency verdict. The partition_organic_low_risk blender, the default until the 2026-09-01 commit (Gen4+13), attaches no ad next to MediumRisk or HighRisk posts (has_avoid() treats both tiers as avoid). The multi_risk blender, the default since Gen4+13, first tries to place a BSR-high ad between two MediumRisk posts and, if it fails the overlap check, moves it to a Safe slot verified — multi_risk_blender.rs:68-179. The verdict is made from a hand-written rule cascade plus whether Grok scored the post and whether PTOS reviewed it verified — home-mixer/models/brand_safety.rs:5-13, 15-22, 45-48
    • The 2026-08-24 commit (Gen4+7, d011592) introduced the BrandSafetyVerdict::HighRisk = 4 tier verified — home-mixer/models/brand_safety.rs
      • Five labels previously in medium and the new SOFT_NSFW were promoted to HIGH_RISK_LABELS. v1 and v2 are symmetric.
        • The five are NSFW_HIGH_PRECISION, NSFW_HIGH_RECALL, GORE_AND_VIOLENCE_HIGH_PRECISION, PDNA and EGREGIOUS_NSFW.
      • The NSFW ancestor-inheritance escalation in ads_brand_safety_vf_hydrator.rs changed from MediumRisk to HighRisk.
      • scored_posts_server.rs added the proto HM enum mapping for five V2 labels (downstream exposure begins).
        • The five are GROK_SFA_V2, GROK_NSFA_LIMITED_V2, GROK_NSFA_V2, GROK_NSFA_EXPANDED_V2 and PTOS_REVIEWED.
  • If the quote post lookup fails, the verdict is pessimistically demoted to MediumRisk verified — ads/../ads_brand_safety_hydrator.rs:144-145
  • Gen4+2 (2026-08-17, b089ce6) wired in the V2 verdict function side by side, and Gen4+3 (2026-08-18, 11a71f8) redesigned its semantics a day later.
  • The Gen4+2 original (b089ce6, 2026-08-17) added two label lists for V2, a migration helper and a verification test verified — home-mixer/models/brand_safety.rs
    • MEDIUM_RISK_LABELS_V2 has 16 entries. Compared with V1, GROK_NSFA was renamed GROK_NSFA_V2, GROK_NSFA_EXPANDED_V2 was introduced, and the list includes NSFA_HIGH_PRECISION.
    • LOW_RISK_LABELS_V2 has 3 entries. Compared with V1, GROK_NSFA_LIMITED was renamed GROK_NSFA_LIMITED_V2, and the list includes NSFA_LIMITED_INVENTORY.
    • The migration helper strip_v1_grok_written removes from the input label set, before the V2 verdict, any label that meets both conditions.
      • The first condition is that the label is NSFA_HIGH_PRECISION or NSFA_LIMITED_INVENTORY.
      • The second condition is that the label's botmaker rule id is one of the seven in PROMPT_OWNED_RULES = [1400, 1410, 1420, 1500, 1510, 1610, 1700].
      • That is, the V2 logic ignores labels dual-written by the V1 prompt and decides from pure V2 labels only.
    • The verification test v2_mirrors_v1_across_tier_matrix asserts that the V1 and V2 verdicts must match exactly across 6 label combinations × two tweet_id points in time (before/after PTOS_CUTOFF).
  • The Gen4+3 redesign (11a71f8, 2026-08-18) deleted this helper and added a gate that decides with V2 only when at least one V2-written label is present and otherwise falls back to V1 verified — home-mixer/models/brand_safety.rs
    • strip_v1_grok_written was deleted entirely (even the Cow import was removed). That is, the seven-rule-id PROMPT_OWNED_RULES logic that the canon update folded in yesterday was discarded within a day.
    • A new constant V2_WRITTEN_LABELS was added instead. It has 4 entries: GROK_SFA_V2, GROK_NSFA_V2, GROK_NSFA_LIMITED_V2 and GROK_NSFA_EXPANDED_V2.
    • The new gate checks at the start of compute_verdict_v2 whether any of V2_WRITTEN_LABELS is in the label set. If none is, it falls back to compute_verdict(v1), and if one is, it decides from the pure V2 label set.
    • NSFA_HIGH_PRECISION was removed from MEDIUM_RISK_LABELS_V2, and NSFA_LIMITED_INVENTORY from LOW_RISK_LABELS_V2. That is, once V2 gives a verdict, the V1 heuristic labels are ignored.
    • The new test v2_defers_to_v1_when_v2_has_not_ruled explicitly asserts the decisive case.
      • In particular, for the label set NSFA_HIGH_PRECISION + GROK_NSFA + GROK_SFA_V2, the V1 verdict is MediumRisk and the V2 verdict is Safe.
      • That is, once V2 sends a verdict signal (GROK_SFA_V2 alone), the V2 decision governs and V1's traditional heuristics are ignored entirely.
    • Interpretation: The dual-write migration strategy moved from "filter specific rule ids when V2 decides" to "use the V2 decision if V2 has ruled, otherwise fall back to V1." The latter is much cleaner dual-write semantics. Posts the V2 labeler has not yet reached stay covered by the V1 safety net, and posts it has reached are fully replaced by V2.
    • Undisclosed: When the V1 verdict function will finally be removed, the V2 labeler coverage, and the origin of the four V2_WRITTEN_LABELS labels (a Grok prompt change? a pipeline retraining?)
  • The Gen4+7 commit (d011592, 2026-08-24) introduced the HighRisk tier in the brand safety verdict verified — home-mixer/models/brand_safety.rs
    • A fourth grade, BrandSafetyVerdict::HighRisk = 4, was added on top of the existing Unspecified/Safe/LowRisk/MediumRisk. Because the enum discriminant is assigned 4, the >= comparison in worst_verdict() extends automatically.
    • Five labels were promoted from medium to high. They are NSFW_HIGH_PRECISION, NSFW_HIGH_RECALL, GORE_AND_VIOLENCE_HIGH_PRECISION, PDNA and EGREGIOUS_NSFW. One new label, SOFT_NSFW, is also HighRisk. In both V1 (compute_verdict) and V2 (compute_verdict_v2), the HighRisk check moved to the top.
    • V2 changed symmetrically too. The same five labels were also removed from MEDIUM_RISK_LABELS_V2. V2 originally had NSFW_HIGH_PRECISION, NSFW_HIGH_RECALL, GORE_AND_VIOLENCE_HIGH_PRECISION, PDNA and EGREGIOUS_NSFW.
    • The ads util treats both tiers as avoid. has_avoid() in home-mixer/ads/util.rs was extended to a MediumRisk | HighRisk matches (previously only MediumRisk).
      • That is, in the adjacency verdict, no ad is attached next to a HighRisk post, exactly as with MediumRisk.
      • There is no further action yet, and the severity may be for flagging or logging purposes.
    • Quote adjacency was also promoted. ads_brand_safety_vf_hydrator.rs escalates the verdict to HighRisk, not MediumRisk, when the quoted ancestor post has an NSFW author.
    • Downstream exposure began. Five V2 labels were added to the safety_label_to_proto mapping in scored_posts_server.rs.
      • The five are GROK_SFA_V2, GROK_NSFA_LIMITED_V2, GROK_NSFA_V2, GROK_NSFA_EXPANDED_V2 and PTOS_REVIEWED.
      • These labels are now actually carried out in the feed response proto. Previously, V2 labels were used only in the internal verdict and were not sent downstream.
    • DDG experiment tracking was added. ads_injection_logging_side_effect.rs exposes the EnableAdsBrandSafetyVerdictV2 experiment bucket in logging in the form ddg_experiment_bucket = "experiment_id:bucket".
      • DDG passes to the ads reporting pipeline (presumably Data Dashboard or a similar system inferred), which makes it possible to observe revenue and safety metrics for the V2 experiment group.
      • Why it matters: It indirectly shows that V2 is still at the experiment stage (experiment bucketing). Gen4+2 introduced only the V2 verdict function, Gen4+3 redesigned the semantics, and Gen4+7 put the experiment measurement infrastructure in place.
    • Undisclosed: What follow-up actions the HighRisk tier triggers beyond ads avoid (strong ranker decay, complete blocking of organic impressions, etc.) and the holdout/treatment ratio of the V2 experiment

Ads conversion action expansion — Gen4+6 (2026-08-21, 28e414f)

The Phoenix ranker's ads revenue prediction heads grew by 20 in the 2026-08-21 commit (Gen4+6), and the training loss was reorganized verified. After that, the Gen4+9 commit (2026-08-26, 45b48ba) added three pixel-fire-related heads, and the 2026-09-17 commit (Gen4+23) added 10 each of tier 2 and tier 3 to the delayed conversion actions verified.

Before Gen4+6, the ads conversion family effectively had only a few members (install and click-attributed conversions). The Gen4+6 commit expanded it as follows.

20 ActionNames were introduced in the proto verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto

  • 6 MACT kinds × click/view = 12 (171–182) were added.
    • MACT is Mobile App Conversion Tracking. It attributes in-app events after an ad click or impression (purchase, add to cart, level achieved, tutorial completed, sign-up, custom) to the ad.
    • Each event has two versions: click-attributed (MACT_<event>) and view-attributed (MACT_VIEW_<event>).
  • 8 view-through conversions (183–190) were added.
    • The targets are purchase, add_to_cart, checkout_initiated, sign_up, site_visit, session, landing_page_view and upper_funnel.
    • Each is a view-through head corresponding to the existing click-attributed one (ADS_PURCHASE_CONVERSION = 200, etc.).

The engagement metric group changed verified — phoenix/xrex/data/recsys/constants.py

  • Added 12 new MACT mappings and 8 view-through mappings to ads_conversion_engagement_to_action_types.
  • The new metric group ads_slim trains only 3 positive signals, 4 negative signals and non-primary conversion clicks. It is the slim signal combination of the ads ranking line.
    • The positive signals are open link, external link long dwell and video quality view.
    • The negative signals are not interested, block, report and mute.
  • CLIENT_TWEET_SEARCH_QUERY_REFORMULATED was added to NEGATIVE_FEEDBACK_HEAD_INDICES. When a user rewrites the query because they dislike the search results, it is treated as negative feedback. This is in the same direction as the xrecsys_search config.

A delayed feedback training branch was added verified — phoenix/xrex/models/recsys_model.py

There are four new config options. The defaults in the table are as of the Gen4+6 commit.

Option Default Meaning
split_head_training_by_source False Separates delayed feedback samples from fresh samples in the loss mask (engagement heads train on fresh only, conversion heads on delayed only). condition_conversion_on_click=True is an assert precondition. In Gen4+24 (2026-09-18) this option was unified into ads_head_masking and deleted.
train_view_through_heads False Turns on training of the 8 view-through heads. If False, the view-through loss mask is 0. Unified into ads_head_masking and deleted in Gen4+24.
mact_in_app_loss_weight 1.0 Loss weight applied only to the 12 MACT actions. Tunes the training intensity of the ads conversion heads
metric_mask_keys None Filters the training observability metric slices

There are two new batch fields.

  • sample_source (bool) comes from the is_delayed_feedback column of the kafka record. If True, this sample is a conversion delayed re-observation.
  • conversion_keep_mask (bool per-candidate) is the kafka conversionKeepMask column. If False, this candidate is excluded from conversion training.
    • In sequence packing, only all(True) is allowed (when not linked).

The new metric slices are fresh (delayed=0), delayed_clicked (delayed=1 & clicked=1) and delayed_non_clicked (delayed=1 & clicked=0). Because condition_conversion_on_click is kept in delayed training as well, even a delayed sample trains conversion under the condition "was there a click."

In the 2026-09-18 commit (Gen4+24), three options were unified into a single ads_head_masking: bool = False. The three options are split_head_training_by_source, train_view_through_heads and condition_conversion_on_click. The three slice names (fresh, delayed_clicked, delayed_non_clicked) are kept, and a new slice late_split_no_early was added. Sample source is a three-valued categorical (FRESH=0, EARLY_RELABEL=1, LATE_SPLIT=2). The twin head mapping is summarized in Gen4+24 in the change log.

Why it matters: The Gen4+6 commit attached two things to ads conversion prediction at once. One is a large increase in the number of heads (20 introduced), and the other is infrastructure that formally branches delayed feedback training. This is a signal that X ranker training is explicitly aligned with the standard treatment in the ads delayed conversion prediction literature (since Chapelle 2014). Separate from the ranking signal channel, SID and SlateContext extension axes that the canon has folded in through Gen4~+5, the ads revenue prediction stack was added as a new axis.

In Gen4+9 (2026-08-26, 45b48ba), three combined pixel-fire and long-dwell heads were added and conv_asset_ids was wired in verified — phoenix/python/common/xai-proto/proto/recsys.proto (+12)

  • Other evidence files are phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto and home-mixer/models/candidate.rs (+8).
  • Three new ActionNames (191–193) were added.
    • 191 ADS_PIXEL_FIRE is the firing of a conversion pixel (web tracking pixel) that an advertiser planted on their own site or app. It directly predicts web conversion events.
    • 192 ADS_LONG_DWELL_AND_PIXEL_FIRE combines pixel fire and long dwell (staying long on the landing page) with AND. That is, it is the case where the pixel did not merely fire but the user actually stayed long.
    • By the Gen4+6 convention, the prefix P_ of 193 P_LONG_DWELL_AND_PIXEL_FIRE suggests a primary or predicted target family inferred.
  • A ConvAssetIds message was introduced and wired in as the field PredictNextActionsRequest.conv_asset_ids = 18.
    • It consists of three arrays: impressed_time_ms (int64), author_id (int64) and asset_id (int64).
    • It passes to Phoenix serving, along with the request, the history of ad creatives (assets) the user was recently shown.
    • This history is presumably consulted in conversion prediction when an ad asset is reused across several advertisers or when a user saw the same asset several times inferred.
  • Two Option<u32> fields (SlateContext.recon_count_above, recon_gap_above) were added to the home-mixer SlateContext struct.
    • These fields break down the recon reconstruction metrics of served_slate_context.
    • They are not yet reflected on the proto side. The proto has only the field 13 reconCosMilli.
    • The home-mixer local wiring is waiting for the proto migration.
  • Why it matters: This follows Gen4+6's large expansion of the ads conversion prediction heads by 20. Gen4+9 adds three heads that combine pixel fire and long dwell, and creates a channel to carry asset history in the serving request. constants.py and models/recsys_model.py do not yet reflect it. Because only the proto was introduced, the training and serving wiring is a checkpoint to watch in the next commit.

In Gen4+13 (2026-09-01, 7ba7768), the DPA product SID was carried in the serving wire format verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto

Dynamic Product Ads (DPA) is an ad format in which an advertiser uploads a product catalog and X automatically generates and shows ads tailored to each user. This commit added repeated int32 sid_codes = 8 to the DPA DpaProductInfo. That is, SID codes for ad DPA products are carried in the request payload into the ranker. This is a separate pipeline from the SID of organic posts, which is already carried as post_sids in history_seq and candidate_seq.

In the same commit, reserved 214 to 252 was added to the DiscreteActionName enum; reserving 39 slots in advance appears to secure room for further expansion of ads conversion actions inferred.

Why it matters: Ads DPA ranking has begun to be incorporated into the SID family of signals. Paired with the Phoenix ranker's Gen4+13 SID recon mode, this opens the possibility that ads DPA products and organic posts are represented under the same SID vocabulary. However, this commit alone does not tell us the following three things:

  • Whether the DPA SID codebook is the same as or different from the organic post SID codebook
  • Whether a server-side connection that consumes sid_codes already exists
  • Whether there are A/B results

In Gen4+23 (2026-09-17, 42266f3), delayed conversion attribution was expanded to 3 tiers verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto

The existing reserved 214 to 252 was narrowed to reserved 234 to 252, and tier 2 and tier 3 were attached at 214-233.

  • 10 Tier 2 actions (214-223) were added.
    • ADS_ATTRIBUTED_KEY_CLICK_CONVERSION_DELAYED_TIER_2·ADS_ATTRIBUTED_CLICK_CONVERSION_DELAYED_TIER_2·ADS_PURCHASE_CONVERSION_DELAYED_TIER_2·ADS_MID_FUNNEL_CONVERSION_DELAYED_TIER_2·ADS_ADD_TO_CART_CONVERSION_DELAYED_TIER_2·ADS_UPPER_FUNNEL_CONVERSION_DELAYED_TIER_2·ADS_WEB_CONVERSION_DELAYED_TIER_2·ADS_SEARCH_CONVERSION_DELAYED_TIER_2·ADS_SIGN_UP_CONVERSION_DELAYED_TIER_2·ADS_CHECKOUT_INITIATED_CONVERSION_DELAYED_TIER_2
  • 10 Tier 3 actions (224-233) are the _TIER_3 symmetric counterpart of the same 10.

Why it matters: The 10 *_DELAYED actions (204-213) introduced by the 2026-09-01 commit (Gen4+12) were expanded to tier 2 and 3 in 16 days verified — recsys.proto:446-455 in 6384ca7. The delayed window is now divided not into one but into three intervals by lag. The delayed feedback literature of the Chapelle (2014) and Yasui&Sakata (2020) line has taken the approach of mitigating propensity bias with per-lag masks, and this change is in the same direction inferred. Which part of the observation window each tier covers and how the loss weights are combined across tiers are not stated in this commit. Whether values like mparams["*_tier_2_loss_weight"] appear in the training config is the checkpoint to watch.

↑ Contents

Phoenix training and index (introduced in Gen4)

Since the Gen4 release (2026-08-13), Phoenix can train and serve a small model end-to-end, but the production checkpoint, data, and infrastructure are not public verified (training code). The ranker's dense-parameter optimizer changed from Adam to Muon in the 2026-08-19 commit (Gen4+4) verified (training code). The path by which home-mixer uses the slate context computed by Phoenix serving was added in the 2026-08-20 commit (Gen4+5), and the default of its gate was false verified (prediction output). SID reconstruction input and StableHLO bundle export were added in the Gen4+13 commit (2026-09-01, 7ba7768), and both were off by default verified (input features, infrastructure).

Training code, optimizer, and training config

As of the 2026-08-21 commit (Gen4+6), the released flagship ranking config and nano config use the same Muon optimizer recipe as the production home ranker verified. The training config for search ranking was added next to the For You feed config in the 2026-08-19 commit (Gen4+4) verified.

Phoenix can be run end-to-end since the Gen4 release verified — README.md #deployment-related-code.

  • A Cargo workspace, pyproject.toml, QUICKSTART, and a synthetic data generator are included verified — README.
  • The scope the README states is a small model: "a small model can be trained and served end-to-end". As of this README, the production checkpoint, data, and infrastructure are still not public verified — README.
  • Loss and negative sampling details need a deep dive. The optimizer was released in the 2026-08-19 commit (Gen4+4).

In the 2026-08-19 commit (Gen4+4), Phoenix's dense-parameter optimizer changed from Adam to Muon verified — phoenix/xrex/optimizers/recsys/muon.py (240 lines added).

Muon is an optimizer that approximately orthogonalizes gradient momentum with Newton-Schulz orthogonalization. The Muon in this code wraps optax.contrib._muon and became the default dense-parameter optimizer of the Phoenix ranker. The application is hybrid: only matrix-shaped parameters use Muon, and parameters whose names match the patterns below still use Adam.

muon_adam_patterns = "embed,_emb,logits,vocab,norm,table"

In other words, embedding tables, the output logits head, vocab, and norm (layernorm/rmsnorm) stay on Adam. Only the attention and FFN weight matrices of the Transformer body go to Muon. This setup matches the standard Muon hybrid pattern (Keller Jordan 2024 and the Kimi/Moonshot K2 line).

The default config of _home_direct_packed_base() changed as in the table below.

Parameter Value (from Gen4+4) Previous value (Gen3–Gen4+3)
optim "muon" Adam
learning_rate (dense) 7.1e-4 1e-3
emb_learning_rate (moved to the embedding config) 0.2
muon_consistent_rms 0.2 —
muon_matrix_weight_decay 0.014 —
muon_split_fused "qkv:128" (splits the fused QKV projection into 128-dim blocks and orthogonalizes each) —
adam_embedding_weight_decay 0.014 —
b1, b2 0.95, 0.98 —
embedding optim RecsysRowwiseAdagradConfig(learning_rate=0.28, half_life_steps=2500, lazy_decay=True, weight_decay=2.8e-4) rowwise_adagrad (LR 0.2)

The GB300 override was updated in the same direction. The values are Muon, LR 5e-4, half_life_steps=5000, and weight_decay=0.01.

This is the first time the code confirms Muon adoption in a large-scale production recsys training pipeline that this repository has covered. A new observation axis appears: an optimizer that started in LLM training has moved to ranker and retrieval training. An optimizer axis needs to be added to topics/sequence-transformer-ranking.md.

In the 2026-08-21 commit (Gen4+6), the Muon training recipe was formally included in the export verified — phoenix/TRAINING.md.

The canon's previous statement was: "The dense optimizer of the Phoenix export is standard Optax AdamW; production's tuned RMS-normalized-Adam derivative is undisclosed." Even after muon.py and dense_optim.py were exported in Gen4+4, which introduced Muon, TRAINING.md still said that only AdamW was exported. In this commit, TRAINING.md changed as follows.

Two dense-parameter optimizer families ship in this export:

  • Muon (xrex/optimizers/recsys/muon.py): the production home-ranker recipe — consistent-RMS scaling with decoupled weight decay on the matrix and embedding partitions. The flagship ranking configs and home_direct_packed_nano select it (optim="muon"), so the nano trains with the same dense-optimizer recipe production runs.
  • Standard Optax AdamW: the slot used by the remaining shipped configs (two-tower retrieval, gen-recs, and the legacy ranking presets). ...

— phoenix/TRAINING.md

  • The flagship ranking config and home_direct_packed_nano train end-to-end with the production Muon recipe verified — TRAINING.md. Running the exported nano model as is gives the same Muon training as production.
  • The AdamW arm remains only in two-tower retrieval, gen-recs, and legacy ranking presets verified — TRAINING.md. These are still the export-safe equivalent of production's tuned RMS-normalized-Adam derivative.
  • It also states that three options come with the rowwise AdaGrad for embeddings in flagship and nano verified — TRAINING.md.
    • The three options are accumulator half-life decay, lazy per-row decay, and decoupled weight decay.
    • The emb_optim_config parameter (LR 0.28, half-life 2500 steps) introduced in Gen4+4 has been settled in the canon.
  • The train_step.py entry in reference/README.md was also generalized to "dense optimizer + rowwise-AdaGrad" verified — reference README. The reference training script configures the AdamW arm, while the flagship and nano configs use the Muon recipe.

Why it matters: With this commit, running the exported training Python code as is gives the same training as the production Muon recipe. When Muon entered the code in Gen4+4 (2026-08-19), the canon's understanding was "the code is exported, but the training recipe is still production-specific." The reproducibility documentation has narrowed, but the data and infrastructure are still not public (the README caveat stays).

In the same commit (Gen4+6), the nano config inherits the LR and optimizer settings of the GB300 hardware-specific override verified — phoenix/xrex/configs/xrecsys.py:330-345.

  • _GB300_OVERRIDES itself has existed since the Gen4 release (batch, EP, attention, remat, and LR entries) verified — xrecsys.py:305-313, 368. The optimizer entries were added in the 2026-08-19 commit (Gen4+4).
  • The block below shows the values as of Gen4+6.
_GB300_OVERRIDES = {
    "bs_per_device": 512,
    "ep": 64,
    "attn_impl": "cutedsl_ranker_varlen_attn",
    "remat_policy": RematType.SAVE_GB300_RECSYS,
    "unroll_layer_stack": True,
    "learning_rate": 5e-4,
    "checkpoint_every_n": 300,
    "optim_config": RecsysDenseOptimConfig(
        optim="muon",
        muon_consistent_rms=0.2,
        muon_matrix_weight_decay=0.01,
        muon_split_fused="qkv:128",
        adam_embedding_weight_decay=0.01,
        b1=0.95,
        b2=0.98,
        ...
    ),
    ...
}
  • bs_per_device=512 is 8× the previous bs_per_device=64 of _home_direct_packed_nano. It reflects the HBM headroom of GB300.
  • ep=64 sets expert parallelism to 64-way.
  • attn_impl="cutedsl_ranker_varlen_attn" is a variable-length attention kernel based on NVIDIA CUTLASS/CUTe DSL. It is a new implementation optimized for GB300.
    • This name is not in open-source catalogs, so it may be xAI's own kernel inferred.
  • remat_policy=RematType.SAVE_GB300_RECSYS is a rematerialization policy tuned to the GB300 memory hierarchy.
  • unroll_layer_stack=True compiles the Transformer layer stack unrolled. This leaves room for XLA optimization.
  • checkpoint_every_n=300 saves a checkpoint every 300 steps.

_home_direct_packed_nano inherits the LR, optim_config, and emb_optim_config of this override. Previously nano had its own learning_rate=2e-3, but from this commit it is unified on the GB300 basis.

Why it matters: This is the first case where X exposes a hardware generation transition in code. NVIDIA GB300 (Grace Blackwell 300) is the latest data-center GPU, which began shipping in the second half of 2025, and the successor to Blackwell B200. LLM companies are securing this generation in bulk, and xAI applied it as is to the ranker training and serving pipeline as well. The new fact for the canon is that Phoenix production training runs on GB300. That even nano is tuned to the GB300 config means the export itself assumes a GB300 reproduction scenario.

The remat policy for H100 was added in a later commit verified (Gen4+15 D in the change log).

In the 2026-08-19 commit (Gen4+4), Phoenix expanded to search ranking by introducing the xrecsys_search config verified — phoenix/xrex/configs/xrecsys.py:435-473.

xrecsys_search was added next to xrecsys, the For You feed training config. The main differences from the For You config are as follows:

  • The vocab grew substantially. The search query and click corpus has greater user and item diversity, so the vocab must be larger than the much smaller vocab of the For You config.
    • The values are user_vocab_size=100_000_000, item_vocab_size=100_000_000, author_vocab_size=30_000_000, and ip_vocab_size=10_000_000.
  • The architecture values are num_layers=8, emb_size=2560, emb_table_width=1024, query_heads=20, kv_heads=4 (GQA), history_seq_len=1022, and candidate_seq_len=64.
  • The distribution settings are ep=512, dp=2, fsdp=1, and tp=1. With expert-parallel 512, the EP axis is much larger than in the For You base config, and the MoE routing is also much larger in the search config.
  • There are search-specific flags. use_product_surface=True turns on product surface conditioning, and output_vocab_size=128 expands the actions to 128 kinds.
    • The rest are condition_search_relevance_on_prompt=True, use_dense_action_table=True, and metric_group="search".
  • The training signal uses a separate group, group_id="user_action_sequence_xrecsys", and log_q_correction=True.
  • The training scale is total_samples=1e11, the same as the For You base.

Meaning: Phoenix is expanding from the For You feed ranker to a search surface ranker. This config pairs with two actions renumbered in the proto from 161/162 to 105/106 (CLIENT_TWEET_RELEVANT_TO_SEARCH = 105, CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106) (prediction output schema). Previously the canon understood each surface (For You, Following, search) as a separate codebase. This commit confirms a new fact: unification is under way toward serving both search and feeds with the same Phoenix Transformer architecture, swapping only the per-surface config. The direction matches the "single ranker for search and recommendation" trend of Netflix UniCoRn and GenRec (topics/llm-in-recsys.md).

In the 2026-08-21 commit (Gen4+6), three options were added to the nano config of two-tower retrieval verified — phoenix/xrex/configs/xrecsys_two_tower.py:429-431.

The options went into the xrecsys_two_tower_nano entry of MODEL_CFGS verified — xrecsys_two_tower.py:411-431. This entry does not inherit the combined base, so until then all three keys used the defaults in the model construction code verified — same file, lines 530, 627, 675, 681. In the combined base, the three keys have been False, 80.0, and True since the Gen4 release, and this commit did not change the base verified — xrecsys_two_tower.py:274-275, 318.

  • qk_norm=True
  • attn_logit_cap=-1: turns off logit capping.
  • right_anchored_rope=True: sets the RoPE positional encoding to right-anchored.

A stability trick from LLM training has moved to the retrieval two-tower. Separately, the EVERGREEN retrieval dataset file was drastically reduced from evergreen_video_1825day.parquet to video_4to14day.parquet. The name and the content now disagree, and this may be temporary inferred.

In the same commit (Gen4+6), the retrieval post-embedding computation was split into 65k-row chunks verified — phoenix/xrex/train/trainer_recsys.py _forward_chunked.

maybe_build_retrieval_post_embeddings previously computed a whole shard with a single candidate_tower_forward_jit call. From this commit, on even shards where total_samples % data_world_size == 0, it computes in units of _chunk_rows = min(_shard_rows, 65536). This stabilizes the subroutine as training scale grows with the Muon promotion and the GB300 config.

Prediction output schema and slate context

In the 2026-08-19 commit (Gen4+4), a reward output and a 5-quantile head for link-open probability were added to the Phoenix prediction output schema verified. The path by which home-mixer receives and uses the slate context computed by Phoenix serving was introduced in the 2026-08-20 commit (Gen4+5), and the default of its gate was false verified.

In the 2026-08-19 commit (Gen4+4), a reward head and a link-open 5-quantile head were added to the Phoenix proto verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto.

The Phoenix prediction output schema expanded in two directions.

  1. A RewardOutputs message was added, providing scaffolding for Phoenix to emit a reward value head and a policy logits head alongside the 24 probability prediction heads verified — recsys.proto.
    • A RewardOutputs rewardOutputs = 8 field was added to NextActionDistribution. The two fields of RewardOutputs are values, a scalar array, and policyLogits, the policy logits.
    • optional double rewardRerankSlotProb = 5 was also added to ScoreInfo.
    • It is presumably used for RL-style (actor-critic value+policy) training or reward-model-guided re-ranking slate decisions, and no scorer that consumes this field is connected yet inferred. It is still at the hidden state scaffolding stage inferred.
    • The training code of the page-level RL re-ranker for immersive surfaces, released later, reads the rewardRerankSlotProbSeq column as rl_rerank_prob and uses it as the logged probability of the page RL loss verified — recsys_batch.py:284, page_rl.py:206, 216, 233 (the page-level RL re-ranker commit in the change log).
      • The claim that this column collects the rewardRerankSlotProb field of the serving response rests on the names being the same inferred.
  2. A head was added that predicts link-open probability not as a single scalar but as a distribution of five quantiles (P10/P25/P50/P75/P90) verified — recsys.proto.
    • The five slots PLACE_HOLDER_167/168/169/170/253 were renamed P_OPEN_LINK_P10, P_OPEN_LINK_P25, P_OPEN_LINK_P50, P_OPEN_LINK_P75, and P_OPEN_LINK_P90, respectively.
    • Before this commit, X's prediction heads were of the single-probability or scalar kind. This head is the first time a distribution-shaped prediction entered the ranker.

The same proto change also includes these:

  1. The search relevance actions were renumbered as CLIENT_TWEET_RELEVANT_TO_SEARCH = 105 and CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106 verified — recsys.proto. The old 161/162 remain as *_DEPRECATED. These actions pair with the xrecsys_search config.
  2. SlateContext gained seven SID fields verified — recsys.proto. The fields are sidKnown, sidK1/2/3, and sidGap1/2/3. This aligns, at the proto level, with the 2026-08-18 commit (Gen4+3) that connected SID to VMRanker.

The following questions need a deep dive:

  • In which training loop is RewardOutputs actually filled? In this commit recsys_model.py changed substantially, +265/−25. The key is whether actor-critic heads were added or a reward model is trained separately.
  • Is the link-open 5-quantile a quantile regression or a categorical mixture?
  • Has the xrecsys_search config been deployed to production, or is it still used only for offline training?

In the 2026-08-20 commit (Gen4+5), home-mixer started receiving and using the slate context computed by Phoenix serving verified — home-mixer/models/candidate.rs:26-27, 76-77, 99-118.

The SlateContext.sid* fields added to the Phoenix proto in Gen4+4 were connected to the consumption path of the actual pipeline in this commit. Four places changed at once across three files (candidate.rs, phoenix_scorer.rs, ranking_scorer.rs).

  1. PostCandidate gained a new field served_slate_context: Option<SlateContext>. It is a second storage slot next to the existing slate_context (a value home-mixer computes locally and then caches), and it holds only the slate context that came from the Phoenix response.
  2. A conversion impl between proto and Rust, From<xai_recsys_proto::SlateContext>, was added. This impl renames the proto's sid_k1/2/3 and sid_gap1/2/3 to home-mixer's sid_k_l1/l2/l3 and sid_gap_l1/l2/l3. The "l" stands for level.
  3. PhoenixScorer takes the slate context out of the prediction response and puts it in this field. If the Phoenix prediction response carries a SlateContext along with the 24 candidate scores, it is used as is.
    • The SlateContext at this point contains pool_rank, pool_rank_gap, fatigue, pre_diversity_score, sid_known, sid_k_l1/2/3, and sid_gap_l1/2/3.
    • The code is predictions.candidate_slate_context(&c.get_original_tweet_id()).map(Into::into).
  4. RankingScorer decides which slate context to use through a three-way fallback. The new gate is UseServedSlateContext, and its default is false.
    let persisted_contexts: Option<Vec<SlateContext>> =
        match Self::served_slate_contexts(query, candidates) {
            Some(served) => Some(served),
            None if query.has_cached_posts => Self::stored_slate_contexts(candidates),
            None => Some(Self::compute_slate_contexts(candidates, &weighted_scores)),
        };
    
    The fallbacks apply in order from the top, and both ranking branches (mpn_scoring, diversity) use the same fallback.
    1. If the gate is on and Phoenix sent the slate context for every candidate, it is used as is.
    2. If the gate is off and the Redis cache hits, the stored context is used.
    3. Otherwise, it is computed locally.

Why it matters: This is the first step of a migration that moves the place where the slate context is computed from home-mixer locally to Phoenix serving. At the moment Phoenix produces a prediction, it already knows each candidate's pool rank, fatigue, pre-diversity, and three-level SID information. Therefore, sending these in the serving response instead of recomputing them in home-mixer gains two things:

  • (a) It saves ranker CPU.
  • (b) It can reflect the richer context that Phoenix has. The three SID levels cannot be computed locally by home-mixer.

This matches the direction of the SID connection to VMRanker in the 2026-08-18 commit (Gen4+3). By definition, SID is a Phoenix training artifact and cannot be computed locally; this commit kept UseServedSlateContext off by default, so it only opened the gate, and the actual rollout is planned as a later parameter change. What to watch next is the commit that raises this gate to true. That commit marks the point when the slate context from Phoenix serving reaches production traffic.

In the 2026-08-21 commit (Gen4+6), SlateContext was also added to NextActionDistribution as field #9 verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto.

SlateContext first appeared in Gen4+4 as the field slateContext = 4 of ScoreInfo, the context attached to re-ranking slate decisions, and in Gen4+5 it was connected as PostCandidate.served_slate_context, which home-mixer started using. In this commit, a SlateContext slateContext = 9 field was also added to NextActionDistribution, the per-action prediction distribution of each candidate. That is, a slate context is now attached to each action distribution as well, so from this commit it can be carried in two places, ScoreInfo and NextActionDistribution. The consumption path is not yet connected, and this is at the scaffolding stage inferred.

Ranker input features and SID reconstruction

Three bool input features of the Phoenix ranker have been filled with real values since the 2026-08-14 commit (Gen4+1) verified. These three became default training inputs in the 2026-08-19 commit (Gen4+4) verified. The recon mode, which turns SID codes back into dense vectors and adds them to the ranker input, was released in the Gen4+13 commit (2026-09-01, 7ba7768), but at release its default was the existing learned mode verified.

In the 2026-08-14 commit (Gen4+1), three new Phoenix input features were added verified — phoenix/crates/common/xai-recsys/src/util.rs:456-540.

bool feature description
IS_STALE_POST14D If enable_stale_post = true and now - creation_ts > STALE_POST_14D_TTL_SEC, the fav/reply/retweet/quote/view counts of this candidate (and history) are all stamped to 0 and this bool is set to true. In effect, all engagement signals of posts older than 14 days are ignored and the ranker is only told "stale".
IS_AUTHOR_FOLLOWED_BY_VIEWER_SEQ Whether the viewer follows this candidate's author (per candidate and history sequence)
IS_AUTHOR_FOLLOWING_VIEWER_SEQ Whether the candidate's author follows the viewer (detects bidirectional follows)
  • In the code from Gen3 to early Gen4, bool_features was stamped entirely as vec![false; ...], so the input itself had no effect (a "dead field" kind in the canon caveat list). In Gen4+1, three were filled for the first time verified.
    • The evidence is the patch replacing bool_features: vec![false; ...] → bool_features: candidate_bool_features.
  • The stale signal comes in as a pair: count zeroing and a bool set. It is an explicit label telling the ranker that "these counts are low not because the post is unpopular but because it is old and its impressions have stopped." The purpose is likely to mitigate Gen3's snowball problem inferred.
  • The is_mutual_follow boost (reply +15) was a condition hardcoded in the scorer. From this commit, the model receives the follow direction as separate input bools and can learn a similar pattern through training.
  • In the 2026-08-19 commit (Gen4+4), all four features were promoted to train-default ON verified — phoenix/xrex/configs/xrecsys.py:270-290.
    • In the _home_direct_packed_base() default config, the four flags enable_stale_post=True, enable_is_author_followed_by_viewer=True, enable_is_author_following_viewer=True, and enable_engagement_counts=True were raised to the FeaturePrepConfig defaults.
    • The three bools filled in Gen4+1 have been default inputs of the training pipeline since this commit.
    • Separately, only the grpc_recsys dataset type overrides enable_stale_post=False, so the stale-flag stamp is off on the real-time serving path. The reason is presumably that count zeroing can be risky in real time inferred.

In the Gen4+13 commit (2026-09-01, 7ba7768), an SID recon mode was added to the Phoenix ranking model. It is off by default. verified — phoenix/xrex/models/recsys_sid.py (72 lines added).

This commit is the first code release of the path by which X attaches RQ-VAE-family SIDs to the ranker input. It does not replace the retrieval model with SID; it is a path that reconstructs SID codes into input embeddings for the ranker post tower.

_load_sid_decoder(path) reads the tensors below from an offline .safetensors.

  • stages is a stacked residual codebook (RQ-VAE style) of shape (num_levels, codebook_size, code_dim). For each level, stages[l, code_l, :] is picked and summed. The codebook size and number of levels are determined by the artifact and are not hardcoded in the code.
  • dec_w0, dec_b0, dec_w1, and dec_b1 form a 2-layer MLP decoder (relu → linear). It restores an SID sequence to a dense vector.

The recon function reconstruct_entity_sid turns SID codes into ranker input vectors in the order below verified — phoenix/xrex/models/recsys_sid.py.

  • (a) codes = sids - 1 converts 1-based to 0-based, and rows with sids[..., 0] == 0 are marked "missing".
  • (b) For each level, stages[l, code_l, :] is picked to compute the residual sum.
  • (c) It is restored with a 2-layer MLP and relu.
  • (d) It is L2-normalized (eps=1e-12).
  • (e) Missing rows are masked to 0.
  • (f) The decoder is kept fully frozen with stop_gradient.
  • (g) Only one linear projection from recon_dim → target_dim ({prefix}_sid_recon_proj, trainable) is a parameter. The result is returned with its dtype cast to fit the ranker post tower.

The mode is chosen by a config gate verified. Two fields were added to RecsysAggregatedModelConfig:

sid_embedding_mode: Literal["learned", "recon"] = "learned"
sid_decoder_path: str = ""
  • The default "learned" keeps using the existing SID embedding table (a lookup trained inside the post tower).
  • Setting "recon" bypasses the learned SID table. Instead, it builds recon vectors from the SID codes of history_seq and candidate_seq and adds them to the post tower embedding (add).
    • The requirements are the legacy build_inputs path (feature_prep_enabled=False), no sid_hash_level, no sid_cross_attn, and a specified sid_decoder_path.

Why it matters

  • This is the first code release of X's SID line. This commit is the first to show in code how SID codes themselves are turned back into dense features. Until now, X's public SID-related material comprised three items:
    • (a) In Gen4+3 (11a71f8, 2026-08-18), the sid_known, sid_k_l1/2/3, and sid_gap_l1/2/3 fields were connected to VMRanker.
    • (b) In Gen4+4 (aad7179, 2026-08-19), the SlateContext.sid* fields were added to the Phoenix proto.
    • (c) In Gen4+5 (d0cef2f, 2026-08-20), a path was added by which SID is carried in the slate context of the serving response.
  • The decoder is fully frozen. Because it uses stop_gradient and an offline artifact, serving only needs the codebook and MLP matrices deployed, and the only thing trained is the single recon_dim → target_dim projection. Codebook tuning happens only offline, and ranker training does not disturb the codebook.
  • "learned" and "recon" become the A/B axis. That the default is still learned is read as meaning that recon mode's production validity has not yet been verified, or that it is before dial-up inferred. If sid_embedding_mode="recon" appears in a production config in the next commit, it will signal that the X SID pipeline has moved from a "trained table" to "offline codebook + recon at serving time".
  • This path is not a retrieval model. Restoring vectors from the codebook and attaching them to the ranker input increases the density of the candidate representation; it does not replace candidate generation with SID. Its lineage differs from the approach of Kuaishou OneRec and Tencent TGR (radar.md generative retrieval thread), which "treat SID as the autoregressive generation target". As of this commit, X still uses SID as an input signal inside the cascade. A generative retrieval source that uses SID for candidate generation was later released off by default verified (candidate generation section).

This commit also leaves some things undisclosed.

  • Code of the SID training pipeline that builds the artifacts (encoder and VQ training)
  • Measured codebook size and number of levels
  • Whether sid_embedding_mode="recon" is on in production
  • A/B metrics for the learned and recon modes

Phoenix retrieval index

The post index that Phoenix retrieval queries, and the event layer that decides which index a post goes into, were released with Gen4 verified. A post gets a visibility verdict before it enters the index verified. Since the 2026-08-17 commit (Gen4+2), candidates with no likes are also published as indexing events verified.

The Gen4 release also included the Phoenix retrieval index services verified — README.md #retrieval-index.

  • phoenix-rankall/ is the post index that Phoenix retrieval queries. It is updated when events arrive verified — README.
  • phoenix-rankall-strato/ is the event layer that decides which index a post belongs to. This layer consults visibility-filtering first verified — README.
  • In the 2026-08-14 commit (Gen4+1), one SID window, video_14day, was added verified — phoenix-rankall/src/config/mod.rs:177-180.
    • It pairs with the existing video_48h, video_96h, and nsfw_video_48h/168h/14day.
    • A video popularity signal over a 2-week window entered the retrieval SID.
  • In the 2026-10-20 commit (Gen4+45), the hourly video windows video_6h and video_6hto2day were added, so the shortest video window of the SID index went from 48 hours to 6 hours verified — phoenix-rankall/src/config/mod.rs:212-213.

The indexing pipeline is already aligned with the ranking-side visibility rules. A post already carries a visibility verdict before it enters the index. This is an architecture fact that the canon did not know at all through Gen3.

Two indexing gates were removed in the 2026-08-17 commit (Gen4+2) verified — phoenix-rankall-strato/columns/phoenix_rank_all/phoenixRankAllCandidateProcessor.strato.

  • build1FavIndex publishes every candidate from this commit. In other words, the "1fav" index name became a backward compat label, and the actual index holds all incoming candidates.
    • Before the change, only candidates with favoriteCount >= 1 published a "1fav" indexing event to Kafka.
    • After the change, only candidates with favoriteCount == 0 are instrumented with a new counter, 1fav_uec_count_race.
  • buildMMEmbMetadataDump also lost its gate and fetches and dumps three versions of the multimodal embedding for every candidate.
    • Before the change, the same favoriteCount >= 1 gate meant that only candidates with favorites looked up the three versions (v3, v5_1, v6_dev) and dumped them as Kafka metadata.
  • This is a new caveat. The canon's understanding from Gen3–Gen4 that "phoenix-rankall updates the index with a popularity signal" is no longer accurate from this commit. The index name does not imply a filter condition.
  • Why it matters: This is an infrastructure expansion that lets Phoenix retrieval attempt embedding-based matching on long-tail posts too inferred. In the next commit we look at two things: the ratio at which the 1fav_uec_count_race counter is actually observed, and which param gates (Phoenix retrieval top-k, SID window) the index coverage expansion is turned on together with.

In the 2026-08-18 commit (Gen4+3), Phoenix postCreationEventForwarder was migrated to unified posts topic V3 verified — phoenix-rankall-strato/stream_forwarders/postCreationEventForwarder.strato.

Field Value (since Gen4+3) Previous value
Kafka topic content_understanding_realtime_unified_posts_v3 content_understanding_realtime_unified_posts
Kafka dest /s/kafka/phoenix-kafka-external-bootstrap (separate Phoenix-only cluster) /s/kafka/main-2:kafka-tls (X main Kafka cluster)
Consumer group post_creation_event_v3 (plus a separate develConsumerGroupId = Some("post_creation_event_v3_devel")) post_creation_event
Kafka mapping KeyValue({key = Long, keyEncoding = NativeEncoding, ...}) (postId as the explicit key) ValueOnly (Thrift value only)
Import hydra/sharedConfig content_understanding/sharedConfig
Processor signature Op.execute[(Long, Event), Unit] (prefers the postId that arrives as the key in the tuple) Op.execute[Event, Unit] (parses the postId from inside the event via ctx.arg)
  • This migration moves the shared event stream of the content understanding domain from v1 to v3. It changed in two directions:
    • (a) It raised postId to the Kafka key, so partitioning, dedup, and inverted-index lookup can be purely key-based.
    • (b) It separated the Phoenix retrieval Kafka cluster from X main, so isolation and tuning can be done separately.
  • Moving the import to hydra/sharedConfig is also a signal that the Phoenix family is being brought under hydra infrastructure.
  • With this change, the upstream event path for maintaining the Phoenix retrieval index (build1FavIndex, buildMMEmbMetadataDump) fully moved from X main Kafka to the isolated Phoenix Kafka. It points the same direction as the "indexing gate removal" in Gen4+2. The direction fits if the indexing pipeline's throughput grew and a separate cluster became necessary inferred.
  • Some things are also not public. They are whether a v2 existed (whether it jumped straight from v1 to v3, or this is a third migration going from v2 to v3), the scale of the Phoenix Kafka cluster and the reason for isolation, and when the v1 forwarder is finally shut down.

Training and serving infrastructure

The 2026-08-20 commit (Gen4+5) extracted the core of the embedding table loader into a function not tied to pyo3 verified. The 2026-08-21 commit (Gen4+6) released an option to return memory after hotswap and the interface of xai-o2, the internal object storage verified. The path that exports a trained ranking model as a StableHLO bundle for CUDA serving entered off by default in the Gen4+13 commit (2026-09-01, 7ba7768) verified.

In the 2026-08-20 commit (Gen4+5), emb_table.rs was split into a Python wrapper and a pyo3-free core verified — phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs (+189/−169, essentially a behavior-preserving refactor).

The existing load_tensor was tied to pyo3 types and errors and could not be reused in non-Python contexts. This commit extracted its core into the function below, and the existing load_tensor was redefined as a pyo3 wrapper.

  • load_tensor_into(&str, &str, &[(String, String, usize, usize)], &mut [u8], usize, usize) -> Result<(), String>

Safety was also reinforced.

  • The tokio runtime's unwrap() was replaced with map_err.
  • A prefix.len() < 3 guard (checkpoint path too short: {path}) was added.
  • The modulo check was unified on the is_multiple_of API.

This is refactor preparation for the growing need to load large embedding tables (vocab 100M/100M/30M of Gen4+4 xrecsys_search) in multiple serving and training contexts inferred.

In the 2026-08-21 commit (Gen4+6), a malloc_trim(0) option was added to inference hotswap verified — phoenix/xrex/inference/model_runner.py.

The new CLI option --hotswap_malloc_trim defaults to True. The _malloc_trim_after_swap() method calls libc malloc_trim(0) at the end of every hotswap cycle. The observability metric is checkpoint_reload_step_seconds.labels(step="malloc_trim").

The CLI help reads:

glibc malloc_trim(0) on the coordinator thread after each applied hotswap; returns the cycle's freed pages (glibc slots otherwise ratchet ~196 MiB/cycle). No-op under jemalloc.

Why it matters: This is the first documentation that embedding table hotswap in Phoenix serving has been leaking ~196MiB of RSS per cycle under glibc. Hotswap is long-running infrastructure in production, so the observation is that the accumulated burden was large enough to need this patch.

The same commit (Gen4+6) released the interface of the xai-o2 storage crate verified — phoenix/crates/storage/xai-o2/.

The description in Cargo.toml reads as follows:

Client interface for the xai-o2 (S3-compatible) object storage backend

The crate consists of 3 files:

  • base_client.rs (12 lines) defines the BaseO2Client async trait, whose only method is put(key, data) -> Result<()>.
  • In o2_client_builder.rs (115 lines), O2ClientBuilder provides 15 builder methods.
    • endpoint·bucket·prefix·access_key_id·secret_access_key·allow_anonymous·timeout·io_timeout·retry_max_times(6)·retry_min_delay(1s)·retry_max_delay(32s)·write_chunk_size_mib(8)·write_chunk_concurrency(16)·max_concurrent_write_requests(64)·trace_always
  • lib.rs is 7 lines.

However, in the open-source build, build() always returns the error below.

pub const BACKEND_UNAVAILABLE: &str =
    "the xai-o2 backend is not included in this build; use the default object_store backend";

The xai-recsys-engine crate declares the dependency as xai-o2 = { workspace = true }, but in actual use it must fall back to the standard object_store crate.

Why it matters: This is the first confirmation that xAI has an internal S3-compatible object storage system named xai-o2. The 15 builder params are significant because they are items actually tuned in production. It signals that production checkpoint and data storage attach here inferred.

A new StableHLO bundle export path arrived in the same commit as SID recon mode (Gen4+13). It is off by default. verified — phoenix/xrex/train/recsys_bundle_export.py (634 new lines).

The entry point build_bundle(trainer) lowers the ranking model's forward pass with jax.export. The only target is RecsysAggregatedModelConfig, and RecsysGenRecsModelConfig is explicitly unsupported. The export produces three outputs:

  • StableHLO MLIR bytecode (.mlirbc) made with jit(...).lower().as_text(dialect="stablehlo")
  • .jax_export made with jax_export.export(platforms=("cuda",)).serialize()
  • MANIFEST.json

The bundle structure reveals features of the serving architecture:

  • Embedding lookup is outside the exported graph. The forward input merged_embeddings is a tensor that concatenates already looked-up embedding rows.
    • MANIFEST.embedding holds {rows, rows_padded, num_shards, width, dtype, merged_slices, hash_table}, so the serving side knows how to concat the embedding server results.
    • The code reveals that the serving architecture of the X ranker is a split structure: "the embedding server does the lookup, and the GPU server runs StableHLO".
  • There are multiple batch buckets. The default of export_bundle_bs_per_device is "1,2,4". A separate program is lowered per bucket, and at serving time the bucket matching the request batch size is chosen.
  • The target is CUDA only. MANIFEST carries device_kind and compute_capability.
  • seqpack supports only pallas_ranker_varlen_attn. The block-sparse layout of FA4 (cutedsl_ranker_varlen_attn) explicitly raises NotImplementedError.
    • Because the Gen4+6 GB300 config uses attn_impl="cutedsl_ranker_varlen_attn", exporting the GB300 training config as is requires switching the attn kernel to pallas observed. This limit is the break point in the pipeline from GB300 training to GB300 serving observed.
  • Custom-call targets are recorded in the manifest. Because ranker-specific pallas/CUDA kernels are baked into the StableHLO, the serving loader must allow custom calls. The allowed range is widened with jax_export.DisabledSafetyCheck.custom_call(...).
  • sid_num_levels is echoed into the manifest prep_spec. The serving side uses this value to know how many SID levels to put in a batch. This field pairs with SID recon mode.

Four new fields on Trainer turn on and tune the bundle export:

export_stablehlo_bundle: bool = False
export_bundle_bs_per_device: str = "1,2,4"
export_bundle_history_seq_len: int = 0
export_bundle_candidate_seq_len: int = 0

If export_stablehlo_bundle is on but checkpoint_config.copy_port is off, __post_init__ raises ValueError. In other words, export can be turned on only when attached to the copy_port of the checkpoint deployment pipeline (hot checkpoint deployment). With every copy_port deployment, a bundle is shipped to {OUT_PATH}/.{prefix}/export/ as well.

Why it matters

  • X released for the first time code that standardizes the handoff from JAX training to CUDA serving as StableHLO. Until then the canon's understanding was "Phoenix is trained with JAX/Haiku and serving is a separate stack". With this commit, the code confirms that the serving stack takes and runs compiled StableHLO.
  • The explicit separation of embeddings and dense forward is settled as an architecture fact. This accurately supports the canon's understanding that X does not hold the embedding table on a single GPU and looks it up from a separate server (the embedding server and mm_embedding_client.rs). An example of table scale is the vocab 100M/100M/30M of Gen4+4 xrecsys_search.
  • It is off by default. This commit alone cannot confirm whether the production serving pipeline actually consumes StableHLO bundles. If export_stablehlo_bundle=True appears in a production config in the next commit, it will signal that serving has attached to this path.

Some things are also not yet public.

  • Code of the serving loader (the runtime that consumes StableHLO)
  • Detailed conventions for how the embedding server builds the merged_embeddings tensor. The merged_slices schema of MANIFEST.embedding is in the code, but the consumer-side code is not public.
  • Whether this bundle is on in production

The Gen4+13 commit added an Orbax writer that encrypts and stores training checkpoints with envelope encryption. The default is off. verified — phoenix/python/training/xai-checkpointing/xai_checkpointing/orbax_encrypted.py

The linked orbax_encrypted.py is a new 288-line file, and phoenix/xrex/train/misc.py (+5/0) and phoenix/xrex/train/checkpoint_write.py (+6/0) also changed with it verified — same commit.

Envelope encryption encrypts data with a data key (DEK) and then encrypts that data key again with another key.

This writer wraps Orbax PyTreeCheckpointHandler and enforces the following:

  • Every file in a checkpoint must start with a magic header. If even one file lacks the header, the commit is refused verified — same commit.

    • The magic header value is b"XAIENC01".
    • After finalize, assert_all_enveloped(path) walks the checkpoint directory. If it finds a file without a header, it raises RuntimeError("encrypted save left plaintext at rest; not committing: ...").
    • _DEK and _DEK.claim (the DEK file issued by KMS and its claim) are exceptions.
  • Leaves allow only jax.Array; string, dict, and Python scalar leaves are explicitly rejected verified — same commit.

    • The guards are _ArraysOnlyRegistry and _require_array_leaves.
    • This guard removes the arbitrary code execution risk when the serving side loads this checkpoint.
  • The tensorstore KvStore is also replaced with an encrypted wrapper verified — same commit.

    • The option is use_encrypted_kvstore.
  • Metadata is also encrypted verified — same commit.

    • _METADATA, _CHECKPOINT_METADATA, and the sharding metadata are all written with encrypt_write.
  • A process that restored from an encrypted checkpoint cannot save in plaintext (no downgrade) verified — same commit.

    • The new guard is in checkpoint_write.py.
    • Attempting a plaintext save raises ValueError("restored from an ENCRYPTED checkpoint but the orbax writer cannot produce encrypted saves — refusing to downgrade to plaintext saves").
  • CheckpointConfig gained new encryption setting fields verified — same commit.

    encrypt: bool = False
    encryption_key_id: str | None = None
    encryption_context: dict[str, str] = field(default_factory=dict)
    encryption_chunk_size_bytes: int = 8 * 1024 * 1024
    
    • The encryption_context field follows the AWS-KMS encryption-context pattern as is.
    • The default chunk size is 8 MiB.

Why it matters

  • A writer that encrypts training checkpoints at rest entered the code verified. We infer that X uses this writer in production inferred. The basis is that it even includes devices aimed at operational accidents, such as the downgrade-prevention guard.
    • Previously this could only be guessed to be handled at the infrastructure layer (not confirmed in the canon).
    • Now envelope encryption, KMS, and magic header enforcement are in-tree in the xrex training Python code.
    • The source did not state a regulatory framework (SOC2, FedRAMP, GDPR, etc.). It reads as general "data at rest" protection.
  • CheckpointConfig.encrypt defaults to False, and as of the 2026-10-17 commit (862a123) none of the public training configs turns it on verified — phoenix/xrex/train/misc.py:56. So code alone cannot confirm whether production actually uses this writer.
  • The downgrade-prevention guard stands out in particular. It blocks accidentally losing the encrypted state at the code level.
    • It points the same direction as the VF reference_compare harness in Gen4+7, which panics when booted with APP_ENV=prod and so enforced staging-only use.
    • In both cases the policy is fixed in code as a fail-close assertion, not as a config value.

The following is not public:

  • The actual implementation of kms_client (within the file it is passed in from outside)
  • DEK rotation period
  • Actual values used for the encryption context
  • Which KMS is used (AWS, Azure, or in-house)

↑ Contents

Grox (LLM annotation) — unchanged from Gen3 (+ threshold raise on 2026-08-14)

Grox is a Grok-based nearline annotation pipeline. The follower threshold that splits reply spam verdicts from reply ranking scoring targets has been 250k since the 2026-09-24 commit (Gen4+28). Replies whose thread root and reply target are both at or below this value receive a spam verdict, and replies where either exceeds it receive reply ranking scoring verified. The threshold started at 15k in the Gen4 release and was raised eleven times; each raise widened the spam verdict targets and narrowed the reply ranking scoring targets verified. The separate spam detection flow was absorbed into the reply ranking flow in the 2026-08-18 commit (Gen4+3) verified. Both thresholds changed after that, and the changed values are in the change log; see the threshold history summary in the commit that raised the coordinated spam threshold from 10k to 30k and the commit that split the reply spam model by thread followers verified.

grox/ produces the slop score, safety labels, topics, reply scores, and multimodal embeddings. In Gen4, grox/ was explicitly placed as Content Understanding stage 1 of the upstream labeling pipeline (safety section). Details are in llm-in-recsys.md.

The 2026-08-14 commit doubled the follower thresholds for reply spam verdicts and reply ranking scoring verified — grox/flows/reply_spam/task_filter.py:17,185.

  • The two threshold constants in grox/flows/reply_spam/task_filter.py changed from 15000 → 30000.
    • TaskSpamFilter.FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION: 15,000 → 30,000
    • TaskReplyRankingFilter.FOLLOWER_COUNT_THRESHOLD_FOR_REPLY_RANKING: 15,000 → 30,000

Meaning: replies whose thread root and reply target both have 30k followers or fewer receive a grok spam verdict, and replies where either exceeds 30k receive reply ranking scoring verified — task_filter.py:77-92, 214-225. Doubling the threshold widened the spam verdict targets and narrowed the reply ranking scoring targets. Because the original code constant names carry "spam detection" and "reply ranking", this adjustment is a resource allocation adjustment that decides which replies get grok resources.

The 2026-08-18 commit (Gen4+3, 11a71f8) raised both thresholds again and merged the separate spam detection flow into the reply ranking flow verified — grox/flows/reply_spam/.

  • Both constants went up from 40,000 → 60,000 verified — same commit. This is the third raise, following 30,000 → 40,000 in the 2026-08-17 commit (Gen4+2) (task_filter.py:17, 185 in b089ce6).
    • Only replies whose root and reply target are both at or below 60k become grok spam verdict targets, and the other replies receive reply ranking scoring.
    • The boundary between spam verdicts and reply ranking scoring moved from 40k to 60k.
  • The separate task generator that detected spam over the whole post stream was deleted, and spam detection was absorbed into the reply ranking flow verified — same commit.
    • The deleted generator is PostStreamTaskGenerator. The TOPIC_UNIFIED_POSTS constant (the original, not v3) was also deleted.
    • The remaining ReplyRankingTaskGenerator injects the three plans below together and consumes from the single source TOPIC_UNIFIED_POSTS_V3.
      • PlanReplyRanking (existing)
      • PlanSpamComment (newly absorbed)
      • PlanCoordinatedSpam (newly absorbed)
  • Meaning: the grox spam verdict pipeline was consolidated with a wider range of verdict target accounts (60k and below) and a single flow verified — same commit. The single flow refers to the merge with reply ranking.
    • This change came in the same commit as the Phoenix unified_posts_v3 topic migration (Phoenix training and index section). So the observation follows that grox and Phoenix both moved to the v3 content understanding stream at the same time.

The Gen4+6 commit (2026-08-21, 28e414f) raised both constants from 60,000 → 80,000 verified — grox/flows/reply_spam/task_filter.py.

  • This is the fourth raise in 3 days, with a step of 1.33×.
  • The threshold went up four times in the 8 days after the Gen4 release (15k → 30k → 40k → 60k → 80k).
  • After the first raise (2×), the step shrank to 1.33–1.5×. This is one of the following: nearing the grok budget bottleneck, diminishing marginal utility because few spam replies come from accounts above that size, or preparation to move to full on with no threshold in the next stage inferred.

This section's change log is below. The values are the threshold shared by TaskSpamFilter and TaskReplyRankingFilter, confirmed in each commit's task_filter.py and constants.py verified.

  • 2026-08-13(Gen4 release): 15k
  • 2026-08-14(Gen4+1): 30k
  • 2026-08-17(Gen4+2): 40k
  • 2026-08-18(Gen4+3): 60k
  • 2026-08-21(Gen4+6): 80k
  • 2026-08-24(Gen4+7): 100k
  • 2026-08-25(Gen4+8): 120k
  • 2026-09-08(Gen4+17): 150k
  • 2026-09-16(Gen4+22): 180k. The constant was renamed to GROK_GEMMA_FOLLOWER_SPLIT.
  • 2026-09-17(Gen4+23): 200k
  • 2026-09-23(Gen4+27): 225k
  • 2026-09-24(Gen4+28): 250k. It is still 250k in the 2026-10-17 commit (862a123).

↑ Contents

Grox → PtOS Adult Content Cross-Validation (Gen4+2 new, 2026-08-17)

The judge in the grox safety verdict is a layer that has a Grok model judge the first-pass verdict once more. The adult content judge was introduced in the 2026-08-17 commit (Gen4+2), and the judge class (cross-validator) that also covers child safety, violent media, and illegal and regulated behaviors was introduced in the 2026-08-24 commit (Gen4+7) verified (verdict flow, model history). As of the commit that introduced each (Gen4+2 and Gen4+7), the two judges only keep or demote the first-pass verdict and never make it stricter verified (verdict flow, model history). Later, cross-validation stages were also added to the high-popularity video path and the leading-frames rescan path, and the judge model for illegal and regulated behaviors was changed verified (commit that created the high-popularity video path, Gen4+15 B, Gen4+17).

The judge model column of the table is as of the commit listed in that column, and the other columns are as of the commit that introduced the judge class.

Verdict target Judge class Commit that introduced the class Judge model (reference commit) Handling based on the judge result
Adult content: posts in the deluxe flow where the safemodel and PtOS verdicts disagreed and that have no NSFW flag yet SafetyPtosAdultContentCrossValidationJudge Gen4+2 (2026-08-17) Grok 4.5 (as of Gen4+2) If the judge verdict is not AdultContentSexualHard, demote to AdultContentSexualSoft
ChildSafety SafetyPtosPolicyCrossValidator Gen4+7 (2026-08-24) EAPI_GROK_4_5_X_ALGO (as of Gen4+15) Disagreement, parse failure, and error all become NoViolation
ViolentMedia SafetyPtosPolicyCrossValidator Gen4+7 (2026-08-24) EAPI_GROK_4_5_X_ALGO (as of Gen4+15) Demote if the judge sees NoViolation; demote to ViolentMediaGraphicMedia if the type differs
IllegalAndRegulatedBehaviors SafetyPtosPolicyCrossValidator Gen4+7 (2026-08-24) EAPI_GROK_4_1_FAST_X_ALGO (from Gen4+15) Demote to NoViolation on disagreement
  • Since the commit that split the judge model by category (Gen4+15), the illegal and regulated behaviors judge uses the model in the table instead of EAPI_GROK_4_5_X_ALGO, and it calls this model directly without a circuit breaker verified (Gen4+15 B in the change log).
  • The high-popularity video path created by the commit that built the special video pipeline based on motion-reveal also has an adult content cross-validation stage verified (commit that created this path).
    • As of that commit, only this path forces a demotion to Soft instead of keeping the original verdict when the judge call fails with an exception (other paths keep the original verdict by default) verified — same subsection.
  • The adult content leading-frames rescan path introduced in the Gen4+17 commit also has a cross-validation stage verified (Gen4+17 in the change log).
    • As of Gen4+17, this path cuts only the first 10 seconds of videos that are 30 seconds or longer and re-judges them verified — same subsection.
    • As of Gen4+17, the entry targets are some of the videos whose fav count is at or above the deluxe threshold and that already received a PTOS verdict but were not judged NSFW verified — same subsection.
    • In the commit that brought broadcast into the PTOS safety pipeline (Gen4+18), this path lost the deluxe fav gate, and it is entered whenever a broadcast exists even without a video of 30 seconds or longer verified (Gen4+18 in the change log).
    • The Gen4+17 subsection treats this path as a second judge that can reverse a demoted verdict again.

Verdict flow of the adult content judge

A judge layer was added to adult content verdicts to handle verdict disagreement. The grox safety pipeline first released in Gen4 runs the two verdicts in parallel and only logged the result when they differed.

The judge task works in four stages:

  1. Compare the two verdicts. The enum CompareOutcome distinguishes BOTH_POSITIVE, BOTH_NEGATIVE, SAFEMODEL_ONLY_POSITIVE, and PTOS_ONLY_POSITIVE. is_disagreement is true for SAFEMODEL_ONLY or PTOS_ONLY.
  2. Check two gates.
    • It runs only in the deluxe flow, and the regular flow only logs. The condition is is_deluxe = ctx.payload.task_type == SAFETY_PTOS_DELUXE.
    • It skips posts that are already NSFW-flagged. The flag is looked up with StratoSafetyPostAnnotationsResultDirectMh.
  3. Call the Grok 4.5 judge. The new class SafetyPtosAdultContentCrossValidationJudge uses ModelName.EAPI_GROK_4_5_X_ALGO.
    • The dedicated circuit breaker has a 50% failure rate, a 600-second window, 600 seconds of recovery, and 5 half-open calls.
    • The system prompt is _strip_thinking_restrictions(adult_content_policy_prompt()). The user message is built with UserRenderer.render(user) and PostRenderer.render(post, include_reply_to=True).
    • The response is parsed with re.compile(r"(.*)<json>(.*)</json>", re.DOTALL) to separate the free-text reasoning from the JSON verdict.
  4. Apply the judge result. If the judge returns AdultContentSexualHard, the original verdict is kept. For any other result, it does three things.
    • It demotes the safetyPolicy.policyType of every AdultContent violation to AdultContentSexualSoft.
    • It writes reason = "Grok 4.5 Cross Validation disagreed" on the demoted violation and leaves it in the audit log.
    • It sets safemodel_sex_nudity.positive to false.

Four points in this design are worth noting:

  • There is only a demotion direction (hard → soft) and no escalation direction.
    • This judge layer works only in the direction of easing over-gating.
    • The code shows a policy direction aimed at reducing false positives rather than false negatives.
  • For a reply, the parent post is also put in as context, so the context "this is a reaction to that post" influences the verdict (include_reply_to=True).
  • In the judge context, the "do not expose reasoning" caution constraint in the original prompt is stripped by _strip_thinking_restrictions() so the reasoning can be seen (for audit purposes).
  • The verdict layers are now three: safemodel, PtOS, and the Grok 4.5 judge. This matches the direction of Gen4's "release of the whole labeling pipeline" trend.

Three questions need a deep dive:

  • Whether this judge layer expands to other categories (violence, hate, spam)
  • How much Grok 4.5 inference cost can be absorbed
  • How the EAPI_GROK_4_5_X_ALGO model differs from the existing EAPI_GROK_4_5_INTERNAL. Presumably the difference is x-algorithm-specific tuning versus a general internal judge.

The 2026-08-18 commit (Gen4+3) improved the logging of this task.

  • The outcome attribute of Metrics.counter(metric) changed from a binary "hard"|"soft" label to the raw policyType.value of the verdict verified — grox/flows/ptos/task_safety_ptos_adult_content_cross_validation.py:128-131
    • Even if the judge returns a policyType other than hard/soft (for example, a finer adult category split), it can now be observed without loss.
    • This is an adjustment to instrumentation granularity, not a change to the verdict logic itself.

Judge models and judged categories over time

With the changes through Gen4+7, X settled on a dual stack that keeps the primary and the judge on different model generations and tunings. The two call sites in the PTOS classifier that called Grok 4.5 internal (the child safety judge and the high-fav deluxe reclassification) moved to Grok 4.6 internal across three commits from 2026-08-19 to 08-21 verified (classifier in 28e414f). Three days later, in the 2026-08-24 commit (Gen4+7), the new judge class also came to use the 4.5 X_ALGO endpoint that the Gen4+2 adult content judge used, and the scope widened to 3 categories verified (classifier in d011592).

From Gen4+4 to +6, the two call sites that used 4.5 internal moved to 4.6 internal, and in Gen4+7 the child safety judge moved to the new cross-validator class and came to use 4.5 X_ALGO. The adult content judge has used 4.5 X_ALGO unchanged since Gen4+2 verified. The flow by commit:

  • Gen4+2 (b089ce6, 08-17): The adult content judge SafetyPtosAdultContentCrossValidationJudge was introduced (when safemodel and PtOS disagree). Its model is EAPI_GROK_4_5_X_ALGO, and this endpoint and its dedicated circuit breaker were also introduced in this commit verified — classifier.py:85, 100, 561.
  • Gen4+3 (11a71f8, 08-18): Refined the metric outcome of the judge above.
  • Gen4+4 (aad7179, 08-19): Attached Grok 4.6 internal with a 10% dial (two sites: high-fav deluxe reclassification and the child safety judge).
  • Gen4+5 (d0cef2f, 08-20): Raised the 4.6 internal dial from 10% to 30%.
  • Gen4+6 (28e414f, 08-21): Switched fully to 4.6 internal at 100%, and 4.5 internal was removed from the code entirely.
  • Gen4+7 (d011592, 08-24): The new cross-validator class also came to use EAPI_GROK_4_5_X_ALGO, which the adult content judge had used since Gen4+2, and the scope widened to 3 categories (child safety, violent media, illegal).
    • The adult content judge is still kept as a separate class (SafetyPtosAdultContentCrossValidationJudge).

If the judge were the same model as the primary, it would amplify the same mistakes, so the rationale of this design is to let the view of a separate model only demote.

The 2026-08-19 commit (Gen4+4) attached Grok 4.6 internal to 10% of traffic and added a cache to skip NSFW reclassification. verified

The verdict stack was refined in two directions.

  • First, it introduced Grok 4.6 to the child safety judge and the high-fav deluxe reclassification verified — grox/flows/ptos/classifier.py (+66/−7)
    • It registered the new model name ModelName.EAPI_GROK_4_6_INTERNAL and attached an EapiSampler instance and a dedicated circuit breaker (_eapi_4_6_internal_breaker).

    • The circuit breaker parameters are the same as 4.5 internal: a 50% failure rate, a 600-second window, 600 seconds of recovery, and 5 half-open calls.

    • Both call sites pick the model probabilistically.

      _GROK_4_6_INTERNAL_DIAL = 0.1
      ...
      if random.random() < _GROK_4_6_INTERNAL_DIAL:
          async with _eapi_4_6_internal_breaker.guard():
              raw = await self.eapi_4_6_internal.sample(...)
      else:
          async with _eapi_4_5_internal_breaker.guard():
              raw = await self.eapi_4_5_internal.sample(...)
      
    • The first site is the existing function _cross_model_validate_with_4_5, which handles cross-model validation for the child safety category. The metric name changed from safety_ptos.cross_model_validate_with_grok_4_5 to safety_ptos.child_safety_cross_model_validate_with_grok_4_5.

    • The second site is the deluxe 4.3 category reclassification inside classify_policy_for_violation. When fav_count >= 1024, it uses deluxe-4.6-internal with 10% probability instead of deluxe-4.5-internal.

      • This site is not a judge; it is a path where the first-pass classification of high-fav posts is done with this model instead of the regular sampler verified — classifier.py:461-489.
    • It is an exclusive traffic split that calls 4.6 with 10% probability per request and 4.5 for the rest verified — the code above. It is not dual-write or shadow, which would send the same request to both models.

    • The adult content judge introduced in Gen4+2 is not a target of this dial.

  • Second, post_is_already_flagged_nsfw, which looks up whether a post is already flagged NSFW, was split out into a new module verified — grox/flows/ptos/prior_nsfw.py (34 lines, new)
    • The new module prior_nsfw.py checks with StratoSafetyPostAnnotationsResultDirectMh (which looks up safety annotation results from X-internal Strato http/Manhattan).
    • It caches the result in state.prior_nsfw so it does not look up again within the same task context.
    • If the lookup fails, it increments the counter safety_ptos.prior_nsfw_lookup_error.count and falls back to not-flagged.
    • This appears to be a refactor of what the Gen4+2 cross-validation task used to look up into a separate module inferred.
    • The sync_task_kwargs of the cross-validation task was changed to import post_is_already_flagged_nsfw.

Why it matters: The PTOS verdict layer is being organized around a traffic split for the 4.5 → 4.6 model switch and a separate cache for the prior classification lookup. Across the three commits Gen4+2 → +3 → +4, this verdict stack is seen being refined daily.

The 2026-08-20 commit (Gen4+5) raised the Grok 4.6 internal dial from 0.1 to 0.3 (3× in one day). verified — grox/flows/ptos/classifier.py:114

-_GROK_4_6_INTERNAL_DIAL = 0.1
+_GROK_4_6_INTERNAL_DIAL = 0.3
  • The _EAPI_4_6_INTERNAL_BREAKER_CONFIG circuit breaker parameters and the places it applies (child safety cross-model validation, deluxe 4.3 category reclassification) are unchanged.
  • Meaning: this promotion supports that 4.6 traffic did not trip the circuit breaker and that the verdict disagreement rate was within the acceptable range inferred. Otherwise a 3× dial promotion would not be possible.
  • Introduction of the Gen4+2 adult content judge → +3 logging refinement → +4 4.6 dial 10% → +5 dial 30%: the PTOS verdict stack changed every day. The 4.6 dial applies to the child safety judge and the deluxe reclassification, not to the Gen4+2 judge.

The 2026-08-21 commit (Gen4+6) removed Grok 4.5 internal completely and switched to 4.6 internal at 100%. verified — grox/flows/ptos/classifier.py (+6/−63)

The Gen4+5 watch point ("if it jumps to 50%/100% in the next commit, a full 4.6 switch is near") was resolved at 100% in one day. Seven items were deleted:

  • _EAPI_4_5_INTERNAL_BREAKER_CONFIG
  • _eapi_4_5_internal_breaker (breaker instance)
  • The _GROK_4_6_INTERNAL_DIAL constant (0.3 a day earlier)
  • self.eapi_4_5_internal (initialization removed from two classes)
  • _sample_4_5_internal method
  • The probabilistic branch in _cross_model_validate_with_4_5 (now always calls 4.6 internal directly)
  • The probabilistic branch for the deluxe 4.3 reclassification in classify_policy_for_violation (now always deluxe-4.6-internal)

Why it matters: The 4.6 internal dial-up (10%→30%→100%) that started in the 2026-08-19 commit (Gen4+4) finished in two days. Grok 4.5 internal is gone from both the classifier code and the ModelName list in the grox config, so it cannot be rolled back verified — grox/config/config.py:82-93. The child safety judge and the deluxe reclassification are unified on Grok 4.6 internal, and the adult content judge continues to use EAPI_GROK_4_5_X_ALGO. The watch point moves from 4.5→4.6 to the timing of the 4.6→4.7 switch.

Two other places in grox changed in the 2026-08-20 commit (Gen4+5), one day earlier verified — the diff between aad7179 and d0cef2f.

  • A stop_predicate callback and aclosing were added to grok_sampler verified — grox/libs/grok_sampler/llm.py (+27/−13)
    • It wraps the raw stream in an async with aclosing(...) context, which guarantees cleanup even on early termination.
    • After yielding each token, it checks stop_predicate(resp), and if True, it increments the llm.sample.early_stop.count counter and breaks.
    • When a safety verdict response arrives in <json>...</json> tags, the tokens after the tag closes are meaningless. Early termination reduces token cost and latency.
    • This is a sampling infrastructure optimization matched to the growth in 4.6 traffic inferred.
  • A profile_image field was added to the grox User schema verified — grox/core/data_loaders/data_types.py
    • The mapping is in grox/core/data_loaders/post_mapper.py. If AuthorMetadata.profileImageUrl exists, it fills in Image(url=...).
    • The Grok classifier can now reference the profile image as verdict context. For example, the profile image also becomes input for judgment in ads brand safety or the adult content judge.
    • This is a step that prepares the upstream data connection inferred.

The 2026-08-24 commit (Gen4+7) created SafetyPtosPolicyCrossValidator and also used, for this judge, the Grok 4.5 X_ALGO endpoint that had existed since Gen4+2. verified — grox/flows/ptos/classifier.py (+223/−52)

Three days after Gen4+6 removed Grok 4.5 internal completely, the 4.5 X_ALGO endpoint was also used for the new verdict judge (cross-validator). The scope widened to 3 categories.

  • The endpoint is ModelName.EAPI_GROK_4_5_X_ALGO, and it uses the circuit breaker _eapi_4_5_x_algo_breaker (window 600 seconds, recovery 600 seconds, half-open 5 calls) verified — same commit. Both were already created in Gen4+2 (b089ce6) for the adult content judge.
    • It is a separate model from EAPI_GROK_4_5_INTERNAL, which was deleted completely in Gen4+6.
    • The X_ALGO suffix in the name suggests an x-algorithm-specific tuned variant inferred.
  • The entry point validate(category, post, policy) of the new class SafetyPtosPolicyCrossValidator routes the three categories to dedicated methods verified — same commit.
    • ChildSafety (_validate_child_safety) is rechecked with 4.5. If the policy type matches, it is kept; if not, it is only demoted to NoViolation. Parse failures and errors also fall back to NoViolation. This absorbs the Gen4+2 child safety judge into this class.
    • ViolentMedia (_validate_violent_media) is rechecked with 4.5. If it matches, it is kept; if the judge decides NoViolation, it is demoted; if type_mismatch, it is downgraded to ViolentMediaGraphicMedia (the weaker form). The demotion options were refined into three: NoViolation, GraphicMedia, and keep.
    • IllegalAndRegulatedBehaviors (_validate_illegal_and_regulated_behaviors) is rechecked with 4.5. If it matches, the policy type is kept but the reason is overwritten with the one 4.5 gave; if not, it is demoted to NoViolation.
  • All three categories build a standard prompt with _build_policy_convo and apply _strip_thinking_restrictions (reasoning enabled) verified — same commit.
    • The prompts are child_safety_policy_prompt(post_creation_time), violent_media_policy_prompt(), and illegal_and_regulated_behaviors_policy_prompt().
  • It keeps the demotion-only principle verified — same commit. This is the same design philosophy as the Gen4+2 adult content judge.
    • The cross-validator cannot produce a stricter verdict than the primary.
    • The judge does not newly catch harmful content the primary missed; it works only in the direction of releasing what the primary falsely flagged.
  • The metrics are counters safety_ptos.<category>_cross_model_validate_with_grok_4_5 for each of the three categories verified — same commit.
    • The counters carry an outcome tag (agreed/disagreed/unparseable/no_violation/type_mismatch/error).

In the same commit, the classifier turned on reasoning only for the two categories ViolentSpeech and SuicideOrSelfHarm, and made it include the entire thread when a parent thread exists verified.

  • SafetyPtosPolicyClassifier.build_convo() applies _strip_thinking_restrictions to the prompt only for these two categories (reasoning enabled) verified — same commit.
    • Here the assistant message is left empty so the model can think freely.
    • The set is USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm}.
    • Other categories still suppress thinking with THINKING_CONTROL_END (fast and cheap responses).
  • Only for the same two categories, if the post has ancestors, the whole parent thread is rendered as the user message with ThreadRenderer verified — same commit.
    • The set is USE_THREAD_RENDERER_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm}.
    • The reason is fundamental. A sentence like "I want to die" cannot be judged without the thread context. If the earlier remark was about losing a game, it is harmless; if it is a self-harm support thread, it is serious.
    • Likewise for violent speech, the quote and reply context decide the verdict.
    • For other categories (adult, NSFW media, illegal), the target post itself carries the signal, so thread expansion is not needed.

Four things are not public as of the Gen4+7 commit:

  • Whether EAPI_GROK_4_5_X_ALGO is the 4.5 base plus x-algorithm domain fine-tuning or a completely separate model
  • The A/B results of the three-category expansion
  • The next expansion candidates (spam, hate speech)
  • The relationship between SafetyPtosAdultContentCrossValidationJudge and the new SafetyPtosPolicyCrossValidator (whether they will merge or be kept in parallel)
    • The two classes have the same function signature (judge vs validate) but different routing approaches.

Changes to the grox reply spam path and LLM input

The grox reply spam pipeline has had a path that writes the reply ranking score back to Strato since the Gen4 release (2026-08-13), and the 2026-08-24 commit (Gen4+7) added regional cache fanout to this path verified(storage module at 47c1bcd, storage module at d011592). As of the 2026-08-28 commit (Gen4+10), grox skips the Manhattan write when the new reply ranking score is not lower than the existing score verified(write task at 24c6094). In other words, the reply ranking score sent out on this path only goes down over time and never goes up verified(write task at 45b48ba).

The 2026-08-24 commit (Gen4+7) also changed three other places in grox.

  • The follower threshold of the reply spam filter and the reply ranking filter rose from 80k to 100k (grox/flows/reply_spam/task_filter.py:17).
    • The two filters are TaskSpamFilter and TaskReplyRankingFilter in grox/flows/reply_spam/task_filter.py.
    • The threshold rose five times in 11 days (15k → 30k → 40k → 60k → 80k → 100k), so the set of accounts covered by spam verdicts kept widening.
    • The increases after Gen4+3 are 1.25–1.5×. The higher the threshold, the more threads of accounts with more followers grok checks with grok, so we infer this adjustment aims to check more spam replies in threads of large accounts inferred.
  • The reply ranking score storage module now also writes the score to two regional caches verified — grox/flows/reply_spam/strato_loader.py (+17/−0).
    • This file and the ReplyRankingScoreStratoLoader class have existed since the Gen4 release (32 lines) verified — same file at 47c1bcd.
    • Since then it has stored the score with StratoReplyRankingScore.put() and emitted an event with StratoReplyRankingScoreV2Kafka.insert().
    • Gen4+7 added one more stage after put(). If score ≤ 1.0, it writes to two regional caches (StratoReplyRankingScoreCacheAtla, StratoReplyRankingScoreCachePdxa) in parallel.
    • If the score exceeds 1.0, the cache fanout is skipped verified — strato_loader.py:20, 34-41. Low-confidence verdicts stay only in the canon (strato), and the serving cache is not polluted inferred.
  • X Spaces (audio) context entered the LLM verdict. The Grok safety judge recognizes posts with an audio space card attached for the first time.
    • A SpaceMetadata class was added to grox/core/data_loaders/data_types.py (a single title as of this commit). A space_metadata: SpaceMetadata | None field was added to Post.
    • post_mapper.py maps it from StratoSpaceMetadata.
    • The grox/core/lm/post.py renderer inserts it into the LLM prompt as \n\nThis post has the following audio space card attached:\nAudio Space title: {title}.
    • It does not listen to the audio content itself, but the space title text becomes input to the verdict. For example, if the topic stated in the space title is adult content or violence, it is reflected in the brand safety verdict.

Gen4+9 (2026-08-26, 45b48ba) added an option to put the follower count and bio into the reply_spam LM prompt and put a monotone-only gate on the Manhattan write. verified

  • The user metadata going into the LM verdict was widened verified — grox/core/lm/post.py (+18/−6), grox/core/lm/thread.py (+25/−4)
    • The previous PostRenderer.render extracted user metadata as a single line, Metadata: <name> @<handle>. It now takes two options, include_follower_count and include_bio, and renders it split as follows:

      User Handle: @<handle>
      [User Follower Count: <N>]     # include_follower_count=True
      User Name: "<name>"
      [User Bio: "<bio>"]              # include_bio=True
      
    • When ThreadRenderer._render_thread receives the include_follower_count argument, it renders the follower count of every post in the thread. It renders the bio only for post 0 (the original post, the thread root) (include_bio=include_follower_count and post_idx == 0).

    • The follower count is also included for the reply under evaluation (the evaluate target).

    • A header # Reply Author Info\n\nThe user profile and any signals below describe the author of the reply being evaluated (the final post of the thread).\n is prepended to the message. This header tells the LLM explicitly that "this information is about the author of the reply being evaluated."

      • Below the header, UserRenderer.render(post.user) separately renders the name, handle, and bio of the reply author verified — thread.py:25-27, user.py:18-24. So the bios of two people are included: the author of the original post (post 0) and the author of the reply under evaluation.
    • Implications:

      • The follower count was already used as the grox reply_spam gating threshold (100k in Gen4+7), and now the LM verdict itself also references this signal directly.
      • Bio is strong evidence for spam and bot detection, so exposing the profiles of both the original post author and the reply-under-eval author together is expected to raise confidence.
      • The bio content that serves as evidence includes spam hints such as "link in profile", affiliation with political or commercial organizations, and interests.
      • Bios of other thread participants are not included (to prevent context explosion), and only the two authors' profiles are expanded.
  • The reply ranking score sent to Manhattan only goes down over time and never goes up verified — grox/flows/reply_spam/task_write.py (+22)
    • Inside _publish_to_reply_ranking_manhattan, it looks up the existing score with ReplyRankingScoreStratoLoader.fetch_reply_ranking_score(post.id).
    • If the new score is greater than the existing one, it skips the write and increments Metrics.counter("task.write_reply_ranking_manhattan.skipped.count", reason="higher_than_existing"). If it is lower, it writes as is.
    • Implications:
      • If a reply's first verdict comes out low (close to spam), that reply keeps a low score. Even if a new verdict becomes lenient, the serving score is not updated.
      • Conversely, a reply whose first verdict comes out high (lenient) is lowered if a later verdict becomes stricter.
      • This is consistent with the monotone safety direction of the reply spam verdict (once caught as spam, always treated as spam; only normal → spam reclassification is allowed).
      • It eliminates label flapping of the reply ranking score along the time axis at the source, so the ranking of the same reply does not go up and down in the UI or in A/B tests inferred.
      • Label flapping was likely actually observed in the reply ranking channel inferred.

Gen4+10 (2026-08-28, 24c6094) added a Kafka-first storage order and tie skipping, and made the follower count always injected into the coordinated spam verdict. verified

_publish_to_reply_ranking_manhattan was tightened in three ways on top of the monotone-only logic of Gen4+9 verified — grox/flows/reply_spam/task_write.py (+15/−21)

  1. Ties are not written either. The comparison operator changed from > to >=, so it skips if score >= existing.score.
    • Previously, when the new score equaled the existing one, it was written anyway (idempotent write).
    • When exactly the same score is recomputed, it saves the Manhattan put, and no Kafka event is emitted either.
    • The counter name also changed from higher_than_existing to not_lower_than_existing to make the semantics clear.
  2. The Kafka event moved ahead of the Manhattan write. The previous order was save_reply_ranking_score(Manhattan) → save_reply_ranking_kafka_v2(Kafka), and the new order is the reverse: Kafka publish first, Manhattan write second.
    • Reason inferred: Kafka is append-only and can be retried on failure, whereas Manhattan can hit the monotone-only logic and get a not_lower_than_existing verdict, so the retry itself may be skipped. Manhattan is also an upsert with side effects, so it is heavier than a Kafka publish. If Kafka succeeds first, the offline reprocessing pipeline can later recover a Manhattan write failure; if Manhattan succeeds first and the Kafka publish fails, downstream (serving cache warm-back, other consumers) cannot see the new score already reflected in Manhattan. In other words, Kafka-first is the durability order that guarantees consistent observability across the whole system.
  3. The try/except around fetch_reply_ranking_score was removed. Previously, if the fetch failed, it logged a warning and proceeded with existing = None, so it always wrote. Now it raises the exception upward as is, so the flow task is marked as failed and retried.
    • This blocks at the source the incident where a fetch fails silently, the monotone comparison is nullified, and an unexpected write goes through.
    • Defensive exception handling was removed, prioritizing logic correctness.

In the same commit, the follower count option of Gen4+9 was adopted as always on in the coordinated spam verdict verified — grox/flows/reply_spam/classifier_coordinated_spam.py (+5/−1)

  • An argument was added to _to_convo in classifier_coordinated_spam.py. It now calls PostRenderer.render(p, max_media=MAX_MEDIA_PER_POST) with include_follower_count=True.
  • The coordinated spam verdict receives the follower count of each participating account in the thread in the prompt. Bio keeps the include_bio=False default, and only the follower count is included.

Why it matters: The two axes Gen4+9 introduced changed in Gen4+10 as follows. The LM prompt user metadata expansion became always on. Manhattan monotone-only gained consistency through stronger idempotency and the Kafka-first order. The durability contract of the reply_spam verdict stack became much more explicit.

↑ Contents

BDSM (abusive account detection from account behavior sequences) — enforcement note generation logic partially released in Gen4+1

BDSM is an account scorer that extracts abuse signals from an account's time-series behavior sequence. As of the c65aa17 commit (Gen4+1), the enforcement note generation logic is partially public verified(commit summary table). The logic for the conditions to create a note and for choosing the bot heads to put in the note is public, while the template string and the tuned verdict thresholds are not public verified(resolution record). The note carries a score for which bot family was triggered and how strongly, but the text explaining which action pattern caused it is not public verified(BDSM section text).

From Gen3 through early Gen4, build_enforcement_note at bdsm/runtime/score_results_sink_focal.py:78 was a stub that only did return None. The Gen4+1 commit released the gate logic and the dominant-head selection logic, leaving only the template string as a "<redacted>" sentinel verified — bdsm/runtime/score_results_sink_focal.py:78-128, bdsm/README.md, bdsm/proto/abuse_inference.proto:98-102.

  • A note is created only for a sequence with 30 or more actions in which some head other than LegitimateUser has a score above 0.5 verified — the three files in the previous paragraph.
    • The action count condition is MIN_ACTIONS = 30, total_actions >= 30, and the score condition is score > 0.5.
  • If the conditions pass, the head with the highest score is chosen as dominant, and the note string carries the names and scores of up to the top 3 heads verified — the three files in the previous paragraph.
    • The note string has the form "<redacted> [model: FollowBot=0.91, also: ReplySpamBot=0.60, LikeBot=0.53]".
    • Which bot family was triggered and how strongly goes out in the note as is, but the text explaining which action pattern triggered it is not public.
  • Seven bot heads were officially released verified — the three files in the previous paragraph.
    • The names are FollowBot, LikeBot, EngagementAmplifier, ReplySpamBot, TweetSpamBot, RTBot, and MultiActionBot.
  • The 9.99 filling the operating point (verdict threshold) in sink_policy.yaml is a sentinel, and the README states this explicitly verified — bdsm/runtime/sink_policy.yaml:5-13.
    • The operating point is a probability in the range [0, 1], so when it is filled with 9.99 it never triggers.
    • This means the gating structure and head names are real, and the tuned real numbers were not released, to prevent gaming.
  • Note that the head score carried in the note is a bot probability, not a "report reviewer decision" verified — the three files in the previous paragraph.

↑ Contents

How to read the source — caveat list

This list collects points of caution so that one does not judge "it is in the code, so it is true" when reading X's public source. As of the 2026-08-15 update, weights and constant values are filled in Gen4 xai-org/x-algorithm, and Phoenix can also be fully built, but it is not production scale verified(item 1). The score post-processing utilities and external service client modules that the Gen3 canon regarded as outside the repo are also included in the Gen4 release verified(item 1). In Gen3 code, files not connected to any module and files with syntax broken by literal substitution were also observed verified(items 2 and 3).

  1. xai-org/x-algorithm did not build through Gen3, and Gen4 partly resolved this. ⚠ This item was updated on 2026-08-15.

    • crate::params, missing in Gen3, was filled in Gen4 as home-mixer/params/param.rs verified.
      • This file is 1084 lines and is a production cron mirror of the weights and constants.
      • The canon's Gen3 observation that symbols like FavoriteWeight have only call sites and no definition is resolved verified.
    • crate::util, missing in Gen3, was filled in the Gen4 release as home-mixer/util/ verified — home-mixer/lib.rs.
    • crate::clients was also filled in the Gen4 release as home-mixer/clients/ (19 files) verified — home-mixer/clients/.
    • Phoenix became fully buildable in Gen4 verified.
      • It comes with a Cargo workspace, pyproject.toml, QUICKSTART, and synthetic data.
      • However, the README states explicitly that a small model can be trained and served: "a small model can be trained and served". It is not production scale verified.
    • Whether the neighboring crate (component_library) is still unreleased in Gen4 needs a deep dive.

    Reflecting the changes above, we redefine the character of the repo. Through Gen3 it was "only the shape of the logic released, values and wiring missing", and Gen4 moved to "values released + safety rules released + Phoenix runnable end to end, but the service glue layer still undisclosed". The missing parts are not accidental omissions but still show a consistent character.

  2. In Gen3 (0bfc279) code, the three scorers named in the README were cut off from the module tree. The README wrote "Weighted Scorer / Author Diversity Scorer / OON Scorer", but the actual pipeline was vec![phoenix_scorer, ranking_scorer, vm_ranker] verified — phoenix_candidate_pipeline.rs:299-300 vs README.md:251-255

    • scorers/mod.rs does not declare the three files, and there are 0 external references verified.
      • The three files are weighted_scorer.rs, author_diversity_scorer.rs, and oon_scorer.rs, and the struct names are WeightedScorer/AuthorDiversityScorer/OONScorer.
    • Yet those files contain hardcoded constants, so a reader following the README would mistake unreachable constants for production values verified.
      • An example of a hardcoded constant is p::AUTHOR_DIVERSITY_DECAY.
    • These three are not the only unconnected files. There are 11 .rs files in home-mixer/ not hooked by any mod declaration verified.
      • The 11 files are the following:
        • candidate_hydrators/engagement_counts_hydrator.rs
        • candidate_pipeline/{candidate,candidate_features,query,query_features}.rs
        • query_hydrators/{user_action_seq,user_features}_query_hydrator.rs
        • scorers/{author_diversity,oon,weighted}_scorer.rs
        • side_effects/cache_request_info_side_effect.rs
    • How to tell (Gen3 basis): dead files import crate::candidate_pipeline::{candidate, query}, while the live ranking_scorer.rs imports crate::models::{candidate, query}. Types with the same name exist in both directories, and models/ is current and candidate_pipeline/ is the older generation inferred. This was inferred only from the direction of the connections, and the commit history has no clue.
    • pub(crate) mod is also a declaration. ads/util.rs is hooked in ads/mod.rs:3 by pub(crate) mod util;, so it is live verified.
      • Counting with a regex like ^\s*(?:pub )?mod wrongly judges this file dead verified.
    • In the Gen4 release (47c1bcd), 10 of these 11 files disappeared from the repo, and engagement_counts_hydrator.rs was declared at candidate_hydrators/mod.rs:6 and connected verified. The README of the same release also no longer lists these three scorers verified — README.md.
  3. In Gen3 (0bfc279) code, there were places where string and number literals were mechanically replaced with "". Syntax was not checked, so some files had broken compilation or parsing verified.

    • For example, in grox/classifiers/content/safety_ptos.py:241-244, and is left without an operand, causing a SyntaxError, so the PTOS plan itself cannot be imported verified.
    • grox/tasks/task_summarizer_for_post_embedding.py:14 throws FileNotFoundError at import time, so Grox cannot start at all verified.
    • Neither file exists in the Gen4 release (47c1bcd) verified.
  4. git log --diff-filter=D <commit> shows the deletions of all ancestors, not the deletions of that commit. It would be wrong to conclude from this command's result that "the 2025 refresh deleted DiversityScorer.scala". That file was deleted on 2023-07-07 verified.

  5. If the the-algorithm-ml clone is a shallow clone, every file looks like a "new file". Weight history must not be read from such a clone verified.

  6. has_cached_posts is the most impactful switch, but it is a cache threshold, not a model. When 500 or more posts are stacked in the Redis warming cache, this boolean turns on, and then the retrieval sources and many hydrators turn off verified — cached_posts_query_hydrator.rs:11, 69

    • As of the 2026-10-17 commit (862a123), 25 files in home-mixer have a !query.has_cached_posts gate. These include 8 retrieval sources and 12 candidate hydrators verified — 862a123 home-mixer/ grep. In Gen3 code (0bfc279) there were 18 files.
    • On the same basis, the Phoenix scorer also has a has_cached_posts branch verified — phoenix_scorer.rs:70. EngagementCountsHydrator dropped this gate in the 2026-09-04 commit (Gen4+16) (cold start slot selector).
    • Much of "load more" during scrolling is answered from leftover candidates of the previous request without model inference verified.
    • How large that share is cannot even be inferred. The repo has no clue.
  7. In Gen3 (0bfc279) code, CoreDataHydrationFilter, despite its name, does not actually filter out candidates whose core data lookup failed. This is because the hydrator does not use author_id verified(code) — core_data_candidate_hydrator.rs:108-113, filters/core_data_hydration_filter.rs:13

    • The README describes this filter as "removes posts whose core metadata hydration failed".
    • We regard it as a bug inferred. Given the code flow, another explanation is hard to find.
  8. In Gen3 (0bfc279) code, all failures are silent. There is no framework-level timeout, circuit breaker, or fallback verified — candidate-pipeline/candidate_pipeline.rs:202-218, 266, 419-428

    • Sources and query hydrators that error out disappear verified.
    • If a candidate hydrator errors, the field is left unset verified.
    • Side effects run detached verified.
  9. In Gen3 (0bfc279) code, logging is sampled. The two richest log streams are unrepresentative slices verified — side_effects/reranking_kafka_side_effect.rs:14,29

    • The reranking Kafka log samples 5% in production and keeps only the top 50 verified.
    • The other one is kept only in shadow traffic verified.
  10. In the 2025 Scala code, some comments disagree with the code. Two comments on the low-signal user gate say "< 25 users", but the actual constant is SmallFollowGraphSize = 5. Quoting the threshold from the comments is off by 5× verified — SignalUtils.scala:17-23 vs AllowLowSignalUserGate.scala:12, DenyLowSignalUserGate.scala:12

  11. There is no single value called "ranker score". The 2025 MMR and the 2026 VMRanker both overwrite the score field. When looking at a score in logs or code, check which stage's value it is. This is why the 2026 code keeps weighted_score and score as separate fields verified — DiversityRescoringFeatureHydrator.scala:70, vm_ranker.rs:47-52, ranking_scorer.rs:277-281(both Gen3 code). In the 2026-10-17 commit (862a123), VMRanker also fills the two fields separately (vm_ranker.rs:93-131).

↑ Contents

Generalizations from this service

Among the techniques observed at X, those that span several services are collected separately in topic notes in the topics folder. Each note places X's cases side by side with cases from other services such as Meta.

Note What it covers
Multi-task ranking Prediction head design, why weights were not public through Gen3
Candidate allocation How many candidates each candidate source contributes when there are several, and the misconception that in-network and OON posts are mixed "50:50"
Feed diversity Decay, MMR, DPP, dedup
Embedding-based candidate generation SimClusters, TwHIN, two-tower, ANN index
Sequence Transformer ranking Structure of Phoenix
LLM in the recommendation pipeline Grok/Grox annotation pipeline

↑ Contents

Open questions

Of the questions that X's public code has still not answered since the Gen4 release (2026-08-13), the most recent are those left by the 2026-10-16 and 10-17 commits (Gen4+43 and +44) (Gen4+43 in the change log, Gen4+44). Before that is the deep dive task left by the 2026-10-14 commit (Gen4+41) (Gen4+41 in the change log). The feed request context values added to the ranking request have been filled only for ads index requests since the 2026-10-13 commit (Gen4+40) verified(Gen4+40 in the change log). As of the same commit, the Phoenix ranking request side still has no answer.

Unresolved and needing deep dive (after the Gen4 release)

These are questions that public code still could not answer after the Gen4 release, or that need a deep dive. The struck-through item was resolved in the 2026-09-17 commit (Gen4+23).

  • What percentage of actual impressions does the has_cached_posts path account for? Is there any way to estimate it from outside? The repo still has no clue, so even inferred is not possible.
  • The details of the SimClusters core algorithm are not yet confirmed. The open points are the SVD pipeline, k, the index format, and the update cycle. The simclusters/ sub-repo needs a deep dive.
    • It also asks whether a Gen4 component corresponds to SimclusterColdPostsCandidateSource.
  • What are the details of the agatha/bdsm/user-cred-v2 account scorers? The open points are each scorer's actual model, update cycle, and label threshold.
  • How is pnsfwmedia calibrated? The open point is how it combines the CLIP embedding with the agatha score.
  • What are the details of the phoenix/ training pipeline? The open points are the loss, negative sampling, and the synthetic data format. The optimizer was released in the 2026-08-19 commit (Gen4+4) (training code).
  • How do the phoenix-rankall/ and phoenix-rankall-strato/ indexing pipelines work? The open points are the visibility pre-lookup logic and the details of the event layer.
  • In abuse-enforcement-service/, which score maps to which action (label/challenge/suspension)?
  • What are the DSL and rule categories of scarecrow/botmaker/botmaker-rules? The ratio of public to undisclosed rules is also an open point.
  • What is the label taxonomy in under-the-hood/? The open point is how the labels shown to users connect to the rule code.
  • The holdout ratio params of InventoryHoldoutFilter: resolved in the 2026-09-17 commit 42266f3 (Gen4+23), which confirmed three params (the 21 filters section).
    • The three params are InventoryHoldoutOriginalsPercent, InventoryHoldoutRepliesPercent, and InventoryHoldoutRetweetsPercent.
  • Where is the RECSYS_EMBED_V4 multimodal embedding used? Not in home ranking.
  • What is the architecture of the 2025 Scala-generation heavy ranker (Navi)? The open point is the details of the MaskNet/DCN family. The model definition is on the the-algorithm-ml side and must be checked separately.
  • What do the Phoenix retrieval two-tower structure and the ANN index look like? I have read phoenix/recsys_retrieval_model.py. The serving-side index was partly released in Gen4 (phoenix-rankall/). The whole must be verified again.
  • Was the dual layout of models/ and candidate_pipeline/ in the 2026 generation cleaned up in Gen4? This still needs to be checked.
  • Was the weight noise injection of Gen2 kept in Gen4? The Gen2 noise is a Beta distribution in HeavyRankerWeightsQueryFeatureHydrator.scala. Whether it was kept needs a deep dive.
  • The 2026-10-08 commit (Gen4+38) left four deep-dive tasks (change log: 35650fb).
    1. The final response size of the video carousel, and whether it overlaps with the main feed
    2. Whether VF hydration/sources/exchange.rs (528 lines) and clients/user_location_client.rs (345 lines) add new location-based verdicts
    3. The new hydration scheduler in phoenix-rankall/src/hydrate/* (about 1,300 lines)
    4. Whether the MoL two-tower is turned on in the actual experiment config
  • Where are FeedRequestContext and FeedCursorDirection, which the 2026-10-09 commit (Gen4+39) added to the ranking request, filled and where are they read?
    • We need to check when the code in which the Phoenix ranker reads these values as features will be released, and from which request information home-mixer fills them.
    • In Gen4+40 (e1a0375), home-mixer started building the value with RequestContext::parse(query.request_context) and filling it only in ad index requests verified.
    • The Phoenix ranking request side is still unresolved.
  • The 2026-10-13 commit (Gen4+40) left four deep-dive tasks (change log: e1a0375).
    1. The verdict threshold of CLIENT_TWEET_RECAP_DWELLED in the two-tower ld head, and whether the combined preset is production retrieval
    2. The service that calls the reply ranker reply_two_tower_v2, and its connection to the grox reply score
    3. Whether the abuse-enforcement action_control and off_switch (about 6,000 lines) and the VF explain.rs and rules/levels.rs refactors include a policy change
    4. Which side is the serving value for the four weights that diverged between the home-mixer and vm-ranker mirrors (PostUnexploredWeight, favorite, video_open, cont_dwell_time)
  • The 2026-10-14 commit (Gen4+41) left four deep-dive tasks (change log: 17c96bf).
    1. Whether any serving instance turns on serve_page_decoder (off by default), and which home-mixer stage consumes the decoding result
    2. The training log already has rl_rerank_prob and rl_decode_prob; what is the behavior policy that produced these values
    3. Whether immersive_page_rl is only for the immersive video surface or is also used in Home For You
    4. Whether any experiment turns on TopicDppEnabled
  • The 2026-10-16 and 10-17 commits (Gen4+43 and +44) left four questions (change log: bc3948b, 862a123).
    1. Which encoder the Phoenix model uses to read TweetInfo.postEngagers
    2. The home-mixer call site that fills RankingScoreWeights
    3. Whether the head that uses the IsBrandEngagement label is the ads ranker
    4. The author bucket scale of the ZeroFav two-sided experiment
  • The 2026-10-20 commit (Gen4+45) left two questions (daily 2026-10-21).
    1. For which re-ranking objective VMRanker uses the viewer-history topics carried in the request
    2. How the history aggregation DENSE_WITH_LONG_DWELL_HOME_EXCURSION, used by the shadow cluster Experiment16Fou, differs from the existing aggregations
  • What does X change in its EU DSA transparency report? The question is what X newly submits or deletes after this 8-13 release. Whether the regulatory documents and the code match (sync) is a new verification angle.

Resolved by the Gen4 release (2026-08-15)

The Gen4 release resolved the questions about the actual weight values, whether VMRanker and DPP are active, and the number of Phoenix heads. The training pipeline and cold start questions were resolved in part, with some remainder.

  • ✅ Actual weight values: home-mixer/params/param.rs was released as a production cron mirror.
  • ✅ Whether VMRanker/DPP are on: they are on (EnableVMRanker=true, DPP θ=0.65, K=150).
  • ✅ Number of Phoenix heads: confirmed as 24 per the documentation.
    • The composition is Engagement 7 + Clicks 6 + Attention 5 + Author 1 + Negative 5.
  • ✅ 2026 Rust training pipeline: phoenix/ has the training code, synthetic data, and a QUICKSTART. However, the loss, negative sampling, and optimizer need a deep dive.
  • ✅ Absence of new-item cold start: Gen4 released a new-author boost (PostUnexploredWeight=0.02, ColdStartImpressionThreshold=1000). However, there is still no separate mechanism for individual new posts.

Resolved by the Gen4+1 release (2026-08-16)

This release resolved the questions about the rule for reading weight ratios, the cold start slot selector, and the jurisdiction filter. The Phoenix bool features and the BDSM enforcement note were resolved only in part.

  • ✅ Rule for interpreting weight ratios: the comments in param.rs and ranking_scorer.rs and the README state explicitly "do not read the ratio as a raw count equivalent".
    • Through Gen3 the canon cited documents that interpreted this as "reply 13.5 = the value of 27 favorites". It is now confirmed that this interpretation was a framing that was wrong in principle.
  • ✅ Fill status of the Phoenix bool features: the observation that all of bool_features are zero-stamped was partly resolved in Gen4+1.
    • Three were filled (IS_STALE_POST14D and IS_AUTHOR_FOLLOWED/FOLLOWING).
    • Whether the remaining bool slots are used is still not public.
  • ✅ Cold start slot selector logic: two branches, greedy and Beta Thompson sampling, were connected through params. However, Thompson is off by default, and actual production is still greedy.
  • ✅ Existence of a jurisdiction filter: confirmed at the code level through Brazil2026ElectionFilter. Whether election and regulation responses for other countries will be attached by the same practice is a new checkpoint to watch.
  • ✅ BDSM enforcement note generation logic: the gate and dominant head logic were released. The template strings and the operating point values are still not public.

Resolved by the Gen4+2 release (2026-08-19)

This release revealed how Grok safety labels are migrated, how conflicting adult content verdicts are handled, and the indexing gate of Phoenix retrieval. The judgment on the first item was overturned the next day.

  • ✅ Schema migration practice for Grok safety labels: it was confirmed that the label schema is managed by version and that its migration is built into dual-write detection logic in the code. Caution: this judgment was overturned by a redesign in Gen4+3 (the next day)(Gen4+3 record).
    • compute_verdict_v2 references six _V2 labels.
    • strip_v1_grok_written filters out V1 dual-write labels that belong to PROMPT_OWNED_RULES = [1400, 1410, 1420, 1500, 1510, 1610, 1700].
    • The v2_mirrors_v1_across_tier_matrix test asserts V1/V2 equivalence across 6 label combinations and 2 tweet_id points.
  • ✅ Handling when adult content verdicts conflict: when safemodel and PtOS disagree, the Grok 4.5 judge re-decides, and only hard→soft demotion is allowed.
    • safemodel is an in-house vision model, and PtOS is a policy verdict. There is no escalation.
    • The verdict layer expanded to three layers (safemodel, PtOS, the Grok 4.5 judge).
    • A dedicated circuit breaker for EAPI_GROK_4_5_X_ALGO was added, and the audit log states the text "Grok 4.5 Cross Validation disagreed" explicitly.
  • ✅ Indexing gate of Phoenix retrieval: the favoriteCount >= 1 condition was removed from build1FavIndex and buildMMEmbMetadataDump. The actual index is all incoming candidates.
    • The index name "1fav" became a backward compat label.
    • The 1fav_uec_count_race counter instruments only the cases reached without a fav.
  • Deep dive needed (new):
    • Whether the Grok 4.5 judge expands to other categories
    • The actual observed rate of 1fav_uec_count_race (not observable from outside)

Resolved or overturned by the Gen4+3 release (2026-08-20)

This release overturned the dual-write judgment on brand safety V2 that was considered settled in Gen4+2 the day before. It also confirmed the SID connection in VMRanker, the hydration branch between For You and Following, the move of the Phoenix event stream to v3, and the merge of the grox reply_spam flow.

  • ⚠ Claim changed (in one day): the V2 brand safety dual-write logic was completely redesigned.
    • The Gen4+2 approach was strip_v1_grok_written and seven PROMPT_OWNED_RULES rule ids. The Gen4+3 approach is four V2_WRITTEN_LABELS gates and V1 fallback semantics.
    • The seven PROMPT_OWNED_RULES disappeared from the code entirely. The deep-dive item that asked about their rule id mapping was resolved automatically (the question itself no longer exists).
    • The new semantics are much cleaner: "if even one V2 label exists, V2 decides and V1 heuristics are ignored entirely; if none exists, fall back to V1".
    • This case is strong evidence for the observation that X exposes the reality of "daily commit" dual-write migrations as they are.
  • ✅ SID connection in VMRanker: seven SID fields entered the VMRanker request. We confirmed that there is a re-ranking-side SID consumer that pairs with the SID window in Phoenix retrieval.
    • The fields are sid_known, sid_k_l1/2/3, and sid_gap_l1/2/3.
    • The architectural fact that SID is a 3-level hierarchical codebook was newly revealed.
  • ✅ Hydration branch between For You and Following: For You and Following started to split explicitly at the pipeline stage.
    • VFFollowingCandidateHydrator was newly connected.
    • Following now keeps only the VF result and leaves the other candidate fields at default.
  • ✅ The Phoenix event stream moved to v3 and the Kafka cluster was separated.
    • content_understanding_realtime_unified_posts → ..._v3
    • main-2 → phoenix-kafka-external-bootstrap
    • ValueOnly → KeyValue(key=Long)
    • grox reply_spam also moved to v3 in the same commit, and its flow was unified.
  • ✅ The grox reply_spam flow was unified and its threshold was adjusted again.
    • PostStreamTaskGenerator was deleted, and ReplyRankingTaskGenerator absorbed PlanSpamComment and PlanCoordinatedSpam.
    • The follower threshold rose from 40k to 60k. This is the third increase, after 30k → 40k in the 2026-08-17 commit (Gen4+2).
  • Deep dive needed (new):
    • The origin of the four V2_WRITTEN_LABELS labels (a Grok prompt change? retraining?)
    • The SID codebook definition (vocabulary size and training approach of each level)
    • The formula for sid_gap
    • Whether Phoenix Kafka unified_posts_v2 existed (or whether it was a direct jump from v1 to v3)
    • How the ColdStartTrackedIds param actually connects to the simplified count_tracked_ids instrumentation

Resolved or new in the Gen4+4 release (2026-08-21)

This release confirmed the Phoenix optimizer in code and gave the first signal that Phoenix is expanding to the search surface. It also newly revealed the reward head and distribution prediction head, a Following-side filter, the PTOS Grok 4.6 shadow, and the VF shadow deployment base.

  • ✅ The identity of the Phoenix dense-param optimizer was confirmed in code in Gen4+4. This long-undisclosed item is a hybrid of Muon (Newton-Schulz orthogonalization) and Adam.
    • muon_adam_patterns="embed,_emb,logits,vocab,norm,table"
    • base LR 1e-3 → 7.1e-4, muon_split_fused="qkv:128", muon_consistent_rms=0.2
    • Embeddings use rowwise_adagrad (LR 0.28, half-life 2500 step, lazy_decay, weight_decay 2.8e-4).
    • The GB300 override is LR 5e-4 and half-life 5000.
    • This is the first case, among the large-scale production recsys training pipelines this repository has covered, where Muon adoption is confirmed in code. A new axis of observation opened: LLM optimizers moving into ranker/retrieval training.
  • ✅ The first code signal appeared that Phoenix is expanding to the search surface. A search config was newly added, paired with the two proto actions CLIENT_TWEET_(NOT_)RELEVANT_TO_SEARCH.
    • The config is xrecsys_search (vocab 100M/100M/30M, EP 512, use_product_surface=True, condition_search_relevance_on_prompt=True).
    • The earlier canon understood each surface to have its own codebase. Now the same Phoenix Transformer is being unified so that swapping only the config serves both search and feed.
  • ✅ The skeleton of a reward head appeared in the Phoenix proto. A connection toward RL (actor-critic value+policy) training or reward-model-guided re-ranking slate decisions has begun. Which scorer consumes it needs a deep dive.
    • The new items are RewardOutputs (values and policyLogits) and ScoreInfo.rewardRerankSlotProb.
    • The 2026-10-14 commit 17c96bf (Gen4+41) released the page decoder (actor) and twin critic training code for the immersive surface, and this training reads the rewardRerankSlotProbSeq column as the logged probability rl_rerank_prob verified (Gen4+41 in the change log).
      • In the same commit, code outside the proto does not reference RewardOutputs (rewardOutputs and policyLogits) verified — grep over all of 17c96bf.
  • ✅ The prediction schema of X's ranker began to carry a distribution shape for the first time. It predicts link-open probability not as a single scalar but with five quantile heads: P10/P25/P50/P75/P90.
  • ✅ The parallel structure of the Following pipeline widened. After the hydrator branch in Gen4+3, one filter and one hydrator were newly attached in Gen4+4.
    • The Gen4+3 branch is VFFollowingCandidateHydrator.
    • FollowingViewerMutedKeywordFilter (65 lines) also matches quoted text and ancestors.
    • QuotedPostTextHydrator (106 lines) fetches the quoted text from TES.
    • For You and Following use different components in their filter and hydrator stacks, so the parallel structure is reconfirmed.
  • ✅ A Grok 4.6 shadow started in the PTOS verdict layer. Only 10% of traffic goes to 4.6 in the child safety cross-validation and deluxe 4.3 categories. The dual-write stage of the 4.5 → 4.6 gradual rollout has begun.
    • The ratio is set by _GROK_4_6_INTERNAL_DIAL = 0.1.
    • The new model (EAPI_GROK_4_6_INTERNAL) and a dedicated circuit breaker were connected.
    • prior_nsfw.py (34 new lines) separates the Manhattan lookup cache.
  • ✅ The VF shadow deployment infrastructure was connected. A base for verifying rule changes of the fail-closed VF through shadow was established (Gen4+4 in the change log).
    • visibility-filtering/dark_traffic_setup.rs (109 new lines) is env-gated and mirrors requests to xai-vf-shadow.prod.visibility only from specific ordinal pods.
    • It uses mTLS and reloads the /config/dark-traffic/dark_traffic.yaml rule file.
  • ✅ The three Phoenix bool features filled in Gen4+1 were raised to ON by default in training.
    • The four enable_stale_post, enable_is_author_followed_by_viewer, enable_is_author_following_viewer, and enable_engagement_counts are the defaults of _home_direct_packed_base().
    • Only the grpc_recsys dataset type overrides enable_stale_post=False (presumably to avoid the count zeroing risk in real-time serving inferred).
  • Deep dive needed (new):
    • In which training loop RewardOutputs is filled and which scorer consumes it (the large +265/−25 overhaul of recsys_model.py must be traced)
    • Whether the link-open 5-quantile is a quantile regression or a categorical mixture
    • Whether the xrecsys_search config was deployed to production
    • Observed performance of Muon (no public metrics)
    • The actual dark_traffic YAML (undisclosed)
    • The prompt form of condition_search_relevance_on_prompt

Resolved or new in the Gen4+5 release (2026-08-22)

This release connected the path by which home-mixer receives and uses the slate context built by Phoenix serving (the gate is false by default). AI trend context began to attach to For You candidates. The Grok 4.6 shadow ratio tripled in one day.

  • ✅ A path appeared that consumes the slate context built by Phoenix serving. SlateContext.sid*, introduced in the proto in Gen4+4, was connected through to the actual pipeline. The gate was connected as false by default.
    • The components are PostCandidate.served_slate_context, the From<xai_recsys_proto::SlateContext> conversion impl, the candidate_slate_context() extraction in PhoenixScorer, and the three-way fallback in RankingScorer.
    • The fallback order is the UseServedSlateContext gate first → cache → local computation.
    • The first step is confirmed in moving the slate context computation point from home-mixer local to Phoenix serving.
  • ✅ A new AI trend feedback context channel appeared. An "AI trend" label is attached to For You candidates as a second context channel alongside topic feedback. It enters as a fallback only when there is no topic feedback, so the two do not overlap.
    • TOP_TREND_COUNT = 2 (at most 2 per page).
    • One candidate is picked at random per trend.
    • A triple gate applies: standalone original post, non-topic, non-in-network.
    • The landing URL is https://x.com/i/trending/{trend_id}.
  • ✅ The core of the emb_table loader was split out as pyo3-free. This prepares large embedding tables to be loaded in non-Python contexts. The direction matches the vocab 100M/100M/30M flow of xrecsys_search.
    • The core is load_tensor_into(&str, &str, &[(String, String, usize, usize)], &mut [u8], usize, usize) -> Result<(), String>, and the Python wrapper is separate.
  • ✅ The Grok 4.6 traffic split dial rose 3× in one day, from 10% to 30%. This supports that the 4.6 traffic stayed within the acceptable disagreement rate range without a circuit breaker trip inferred. The flow of daily refinement continues through Gen4+2→+3→+4→+5.
  • ✅ A callback to end streaming responses early was added to grok_sampler. This is a sampling infrastructure optimization matched to the growth of 4.6 traffic inferred.
    • stop_predicate and aclosing were connected, and the llm.sample.early_stop.count counter was added.
  • ✅ A profile_image field was newly added to the grox User. The schema was extended so the Grok classifier can reference the profile image as verdict context.
  • Deep dive needed (new):
    • The upstream definition of AI trend (Grok clustering? human curation?) and the pipeline source of batch_get_tweet_ai_trend
    • When the UseServedSlateContext gate is raised to true. That point is also when the Phoenix-served slate context reaches production traffic (the checkpoint to watch in the next commit).
    • The next move of the Grok 4.6 dial (30% → 50%/100% or a decrease)
    • Where stop_predicate is actually used (whether it serves as a callback matching the safety verdict JSON tag)

Resolved by the Gen4+6 release / new (2026-08-23)

This release added 20 ads conversion prediction heads and a delayed-feedback training branch, and the export nano config now inherits the training recipe of the GB300 override. PTOS child-safety cross-validation and deluxe reclassification moved entirely to Grok 4.6 internal.

  • ✅ 20 new ads conversion prediction heads were added. Previously only app installs were predicted; now six post-install engagement events are predicted for click and view each (ads conversion subsection).
    • They were added at proto ActionName 171–190. They are 6 MACT in-app events (purchase·add_to_cart·level_achieved·tutorial_complete·sign_up·custom) × 2 families click/view = 12, plus 8 view-through conversions (web conversions).
    • Previously there was only ADS_ATTRIBUTED_MACT_CLICK_INSTALL (=140).
    • CLIENT_TWEET_SEARCH_QUERY_REFORMULATED was added to NEGATIVE_FEEDBACK_HEAD_INDICES.
  • ✅ The training loss now splits on whether feedback is delayed. X ranker training is now explicitly aligned with the standard handling in the ads delayed conversion prediction literature (Chapelle 2014).
    • The split_head_training_by_source config brings kafka's is_delayed_feedback column in as sample_source.
    • Engagement heads train only on fresh samples and conversion heads only on delayed samples (the loss mask splits).
    • There are three metric slices: fresh/delayed_clicked/delayed_non_clicked.
    • The tuning knobs are mact_in_app_loss_weight, train_view_through_heads, and conversion_keep_mask.
  • ✅ A new ads_slim metric group was added. It is a slim signal combination for the ads ranking line.
    • It includes 3 positive signals (open link, external link long dwell, video quality view).
    • It includes 4 negative signals (not interested, block, report, mute).
    • Non-primary conversion clicks are also included.
  • ✅ The Muon training recipe was formally added to the export. The export moved to a state where running the exported training Python code as is yields production Muon training.
    • TRAINING.md, README, and reference/README.md were rewritten. They state explicitly that the flagship ranking config and home_direct_packed_nano are trained end-to-end with the production Muon recipe.
    • Embedding rowwise AdaGrad has three options: accumulator half-life decay, lazy per-row decay, and decoupled weight decay.
  • ✅ home_direct_packed_nano now inherits the LR and optimizer settings of the NVIDIA GB300 hardware override. The override itself has existed since the Gen4 release verified — xrecsys.py:305-313. The same file has also had _H100_OVERRIDES since the Gen4 release (line 297 of the same file).
    • _GB300_OVERRIDES defines bs_per_device=512, ep=64, attn_impl="cutedsl_ranker_varlen_attn", remat_policy=RematType.SAVE_GB300_RECSYS, unroll_layer_stack=True, LR 5e-4, and checkpoint_every_n=300.
    • This attention kernel is a varlen attention kernel based on NVIDIA CUTLASS/CUTe DSL, and may be an xAI in-house kernel.
  • ✅ It was confirmed for the first time that an xai-o2 storage system exists inside xAI. The interface of the xai-o2 crate, an S3-compatible object storage client, was released.
    • It exposes a BaseO2Client trait and 15 builder methods (endpoint, bucket, access_key, chunk_size_mib, retry, timeout, etc.).
    • However, build() always returns the error "the xai-o2 backend is not included in this build" in open-source builds.
  • ✅ Grok 4.5 internal was removed entirely from PTOS child-safety cross-validation and deluxe reclassification, and 4.6 internal went to 100%. The dial-up (10%→30%→100%) that began in the 2026-08-19 commit (Gen4+4) finished in two days verified — aad7179, d0cef2f, classifier.py in 28e414f.
    • The adult-content judge (Gen4+2) keeps using EAPI_GROK_4_5_X_ALGO regardless of this dial verified — classifier.py:563.
    • All 4.5-related breaker, sampler, and dial constants were deleted. Rollback is not possible.
  • ✅ SlateContext was added as field #9 of NextActionDistribution. Now a slate context is attached to each action distribution as well. The consumption path is still a skeleton.
    • Earlier, Gen4+4 introduced ScoreInfo.slateContext (re-ranking slate decisions) and Gen4+5 introduced PostCandidate.served_slate_context (consumed by home-mixer).
  • ✅ The grox reply_spam follower threshold rose from 60k to 80k. It is the fourth increase within 8 days of the Gen4 release (15k→30k→40k→60k→80k), and this increase is 1.33×.
  • ✅ The visibility-filtering dark_traffic target changed from shadow to staging. ⚠ The Gen4+4 canon interpretation needs to be revisited (Gen4+4 in the change log).
    • Within 3 days the shadow workload was deleted and redefined as staging mirroring.
    • The target changed from xai-vf-shadow.prod.visibility to xai-vf-service.staging.visibility:grpc.
  • Deep dive needed (new):
    • The source of the is_delayed_feedback upstream pipeline (the conversion re-observation event logger that feeds this column into kafka)
    • When split_head_training_by_source is promoted to True in _home_direct_packed_base() or the flagship config (a signal that ads conversion training is moving to production)
    • When the train_view_through_heads opt-in is promoted
    • Whether the flagship also explicitly inherits the GB300 override
    • Whether the actual xai-o2 implementation will be released (unlikely inferred)
    • Which way the EVERGREEN retrieval dataset is redefined (rename vs. revert)
    • When 4.7 shadow starts after Grok 4.6 settles
    • Whether the cutedsl_ranker_varlen_attn kernel is an xAI in-house kernel

Resolved by the Gen4+7 release / new (2026-08-26)

This release added a regression verification channel that measures the consistency of the visibility filtering rewrite, and brand safety verdicts grew to four levels. The PTOS judge widened to three categories.

  • ✅ The regression verification channel for the VF rewrite is connected. The consistency of the VF sub-repo rewrite, the Gen4 headline, can now be audited at production traffic scale. Rollout risk dropped considerably (Gen4+7 in the change log).
    • visibility-filtering/reference_compare.rs (812 lines, new) is layered on top of the dark_traffic stack (Gen4+4/+6), making it possible to measure actual verdict comparison.
    • It is staging-only, through VF_DUAL_CALL_HARNESS_ENABLED and an enforcement mechanism that panics when APP_ENV=prod.
  • ✅ BrandSafetyVerdict grew to four levels. Previously the brand safety verdict controlled ads adjacency with only three levels (Safe/Low/Medium), and lumped labels of different risk into the same tier. This limitation is resolved.
    • The new tier is HighRisk = 4.
    • The five labels that were in medium (NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW) and the new SOFT_NSFW were promoted to HighRisk. V1 and V2 moved symmetrically.
    • The ads utility has_avoid() handles both Medium and High, and the quote adjacency escalation also changed from Medium → High.
  • ✅ Five V2 labels are exposed in the downstream proto. Previously V2 labels existed only inside home-mixer. Now other services that consume the feed response can also see the V2 verdict.
    • GROK_SFA_V2, GROK_NSFA_LIMITED_V2, GROK_NSFA_V2, GROK_NSFA_EXPANDED_V2, and PTOS_REVIEWED were added to the safety_label_to_proto mapping in scored_posts_server.rs.
  • ✅ DDG tracking for the V2 experiment is connected. V2 is at the experiment stage, and the infrastructure to observe revenue/safety metrics is complete. The 8-day journey of Gen4+2 (introduction) → +3 (redesign) → +7 (experiment measurement) is complete.
    • ads_injection_logging_side_effect.rs logs the EnableAdsBrandSafetyVerdictV2 experiment bucket in the ddg_experiment_bucket field.
  • ✅ The PTOS judge layer widened to 3 categories. Previously there was an adult-content judge class and, separately, the child-safety cross-model validation that had been inside the classifier since the Gen4 release. Now all 3, ChildSafety, ViolentMedia, and IllegalAndRegulatedBehaviors, are grouped into one judge class (Grox → PtOS section).
    • The new judge is SafetyPtosPolicyCrossValidator. The earlier adult-content judge is SafetyPtosAdultContentCrossValidationJudge (Gen4+2).
    • The new judge class uses the EAPI_GROK_4_5_X_ALGO endpoint that the adult-content judge has used since Gen4+2. It is separate from EAPI_GROK_4_5_INTERNAL, which was removed entirely in Gen4+6 verified — classifier.py:100, 561.
    • X has settled into a dual stack that keeps the primary (4.6) and the judge (4.5 X_ALGO) on different models and tuning.
  • ✅ Parent-thread rendering was turned on for the PTOS ViolentSpeech and SuicideOrSelfHarm categories. Now grox puts the post and its ancestors into the LLM prompt together.
    • The underlying reason is that these two categories cannot be judged for harm from the target post's text alone.
      • For example, whether "I want to die" is about a game or about self-harm is decided only by the thread context.
    • USE_THREAD_RENDERER_CATEGORIES is new.
    • USE_REASONING_CATEGORIES was also turned on for only these two (thinking suppression lifted).
  • ✅ The Phoenix checkpoint loader was reinforced for multi-node environments. It addresses restore-path OOM issues on the Muon and GB300 generation (checkpoint stack subsection).
    • It is a follow-up verification result that came after Gen4+6 formally included flagship Muon training in the export inferred.
    • xai_checkpointing/load.py gained new arguments domains (per-tensor slice), tag (checkpoint subdir), and concurrent_gb (concurrent decoding throttle).
    • Restore is serialized per node with XAI_RESTORE_NODE_SERIALIZE and fcntl.flock().
  • ✅ grox reply_spam started writing reply ranking scores to the regional cache as well. The path that writes scores back to Strato has existed since the Gen4 release, and this time regional cache fanout was added verified — storage module in d011592.
    • ReplyRankingScoreStratoLoader has put scores to Strato and emitted them to Kafka v2 since the Gen4 release (47c1bcd).
    • Since Gen4+7, when score ≤ 1.0 it also fans out to the regional caches (Atla, Pdxa).
  • ✅ The grox reply_spam follower threshold rose from 80k to 100k. It rose five times in 11 days (15k→30k→40k→60k→80k→100k), so the set of spam-judgment targets keeps widening.
  • ✅ X Spaces (audio) context enters the grox LLM judgment. The audio itself is not listened to, but the Space title becomes material for brand safety and adult content judgments.
    • The SpaceMetadata class and the Post.space_metadata field are new.
    • The renderer puts the audio Space card into the prompt in the form \n\nThis post has the following audio space card attached:\nAudio Space title: {title}.
  • Deep dive needed (new):
    • Whether the VF reference diff goes only to logs or to a separate dashboard (definition of the promotion threshold: "promote if the diff rate is X% or lower")
    • What the reference VF wrapped by StratoVfClient actually is (a remnant of the Gen1 and Gen2 Scala VF? a separate stack?)
    • Whether the HighRisk tier triggers follow-up actions beyond ads avoid (strong ranker decay, complete block of organic impressions)
    • The holdout/treatment ratio of the V2 experiment
    • How EAPI_GROK_4_5_X_ALGO is tuned (x-algorithm fine-tuning on the 4.5 base, or a completely separate model)
    • The next category for PTOS judge expansion (spam and hate speech are presumably the next targets of the sequential expansion inferred)
    • Whether SafetyPtosAdultContentCrossValidationJudge and the new SafetyPtosPolicyCrossValidator will be unified (same function signature, different routing)
    • The meaning of the grox reply_spam score threshold 1.0 (whether the cache fanout gating threshold is a probability or a score)
    • Whether a pipeline exists in which the LLM listens to the audio content itself (Space title text only vs. STT of the real audio stream)
    • The production tuning value of concurrent_gb and how much restore time increases

Resolved by the Gen4+8 release / new (2026-08-27)

This release added a general path for the abuse-enforcement service to execute actions requested by scorers. The VMRanker path was greatly simplified, and the Following pipeline now applies block relationships up to the original author of quote and retweet chains.

  • ✅ A general pipeline in which AES executes actions requested by scorers is connected. Now a scorer can add and send requested_actions directly, outside the hardcoded YAML rules (Gen4+8 in the change log).
    • The new file is abuse-enforcement-service/service-lib/src/generic_actions.rs (663 lines, new), and its rule pair is the platform_row_without_requested_actions skip and the act_requested_actions act.
    • Verification is two layers of fail-closed.
      • (a) hardcoded kind allowlist (5 user kinds, 2 post kinds, MAX 16 per msg)
      • (b) GrowthBook dynamic allowlist (kinds·suspend_policies·labels)
    • Rejected requests are not discarded but are recorded as outcome.info["requested_actions_skipped"] JSON and the GENERIC_ACTION_TOTAL{outcome=skipped, reason} metric.
    • As the first client, the panda_reports_embedding_v10_rough_spam label appeared in a temporary PlatformManipulation suspend rule. This is evidence that the embedding-based spam detector is a continuous pipeline retrained up to v10.
  • ✅ The VMRanker path was greatly simplified. VMRanker passes through the score returned by the rank server as is, and DPP diversity is handled by the server's own logic.
    • AUTHOR_DIVERSITY_VALUE_MODEL_ID, the 128-line MPN fallback, the AuthorColdStart post-processing, and the 88-line ranking_scorer::effective_head_weights were all deleted.
    • The only new constant is DPP_VALUE_MODEL_ID = "dpp".
    • It is an architecture refactor that pairs with Gen4+5's UseServedSlateContext (migrating slate context to Phoenix serving). home-mixer is kept thin and the gRPC servers take over the computation.
  • ✅ The Following pipeline respects block relationships up to the original author of quote/retweet chains. X explicitly blocks block circumvention through quotes/retweets.
    • The new hydrator FollowingBlockedByHydrator (65 lines) makes a batch check_blocked_by lookup to SocialGraphClient with the list of quoted/retweeted authors of each candidate.
    • AuthorSocialgraphFilter filters on six branches.
      • (i) viewer→author mute
      • (ii) viewer→author block
      • (iii) author→viewer block
      • (iv) quoted author→viewer block
      • (v) viewer→quoted author block
      • (vi) viewer→retweeted author block
    • It is connected in reverse_chron_posts_pipeline.rs (before the VF filter).
  • ✅ The account list of Brazil2026ElectionFilter was updated. This is a sign that a regular update practice has taken hold. The filter semantics did not change (the follow exception is kept).
    • It reflects the TSE dadosabertos 2026-08-25 edition (+3,379 lines, 4,947 lines in the whole file).
    • The README also states it explicitly: "(Account list updated August 25, 2026.)".
  • ✅ A prefloor snapshot is connected in phoenix-rankall. New candidates stay in a separate pool until they pass the min_age threshold, and once they pass they are reflected in production immediately.
    • The changed files are store/base.rs (+202/−61) and config/mod.rs (+51/−12).
    • For windows with min_age, the prefloor_<window>.parquet file is loaded as well (PREFLOOR_SLACK_SECS = 6.0 * 3600.0, 6 hours of slack).
    • It is a freshness improvement in the same direction as the removal of the favoriteCount>=1 gate in Gen4+2.
  • ✅ The Phoenix copy_port rate limit became accurate. Previously, sequential polling could let actual in-flight concurrency exceed the cap. It is a serving-side reinforcement that pairs with Gen4+7's restore path reinforcement.
    • In copy_port_client.rs (+155), join_all(futures) changed to join_all(futures.map(tokio::task::spawn)).
    • Now the tokio scheduler runs them in true parallel, and the rate limit issues the next batch based on the in-flight count.
    • There are two regression tests (rate_limit_caps_in_flight_despite_spawn, rate_limit_paces_between_spawned_batches).
  • ✅ The README's Latest Updates was renamed Notable Updates. It is a declaration that the README will not be updated daily even though follow-up commits are added daily inferred.
  • ✅ In the home-mixer cron mirror (sync 2026-08-25), two weights and the ads brand safety V2 switch changed verified — home-mixer/params/param.rs:342, 356, 908.
    • VqvWeight changed from 0.05 to 0.0, and DwellWeight changed from 0.0 to 0.05.
    • EnableAdsBrandSafetyVerdictV2 changed from false to true. This value went back to false in the 2026-09-16 commit (Gen4+22) and became true again in the 2026-10-03 commit (Gen4+35).
  • Deep dive needed (new):
    • The first production GrowthBook configuration of the generic_actions allowlist (which scorers it opens to first)
    • The evolution of panda_reports_embedding_v10 from the earlier v1–v9 and when v11 arrives
    • The actual value model architecture of DPP_VALUE_MODEL_ID (whether it is the same as the DPP (θ=0.65, K=150) in the re-ranking section or different)
    • The new consumer point of AuthorColdStart (where it went after being removed from vm_ranker)
    • Whether AuthorSocialgraphFilter is also connected to the For You pipeline
    • The first active window of the prefloor snapshot
    • The role of the grox/flows/upa/ subflow
    • The new event type in the +27 lines of URT client_event.rs

Resolved by the Gen4+9 release / new (2026-08-28)

This release made both saving and restoring of Phoenix checkpoints batch pipelines, and unified the training-step validity gating between dense and embedding. VF removed the media fallback cache, and three ads conversion heads were newly added to the proto.

  • ✅ The Phoenix checkpoint stack was reorganized again so that both save and restore have an RSS cap. The entire Muon+GB300 checkpoint round trip is now a batched pipeline (checkpoint stack subsection).
    • Symmetric to Gen4+7's restore-side reinforcement (concurrent_gb throttle and node-lock), this time the save side was reorganized.
    • The save side gained a new ThrottledD2HArrayHandler. It overrides Orbax ArrayHandler.serialize to do D2H and write per batch, and the budget is set by save_concurrent_gb.
    • Saving is serialized per node with the XAI_SAVE_NODE_SERIALIZE env and the /dev/shm/xai_save_node_lock flock.
    • gc.collect() and malloc_trim(0) are called between batches.
    • The new restore-side function load_checkpoint_streamed is a restore variant that pushes stage→copy→evict tensor by tensor. auto-window = min(max(max_leaf, total/16), 32GB).
    • This function passes old/new array pairs through the on_replaced callback and separates aliased references in advance with copy_aliased_arrays.
    • The Trainer.warm_start_staging_spec hook does not make an opt_state copy in pinned-host during a params-only restore (keep_emb_opt_state=True option).
    • admission.reset_estimates resets the EWMA of the serving admission controller to zero.
    • The copy_port_client logging also records GB/s.
  • ✅ valid_step gating in trainer_recsys was unified. Now if either dense or embedding fails, both keep the originals. It cuts off at the source the residual effect of dense·emb grad scale divergence on Muon+GB300.
    • The param update order was rearranged in the dense sync and async_no_deferred paths.
    • Previously dense params were updated as long as they passed the dense grad-norm check, and if the emb grad-norm failed only the emb table was rolled back, which could create an asymmetric state for a few steps.
    • The unified condition is keep_step = valid_step & emb_valid_step.
    • The async path is also synchronized with AsyncEmbGradientUpdate.pending=keep_step.
    • The grad_norm_keep_threshold is None assert in _create_async_emb_executables was removed (the async path now actually consumes the threshold too).
  • ✅ An option to put follower count and bio in the grox reply_spam LM prompt was added, and Manhattan writes became monotone-only. Because the reply ranking score only goes down over time and never goes up, label flapping is eliminated at the source.
    • Two options, include_follower_count and include_bio, were added to PostRenderer.render, which renders user meta in four parts: Handle / Follower Count / Name / Bio.
    • ThreadRenderer._render_thread puts the follower count in every post of the thread when include_follower_count is on, and renders the bio for post 0 (the original) only.
    • The # Reply Author Info header states the judgment target explicitly.
    • _publish_to_reply_ranking_manhattan looks up the existing score with ReplyRankingScoreStratoLoader.fetch_reply_ranking_score. If new score > existing, it skips the write (higher_than_existing counter).
  • ✅ The media fallback cache was removed entirely from VF TesHydrator. It follows the same "remove the safety net after stabilization" trend as Gen4+8's big VMRanker simplification (Gen4+9 in the change log).
    • 292 lines were deleted. The 1M-entry LRU, serve-stale/shadow mode, and both env flags (VF_MEDIA_FALLBACK_CACHE_SERVE_STALE_ENABLED, ..._POPULATE_ENABLED) are all gone.
    • The pattern is that the safety net from the early VF rewrite was removed once consistency could be audited with the Gen4+7 reference_compare harness and TES stabilized.
  • ✅ dark_traffic no longer mirrors canary workloads. It prevents incidents in which canary issues contaminate shadow verdicts.
    • should_enable changed to should_mirror(workload, ordinal, max_ordinal).
    • With workload=None it is fail-closed, and workloads with a -canary suffix are not mirrored.
  • ✅ Three ads conversion heads were newly added to the proto. The ranker directly predicts advertiser pixel fires, and the combination of pixel and long dwell is also a separate head.
    • They are proto ActionName 191 ADS_PIXEL_FIRE, 192 ADS_LONG_DWELL_AND_PIXEL_FIRE, and 193 P_LONG_DWELL_AND_PIXEL_FIRE.
    • They are not yet reflected in constants.py and recsys_model.py (proto introduction only). The training and serving connection is a checkpoint to watch in the next commit.
  • ✅ Ads asset reuse history is carried in the serving request. It attributes cases where the same creative was shown or re-shown across multiple advertisers.
    • PredictNextActionsRequest.conv_asset_ids is connected. A new ConvAssetIds message (impressed_time_ms / author_id / asset_id 3-array) was added as field 18.
  • ✅ Two fields for reconstruction metrics were added to home-mixer's local SlateContext. They refine the served_slate_context reconstruction metrics. The proto migration is pending.
    • The fields are SlateContext.recon_count_above and recon_gap_above, and both are Option<u32>.
  • ✅ A new use_history_segment_ids flag was added to the retrieval user tower. It is off by default.
    • The default is RecsysTwoTowerModelConfig.use_history_segment_ids=False.
    • When on, user_tower_segment_ids(B, T, ...) gives history tokens HISTORY_SEGMENT_ID instead of zeros.
    • The segment mask of pallas varlen flash-attention isolates the user history segment from other special tokens (e.g., the user_features token).
  • Deep dive needed (new):
    • The production tuning value of save_concurrent_gb and how much save time increases
    • When load_checkpoint_streamed replaces the default load_checkpoint (currently both functions coexist)
    • The first active config of warm_start_staging_spec (whether it is already reflected in the flagship)
    • The actual call trigger of admission reset_estimates (hot-swap completion? traffic shift detection?)
    • Data showing that the Manhattan reply_ranking monotone-only write actually reverses observed label flaps
    • The media feature fail-close rate when TES fails after removal of the VF media fallback cache
    • The training and serving connection of ADS_PIXEL_FIRE and ADS_LONG_DWELL_AND_PIXEL_FIRE (when constants.py is updated)
    • The actual consumption point of conv_asset_ids (which head in recsys_model uses this asset history)
    • When use_history_segment_ids is promoted to production (the commit that changes it to True in the flagship)
    • The formula for recon_count_above/recon_gap_above and when the proto migration happens

Resolved by the Gen4+10 release / new (2026-08-28)

The 2026-08-28 commit (Gen4+10) introduced the ads blender, the VF safety-label cache warmer, and Phoenix checkpoint restore and serving fixes verified (blender, warmer, Phoenix). Operational values such as rollout ratio, initial settings, and production metrics remain new open questions.

  • ✅ The ads blender gained a fourth option, MultiRiskAdsBlender. It decides ad slots with an adjacency matrix that combines the ad's BSR grade and the post's risk grade.
    • The existing options are safe_gap, partition_organic, and time_gap; this commit added multi_risk.
    • Ads are split into two tiers, BsrNormal/BsrHigh, and posts into three tiers, Safe/MediumRisk/HighRisk.
    • Only BSR-high ads are allowed medium-medium slots. If an advertiser opts in to impressions in medium-risk contexts, total impressions increase.
    • A HighRisk post cannot be adjacent to any ad.
    • Three metrics, MultiRisk.enforcement, MultiRisk.slot_outcome, and MultiRisk.serving_limitation, slice two things for observation.
      • Which safety filter caused a slot to be left empty
      • Which of ads_supply/spacing/safe_posts was the binding constraint
    • The same commit also added a "partition_organic_low_risk" alias to blender_selector, bringing the options to five in total.
    • It is placed behind an experiment gate, and the default of the AdsBlenderType param has not yet been promoted.
  • ✅ A warmer was added that, on a miss in the safety-label twemcache, samples a portion of the misses and fills the cache asynchronously.
    • When twemcache returns Miss (the cache does not know this key) on a visibility/baseTweetSafetyLabelMap column lookup, SampledWarmer samples with a probability set by the VF_CACHE_WARM_SAMPLE_PCT env.
    • Sampled keys are published fire-and-forget to a tokio bounded mpsc (capacity 1024).
    • A background drain groups batches with a 500ms linger and at most 50 IDs and performs a Strato batch fetch. The results are written back to twemcache.
    • NotFound (definitively absent) is not a warming target, and FallThrough (error/timeout) is not one either. The principle is that warming resolves only unfilled cache.
    • When the bounded channel is saturated, it never blocks request handling and only increments the DroppedChannelFull counter.
    • Three new envs were also introduced: VF_GIZMODUCK_CLIENT_ID, VF_TWEMCACHE_CLIENT_NAME, and APP_ENV (staging/prod are separated automatically).
  • ✅ The Phoenix trainer now actually uses restore_streamed.
    • The load_checkpoint_streamed scaffolding from Gen4+9 was wired into maybe_load_checkpoint in this commit.
    • The activation condition is restore_streamed=True AND format == "orbax" AND save_method != "tensorstore".
    • The warm_start (do_not_load_opt_state) path benefits in particular. On this path, the pinned-host copy of the opt state is never created.
    • The _graft_replaced callback grafts loaded tensors in place into their original positions in the state tree. Aliased state leaves are blocked by an early assertion.
  • ✅ A bug where the Phoenix gRPC compression path lost response trailers was fixed.
    • When compressing with zstd, GrpcCompressionService wrapped the response body in a new axum::body::Body::from(data) and lost all gRPC trailers (grpc-status, grpc-message).
    • Clients were likely to have treated the response as ending at EOF without a status trailer.
    • Trailers are now restored with collected.trailers().cloned() and a rebuild_response helper. This is unified for both successful and failed compression.
    • Two regression tests were added.
    • The same file gained a new histogram, recsys_engine_response_compress_ms{method} (zstd encode queuing and CPU time).
  • ✅ Phoenix copy_port shuffled the shard download order per pod to even out peer load.
    • Previously, when each pod downloaded sharded tensors, all pods requested in order starting from index 0, which created a spike on the first shard peer.
    • The new shuffle_sharded_schedule(n, tensor_name) hashes POD_NAME/HOSTNAME together with the tensor name and shuffles deterministically with StdRng::seed_from_u64(hash). As a result, each pod requests in a different order.
    • Completed results are restored to the original order with restore_piece_order.
    • The two consumers, emb_table.rs and copy_port_client.rs, were unified in the same way.
    • replicated ownership keeps in-order.
    • This change extends the node-serialize and concurrent_gb hardening of Gen4+7 to the peer axis.
  • ✅ home-mixer gained three counters that observe polling requests. Regular polling refreshes and a user's initial requests can now be viewed separately in serving metrics.
    • A new file, home-mixer/util/strato_context.rs (75 new lines), extracts the StratoContext.is_polling proto field from stratocontext[-bin] metadata.
    • The stat_request_context function was wired into response_stats_side_effect.rs.
    • It increments three counters per request.
      • <feed>.request.polling{polling=true|false, client}
      • <feed>.request.request_context{request_context, client}
      • <feed>.request.client{client, client_version}
  • ✅ grox reply_spam now stores scores to Kafka first, and >= idempotency and fetch-failure handling also changed.
    • The monotone-only rule from Gen4+9 changed from > to >=, so writes are also skipped on ties. The counter name also changed from higher_than_existing to not_lower_than_existing.
    • The storage order was reversed from Manhattan-first to Kafka-first. Kafka append-only durability takes priority, and a Manhattan write failure can be recovered offline.
    • The defensive try/except in fetch_reply_ranking_score was removed. A fetch failure now propagates as a flow task failure.
    • In the same commit, include_follower_count=True was turned on permanently in the PostRenderer.render call in classifier_coordinated_spam.py. The coordinated spam verdict now receives each thread participant's follower count in the prompt.
  • ✅ abuse-enforcement gained an AIS thrift schema conformance test. CI guarantees that AES's payload schema does not drift from the downstream thrift service.
    • The new test every_action_shape_encodes_against_the_ais_schema encodes action shapes against the actual AIS thrift schema to prevent regressions.
    • The encoded targets are eight EnforcementAction shapes built with six constructors (suspend_user, add_user_label, add_post_label, bounce_arkose, bounce_captcha, spam_liveness_check). suspend_user and add_user_label each appear in two variants verified — service.rs:2104-2139.
    • The target schema is ActionIntakeService.intakeAction.
    • When a new scorer is created or a payload field is added, this test detects the problem as an early failure.
  • ✅ A regression test was added to confirm that home-mixer's CJK muted keywords match only as whole tokens.
    • The new test is test_cjk_keyword_whole_token_only in viewer_muted_keyword_filter.rs.
    • If 京都 is muted, I visited 京都 last week must be filtered out, but 東京都に行くのが楽しみ must not be (京都 is a substring of 東京都).
    • The filter itself already handles CJK word boundaries. Only the test was added this time, and its purpose is to lock down the semantics.
  • ✅ The account-list comments of Brazil2026ElectionFilter were reorganized again. The filter semantics are unchanged (the follow exception is kept).
    • Previously, 665 accounts were listed with a single repeated comment such as no live account was found / deleted account.
    • This time they were reorganized into 10 "not the candidate's actual account" verdicts and many no live account found entries.
    • Separate from the list-update practice of Gen4+8, this change tidies the tone of the documentation and comments.
  • ✅ A fallback was wired into the SimClustersInterestedIn2020 snapshot read in simclusters. The batch job became more tolerant of missing snapshots.
    • EntityToSimClustersEmbeddingsJob.scala directly inlined the InterestedInSources.simClustersInterestedIn2020Source(dateRange, timeZone) helper.
    • The inlined code consists of three parts.
      • DAL.readMostRecentSnapshot(..., dateRange.prepend(Days(28)(timeZone)))
      • ExplicitLocation(ProcAtla)
      • fallbackPath = "viewfs://.../simclusters_v2_interested_in_20M_145K_2020/_TMP_RECOVERY/{timestamp}"
    • It uses a 28-day lookback, a pin to a specific data center, and a fallback to a temporary recovery path together.
  • The deep-dive questions newly raised by this commit are as follows.
    • The experiment rollout ratio of the multi_risk blender and the share of BSR-high opt-in advertisers
    • The uplift that medium-medium placement produces in revenue/brand safety metrics
    • The initial production value of VF_CACHE_WARM_SAMPLE_PCT and the change in twemcache miss rate
    • Which service quotas the gizmoduck_client_id() staging separation splits
    • The first active config of restore_streamed (currently presumed to default to false inferred)
    • The P99 target of recsys_engine_response_compress_ms and production observation evidence of the gRPC trailer loss bug
    • The request-rate distribution of the shard 0 peer before and after the copy_port shard shuffle was introduced
    • The actual distribution of the home-mixer polling request ratio (by feed and client)
    • Changes over time in the rate at which reply_spam skips Manhattan writes (what percentage is skipped after monotone-only and >=)
    • How often the AIS conformance test actually catches regressions in the scorer development cycle

Resolved by the Gen4+11 release / new (2026-08-28)

In the 2026-08-28 commit (Gen4+11), a reranker head tag parameter was wired into the request home-mixer sends to Phoenix, and Phoenix's embedding table receive handling was moved to a blocking pool verified (change log). The VF rule-layer refactor in the same commit did not change policy verified (change log).

  • ✅ The visibility-filtering rule layer was refactored. Policy did not change, and regression-preventing assets accumulated.
    • 24 rule files were slimmed down, at +1,522/−1,151.
    • RuleContext was split into a new file, context.rs, and exposes about 25 domain methods (is_stale_tweet, viewer_country_in, has_tweet_nsfw_user_flag, etc.).
    • Condition checks in individual rules were replaced with domain method calls, and the semantics were preserved.
    • A 90-case golden corpus (golden_corpus.rs, +1039) pins verdict results across 13 categories.
    • CI enforces the every_wired_rule_decides_a_corpus_case assertion. It checks that every wired rule has at least one deciding case.
    • This is the next stage in the flow that led from Gen4+7 reference_compare to the removal of the Gen4+9 media fallback cache.
  • ✅ A RerankerHeadTag parameter was wired into the request home-mixer sends to Phoenix. The server-side consumption logic is not in this commit.
    • The new parameter is param!(RerankerHeadTag, i64, "rust_home_mixer_reranker_head_tag", 0).
    • A PostCandidate.reranker_head_tag: Option<u32> field was introduced. PhoenixScorer sends this value in the request and in scoring_sequence, and it round-trips in the response.
    • It was added symmetrically in two proto places: ScoreInfo.rerankerHeadTag = 6 and UserActionSequence.rerankerHeadTag = 9 (in the Rust and Python protos together).
    • This is a wiring stage before head routing is turned on inferred.
    • It reads as a switch for choosing one of several reranker heads in the model through an A/B experiment inferred.
  • ✅ Phoenix emb_table's gRPC receive handling was separated into a blocking pool.
    • Previously, when downloading large embedding tables, non-temporal copy and Adler-32 computation ran directly in the gRPC callback and held up tokio runtime threads.
    • Now data is pushed into a sync_channel(2) in 4 MiB chunks and run separately with tokio::task::spawn_blocking.
    • On the RDMA path as well, consumption up to the final summation is done with spawn_blocking.
    • The new helper JoinOnDrop<T> always terminates the blocking task and prevents handle leaks. The normal path uses join().await, and on drop it uses block_in_place and block_on(handle).
    • The 4 MiB chunks and sync_channel(2) backpressure regulate the receive rate.
  • ✅ copy_port_client gained a join_transfers helper, and proto_parser gained a Bytes callback type.
    • The previous four-line join_all(...spawn) → collect → map_err idiom was extracted into a single join_transfers helper (no behavior change).
    • The new Bytes callback type in proto_parser.rs (FnMut(usize, Bytes)) can pass chunks by ownership transfer.
    • emb_table still keeps the Str signature. True zero-copy is a follow-up checkpoint to watch.
  • The deep-dive questions newly raised by this commit are as follows.
    • The server-side consumption logic of RerankerHeadTag (head routing in the Phoenix Python serving code)
    • Why it was attached in two places, UserActionSequence and ScoreInfo (request/response symmetry vs. a different purpose)
    • When RerankerHeadTag is first promoted in an experiment (the commit where it changes from 0 to 1+)
    • When emb_table consumes the Bytes callback (the true zero-copy path)
    • The tail latency improvement of the tokio runtime before and after the gRPC callback separation was introduced
    • Verifying that the 90 golden corpus cases match prior production verdicts (the possibility that the refactor hid a semantic change)

Resolved by the Gen4+15 release / new (2026-09-04)

The 2026-09-04 commit (Gen4+15) changed grox reply spam verdicts, PTOS judge model assignment, Phoenix serving and training, and VF hydration all at once verified (change log). The new questions include the activation ratio of the new verdict path, the point where SID is filled into the request, and the H100 training config.

  • ✅ grox gained a second reply spam path that judges per thread chain. It is named PlanMultiStepReplySpam.
    • It feeds the whole thread into Gemma 2 as a prompt and extracts a "spam_post_indexes" set.
    • There are eight entry conditions: it is a reply, ancestors are present, root followers ≥ 1,000, root author ≠ reply author, direct parent author = reply author, not high page-rank/grey badge, not Grok/Gork, and depth ≥ 2.
    • The pattern these conditions try to catch is a time-axis coordinated spam where "one author steals root impressions through a chain in which they reply to their own replies."
    • The LM output has a three-step safeguard: newest reply not flagged → drop, other-author → drop, singleton → drop.
    • If it judges a post as spam, it forcibly stores score = 0.0.
    • The plan is injected into two streams, ReplyRankingTaskGenerator and RecoveryTaskGenerator.
    • A time-axis collusion axis, alongside Coordinated_spam (account collusion), is added to the canon.
  • ✅ The first concrete case of the PTOS judge stack using a different model per category was confirmed.
    • Gen4+7 assigned a single model, EAPI_GROK_4_5_X_ALGO, to the SafetyPtosPolicyCrossValidator of three categories (ChildSafety, ViolentMedia, IllegalAndRegulatedBehaviors).
    • In this commit, only IllegalAndRegulatedBehaviors was migrated to EAPI_GROK_4_1_FAST_X_ALGO.
    • This call is made directly without a Breaker, and the metric name also changed to ..._with_grok_4_1.
    • The policy of X tuning judge models separately per category was stated explicitly.
  • ✅ Gemma 4 was promoted to first-pass for IllegalAndRegulatedBehaviors, and the Gemma probability dial was removed.
    • USE_GEMMA_CATEGORIES = {Spam, IllegalAndRegulatedBehaviors} (previously Spam only).
    • The if random.random() < self.use_oai_gemma4_dial probability gate was removed entirely, so both categories are routed 100% to Gemma.
    • The removed dial value had been 1.0 since the Gen4 release, so Spam behavior did not change verified — classifier.py in 47c1bcd and 85ac72a. The actual change is that IllegalAndRegulatedBehaviors moved to Gemma.
    • task_safety_ptos_policy.py also added IllegalAndRegulatedBehaviors to the reclassification trigger. This completes the three-stage stack (Gemma first-pass → deluxe cross-model → Grok 4.1-fast judge).
  • ✅ The SID retrieval sidecar was removed completely from Phoenix serving. History SIDs now arrive carried in the request.
    • Five things were deleted.
      • sid_client.rs (189 lines)
      • sid_retrieval_runner.py (17 lines)
      • --sid_endpoint CLI
      • The xai-recsys-sid-proto dependency
      • The PySemanticIdClient Python binding
    • The log keeps the phrase "Parsing history SIDs from the request (sid_num_levels=%d); no sid_endpoint".
    • The responsibility for SID codeword lookup moved out of online serving. The contract is that home-mixer (or the candidate hydration layer) already puts the SIDs in when it builds the request.
    • One gRPC call per request is eliminated, and the fault domain of the SID infrastructure shrinks.
  • ✅ The default of Phoenix use_pinned_d2h changed from False to True.
    • D2H transfer speed is ~50 GB/s with CUDA pinned host memory, versus ~3 GB/s for the JAX default. It saves ~29ms per inference.
    • This option was introduced in Gen4+6, and its default changed in this commit.
  • ✅ Phoenix training gained a remat policy for H100. It is the first code signal that training also runs on H100 besides GB300.
    • The new policy is SAVE_H100_RECSYS = 32, and it entered custom_remat_policy as a new branch.
    • It designates the same name list as GB300 (attn_outputs, dense_outputs, gate_up_proj, query_heads, key_heads, value_heads, etc.) as rematerialization save targets.
    • A hardware-specific config block such as _H100_OVERRIDES is not yet in this commit.
    • The fact that X's training hardware is not only GB300 is itself new content to fold into the canon.
  • ✅ The FA4 attention kernel now natively supports variable-length history.
    • hist_valid_len was added to the build_dense_block_sparse_layout signature.
    • Layout computation moved from numpy to jax.numpy (cache removed).
    • Two masking fields, valid_block_upper and valid_block_lower, were added so the kernel handles partial-history blocks directly. A partial-history block is a block whose valid tokens inside a tile are at most the tile size.
    • Training and inference accuracy improve for new users and short sessions.
    • The varlen semantics of Gen4+6 cutedsl_ranker_varlen_attn were extended to the history axis.
  • ✅ The hydration and safety-label code in visibility-filtering was simplified considerably. Over 800 lines were removed in net.
    • The reduced files include registry.rs −378, safety_label_source/codec.rs −206, hydration/mod.rs −90, hydration/fallback_cache.rs −87, and config.rs −118.
    • The flow of "removing the safety net after the rewrite stabilized" continues. The earlier steps are the removal of the media fallback cache in Gen4+9, the deletion of 128 lines of VMRanker in +8, the rules refactor in +11, and the elimination of Box<dyn Rule> in +14.
    • In the same commit, fixtures.rs +81 and golden_corpus.rs +102 made the regression safety net thicker.
  • ✅ Brazil2026ElectionFilter was refreshed on schedule. The regular refresh practice of Gen4+8 continues. It is a signal that verification of politician X account mappings continues every week.
    • 166 account IDs were added as hardcoded entries, taking the set from 2,388 to 2,554 verified — brazil_2026_election_filter.rs. Examples of added accounts are +@nanacachen, +@AecioNeves, +@lgmbrasilia, +@crismonteirosp, +@rodrigolimamdh, +@samiabomfim, and +@gabrielmagno_13.
    • A few more "not the candidate's actual account" verdicts were also added.
  • ✅ PR #88 (e4dcedd) from an external contributor was merged. It is evidence that Gen4's open-source stance actually accepts external PRs.
    • Two lines were added to vf_candidate_hydrator.rs that dedup in_network_ids before the VF lookup.
  • The deep-dive questions newly raised by this commit are as follows.
    • The initial active traffic ratio and verdict metrics of PlanMultiStepReplySpam (experiment gating is not in this diff)
    • The tuning history of FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION = 1000
    • How multi-step verdict results are reflected in reply ranking in experiments
    • The name and source of the hydrator that fills SID in home-mixer or the candidate hydration layer (the point that puts SID into the request after sid_client was removed)
    • The SID codeword storage backend (Manhattan? Strato? phoenix-rankall itself?)
    • The first active config of SAVE_H100_RECSYS (which recipe is trained on H100)
    • When _H100_OVERRIDES appears and the batch/attn/remat tuning differences between H100 and GB300
    • What the 206-line deletion in safety_label_source/codec.rs actually removed (which wire format or label source)
    • How EAPI_GROK_4_1_FAST_X_ALGO differs from 4.5 X_ALGO (latency, cost, and accuracy trade-offs)
    • The next category expansion candidates (the next Gemma promotion targets after Spam and IllegalAndRegulatedBehaviors, and when the ChildSafety and ViolentMedia judges move to 4.1-fast)

↑ Contents

Sources

We checked the git log of xai-org/x-algorithm (2026, public source code, Apache 2.0) directly as of 2026-10-21. Up to that point there are 46 commits after Gen4, and the most recent commit is the 2026-10-20 commit (Gen4+45).

Commit Date; number in these notes or content
aaa167b 2026-01-20
e414c17·0bfc279 2026-05-15
47c1bcd 2026-08-13, Gen4
a389166 2026-08-13, .gitattributes cleanup
c65aa17 2026-08-14, Gen4+1
b089ce6 2026-08-17, Gen4+2
11a71f8 2026-08-18, Gen4+3
aad7179 2026-08-19, Gen4+4
d0cef2f 2026-08-20, Gen4+5
28e414f 2026-08-21, Gen4+6
d011592 2026-08-24, Gen4+7
0d3cdd8 2026-08-25, Gen4+8
45b48ba 2026-08-26, Gen4+9
24c6094 2026-08-28, Gen4+10
bc8e5f0 2026-08-28, Gen4+11
6384ca7 2026-09-01, Gen4+12
7ba7768 2026-09-01, Gen4+13
85ac72a 2026-09-02, Gen4+14
e4dcedd 2026-09-03, PR #88 dedup merge
9b0dc31 2026-09-04, Gen4+15
902a06f 2026-09-04, Gen4+16
49815da 2026-09-08, Gen4+17
75d93d9 2026-09-09, Gen4+18
fee1d0f 2026-09-10, Gen4+19
6bb4594 2026-09-12, Gen4+20
2d4a03c 2026-09-15, Gen4+21
fad2f71 2026-09-16, Gen4+22
42266f3 2026-09-17, Gen4+23
c279172 2026-09-18, Gen4+24
8b25829 2026-09-18, Gen4+25
3aa0fa33 2026-09-22, Gen4+26
1b3fec20 2026-09-23, Gen4+27
44d37eb 2026-09-24, Gen4+28
bf7db1b 2026-09-25, Gen4+29
4c5cfe8 2026-09-26, Gen4+30
a707cc2 2026-09-29, Gen4+31
77d431a 2026-09-30, Gen4+32
b79b947 2026-10-01, Gen4+33
76843a5 2026-10-02, Gen4+34
b412112 2026-10-03, Gen4+35
e62790c 2026-10-06, Gen4+36
78460ca 2026-10-07, Gen4+37
35650fb 2026-10-08, Gen4+38
cb45b55 2026-10-09, Gen4+39
e1a0375 2026-10-13, Gen4+40
17c96bf 2026-10-14, Gen4+41
92f0918 2026-10-15, Gen4+42
bc3948b 2026-10-16, Gen4+43
862a123 2026-10-17, Gen4+44
74d49a4 2026-10-20, Gen4+45

The sections and table rows below each have their own reference commit.

Reference commit Where this commit is used as the reference
e62790c Line-number quotes in the Gen4+36 subsection of the change log
78460ca The Gen4+37 subsection of the change log, the PopularPostsSource row of the candidate source table, the PostUnexploredWeight row of the weight table
35650fb The Gen4+38 subsection of the change log, the video carousel item in the feed surfaces section, the dwell_time CREAD paragraph in the ranking section, the carousel item in the "Final placement" section
cb45b55 The Gen4+39 subsection of the change log
e1a0375 The Gen4+40 subsection of the change log, the video_open, cont_dwell_time, and react_with_video rows of the weight table
17c96bf The Gen4+41 subsection of the change log, the favorite and not_interested rows of the weight table
92f0918 The Gen4+42 subsection of the change log, the sub-bullet on the sort score in the carousel item of the feed surfaces section
bc3948b The Gen4+43 subsection of the change log, the label sentence in the PopularPostsSource detail of the candidate generation section
862a123 The Gen4+44 subsection of the change log, the dedup sub-bullet in the carousel item of the feed surfaces section
74d49a4 The media dedup bullet in the carousel item of the feed surfaces section, the topic novelty measurement paragraph in the diversity section, the 6-hour video window bullet in the Phoenix retrieval index section

↑ Contents

Change log (per-commit detail)

This is a record, in date order, of what changed in each commit of X's public repository after the Gen4 release. The earlier topic sections describe the current behavior of X For You; this log shows which commit introduced each change.

Commit summary table

Generation Repo Released Language Status
Gen4+1 same repo @ c65aa17 2026-08-14 Rust/Python Added the Brazil election filter and cold-start Thompson sampling, which is off by default (daily). +2,271/−32 (21 files). Introduced Brazil2026ElectionFilter (1,573 lines, 665 accounts hardcoded), stated the explanation of weight misconceptions in param.rs and ranking_scorer.rs comments, cold-start Thompson sampling (off by default), introduced 3 bool sequences in Phoenix such as IS_STALE_POST14D and IS_AUTHOR_FOLLOWED_BY_VIEWER, partial release of the BDSM enforcement note generator (gate and dominant head only), raised the grox reply-spam threshold 15k→30k, added a video_14day SID window to phoenix-rankall
Gen4+2 same repo @ b089ce6 2026-08-17 Rust/Python Added V2 to the ads brand safety verdict and a task that calls the Grok 4.5 judge when adult content verdicts disagree (daily). +751/−162 (34 files). Introduced ads brand safety compute_verdict_v2 (medium 14→16, low 3, _V2 migration of 6 Grok labels, V1 dual-write label filter via the strip_v1_grok_written helper [PROMPT_OWNED_RULES = 1400/1410/1420/1500/1510/1610/1700]), the v2_mirrors_v1_across_tier_matrix verification test, and an adult content verdict cross-validation task (task_safety_ptos_adult_content_cross_validation.py introduced with 162 lines; calls the Grok 4.5 judge when safemodel and PtOS verdicts disagree; demotion only). Removed the favoriteCount>=1 gate from build1FavIndex and buildMMEmbMetadataDump in phoenix-rankall-strato, introduced the 1fav_uec_count_race counter
Gen4+3 same repo @ 11a71f8 2026-08-18 Rust/Python Changed the ads brand safety verdict to fall back to V1 when there is no V2 label, and connected SID fields to VMRanker (daily). +208/−70 (14 files). strip_v1_grok_written deleted within a day; redesigned the V2 verdict semantics with a gate on 4 V2_WRITTEN_LABELS (GROK_SFA_V2, GROK_NSFA_V2, GROK_NSFA_LIMITED_V2, GROK_NSFA_EXPANDED_V2): pure V2 when a V2 label is present, V1 fallback otherwise; added the new test v2_defers_to_v1_when_v2_has_not_ruled; new 95-line home-mixer/candidate_hydrators/vf_following_candidate_hydrator.rs (reverse_chron_posts_pipeline uses this hydrator; it keeps only the VF result and sets other candidate fields with ..Default::default()); connected 7 SID fields to home-mixer/scorers/vm_ranker.rs (sid_known, sid_k_l1/2/3, sid_gap_l1/2/3); count_tracked_ids in author_cold_start.rs was simplified to drop the tracked filter and the author_id count and always count tweet_id; grox reply_spam/generators.py deleted PostStreamTaskGenerator and ReplyRankingTaskGenerator absorbed PlanSpamComment and PlanCoordinatedSpam (unified into a single flow); task_filter.py follower threshold 30k → 60k; Phoenix postCreationEventForwarder v3 topic migration, Kafka cluster move, KeyValue(key=Long) mapping; stated an 80ms timeout for the Gizmoduck Strato client in visibility-filtering/server_deps.rs
Gen4+4 same repo @ aad7179 2026-08-19 Rust/Python Switched the optimizer for Phoenix dense parameters from Adam to Muon and added a config for the Phoenix search surface (daily). +3,303/−651 (79 files). The Phoenix dense-param optimizer switched from Adam to Muon with phoenix/xrex/optimizers/recsys/muon.py (240 new lines) and dense_optim.py (47 new lines) (embedding, logits and norm stay on Adam as a hybrid, muon_adam_patterns="embed,_emb,logits,vocab,norm,table"), base LR 1e-3 → 7.1e-4, muon_split_fused="qkv:128", muon_consistent_rms=0.2. New config xrecsys_search (Phoenix expanded to a search surface, vocab 100M/100M/30M, use_product_surface, condition_search_relevance_on_prompt). Introduced RewardOutputs (values and policyLogits) and ScoreInfo.rewardRerankSlotProb in the proto, and a five-quantile link-open probability head (P10/P25/P50/P75/P90). Hardened muted-keyword and quoted-text handling in the Following pipeline with home-mixer/filters/following_viewer_muted_keyword_filter.rs (65 new lines) and candidate_hydrators/quoted_post_text_hydrator.rs (106 new lines). Added the Grok 4.6 internal 10% dial to grox PTOS classifier.py (_GROK_4_6_INTERNAL_DIAL = 0.1) and skipped reclassification via the Manhattan lookup in prior_nsfw.py (34 new lines). Mirrored traffic to an env-gated shadow deployment (xai-vf-shadow) with visibility-filtering/dark_traffic_setup.rs (109 new lines). Promoted enable_stale_post, enable_is_author_followed_by_viewer, enable_is_author_following_viewer and enable_engagement_counts to train-default ON
Gen4+5 same repo @ d0cef2f 2026-08-20 Rust/Python Added a channel that attaches trend context to For You original posts, and ranking now prefers serving-side slate context through a new gate (daily). +479/−200 (14 files). Introduced an AI trend feedback context channel with home-mixer/candidate_hydrators/ai_trend_feedback_context_hydrator.rs (157 new lines) and post_marshaller.rs +29 lines: attaches the top-2 trends to a For You standalone original post and renders them as URT ContextType::TOPIC (includes the landing URL https://x.com/i/trending/{trend_id}; entered only as a fallback when there is no topic feedback). In phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs (+189/−169), split load_tensor into a pyo3-free core (load_tensor_into) and a Python wrapper, changed the tokio runtime unwrap() → map_err, and added a checkpoint path too short guard. Connected a served_slate_context field on PostCandidate and a From<xai_recsys_proto::SlateContext> conversion impl (sid_k_l1/l2/l3, sid_gap_l1/l2/l3); PhoenixScorer extracts candidate_slate_context() from the prediction response and sets it; RankingScorer prefers the serving output over local computation and cache through the new gate UseServedSlateContext (three-way fallback). Grok 4.6 internal dial _GROK_4_6_INTERNAL_DIAL = 0.1 → 0.3 (3× in one day). grox/libs/grok_sampler/llm.py (+27/−13) streams with early termination through a stop_predicate callback and async with aclosing, plus an llm.sample.early_stop.count counter. Introduced a profile_image field in grox/core/data_loaders/data_types.py and post_mapper.py
Gen4+6 same repo @ 28e414f 2026-08-21 Rust/Python Added 20 ads conversion actions to the Phoenix proto and a config that trains heads separately by sample source (daily). +856/−152 (35 files). Introduced 20 ads conversion actions in Phoenix proto ActionName 171–190: 12 from 6 MACT (mobile app conversion tracking) in-app events (purchase, add_to_cart, level_achieved, tutorial_complete, sign_up, custom) × click/view, and 8 view-through conversions (web conversion). 3 lists in constants.py, MACT_IN_APP_LOSS_ACTION_INDICES, VIEW_THROUGH_ACTION_INDICES and SOURCE_SPLIT_CONVERSION_HEAD_INDICES, and a new metric group ads_slim (3 positive signals, 4 negative signals, non-primary conversion click). Added CLIENT_TWEET_SEARCH_QUERY_REFORMULATED to NEGATIVE_FEEDBACK_HEAD_INDICES. models/recsys_model.py (+99/−1) gets a split_head_training_by_source config and a sample_source batch field (coming from the is_delayed_feedback column). Fresh samples train only the engagement head and delayed samples train only the conversion head (the loss masks diverge), with fresh/delayed_clicked/delayed_non_clicked metric slices and the tuning knobs mact_in_app_loss_weight, train_view_through_heads and conversion_keep_mask. Rewrote TRAINING.md and README.md: the flagship ranking config and home_direct_packed_nano train end-to-end with the production Muon recipe (even nano inherits the GB300 overrides), including three options for embedding rowwise AdaGrad: accumulator half-life decay, lazy per-row decay, and decoupled weight decay. Introduced _GB300_OVERRIDES: bs_per_device=512, ep=64, attn_impl="cutedsl_ranker_varlen_attn", remat_policy=RematType.SAVE_GB300_RECSYS, unroll_layer_stack=True, LR 5e-4, checkpoint_every_n=300, Muon config. phoenix/crates/storage/xai-o2/ (154 new lines): released the BaseO2Client trait and a 15-builder-method interface. However, build() always returns the error "the xai-o2 backend is not included in this build; use the default object_store backend" in the open-source build (implementation undisclosed). Added the field SlateContext slateContext = 9 to the proto NextActionDistribution (slate context per action distribution). grox/flows/reply_spam/task_filter.py follower threshold 60k → 80k (1.33×, one of 4 steps). visibility-filtering/dark_traffic_setup.rs switched from shadow to staging: xai-vf-shadow.prod.visibility → xai-vf-service.staging.visibility:grpc, TLS domain .prod. → .staging.. grox/flows/ptos/classifier.py removed Grok 4.5 internal entirely (dial 0.3 → 100%, deleted _eapi_4_5_internal_breaker, _sample_4_5_internal and _GROK_4_6_INTERNAL_DIAL). Added _forward_chunked to phoenix/xrex/train/trainer_recsys.py (chunking retrieval post-embedding into 65k rows). Added a --hotswap_malloc_trim CLI and _glibc_malloc_trim() to phoenix/xrex/inference/model_runner.py and launch_inference.py (help text: "glibc slots otherwise ratchet ~196 MiB/cycle. No-op under jemalloc"). Added conversion_keep_mask, sample_source padding and exclude_required_columns to phoenix/xrex/data/parquet_recsys.py and kafkaloader.py. 3 options in xrecsys_two_tower.py: qk_norm=True, attn_logit_cap=-1, right_anchored_rope=True. The EVERGREEN retrieval dataset file shrank from evergreen_video_1825day to video_4to14day (name and content no longer match, possibly temporary). Fixed the double counting of deadline_shed and inflight_cap in request_metrics.rs. Added a helper to checkpoint_cloud.py and metadata.py that skips uncommitted checkpoints in the orbax tmp-dir
Gen4+7 same repo @ d011592 2026-08-24 Rust/Python Added a HighRisk tier to the ads brand safety verdict and a staging-only path in VF that compares verdicts against a reference VF (daily). +1,418/−117 (19 files). visibility-filtering/reference_compare.rs (812 new lines): calls a Strato-based reference VF (StratoVfClient) in parallel on every request and strictly compares verdicts (action and reason must match exactly); differences are logged only as JSON lines (12KiB budget), with 6 metrics (vf_reference_compared/exact_match/differed/error/skipped/enabled). Gated by VF_DUAL_CALL_HARNESS_ENABLED and APP_ENV; with APP_ENV=prod it panics at boot, which enforces staging-only use. A canary that compares actual verdicts was added, separate from dark_traffic. home-mixer/models/brand_safety.rs (+20/−13): introduced BrandSafetyVerdict::HighRisk = 4, promoted 5 existing medium kinds (NSFW_HIGH_PRECISION, NSFW_HIGH_RECALL, GORE_AND_VIOLENCE_HIGH_PRECISION, PDNA, EGREGIOUS_NSFW) to HighRisk, the new SOFT_NSFW is also HighRisk (symmetric move in v1/v2), the ads utility has_avoid() handles both Medium and High, the NSFW ancestor promotion in ads_brand_safety_vf_hydrator.rs changed from Medium to High, and scored_posts_server.rs introduced a proto HM enum mapping for 5 V2 labels (GROK_SFA_V2, GROK_NSFA_LIMITED_V2, GROK_NSFA_V2, GROK_NSFA_EXPANDED_V2, PTOS_REVIEWED). grox/flows/ptos/classifier.py (+223/−52): new class SafetyPtosPolicyCrossValidator. The new judge also uses the EAPI_GROK_4_5_X_ALGO endpoint that has existed since Gen4+2. It is judge-only, not primary, and for the 3 categories ChildSafety, ViolentMedia and IllegalAndRegulatedBehaviors it rechecks the primary verdict and allows demotion only (disagreement → NoViolation or lighter). It sits alongside the adult content judge from Gen4+2. Introduced USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm} and USE_THREAD_RENDERER_CATEGORIES (rendering includes the parent thread). phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+178/−36): introduced the arguments domains (per-tensor ts.IndexDomain slices), tag (checkpoint subdirectory, default "orbax-ckpt") and concurrent_gb (byte throttle for concurrent decoding). XAI_RESTORE_NODE_SERIALIZE (on by default) serializes restores per node with fcntl.flock() (/dev/shm/xai_restore_node_lock), with gc.collect() and malloc_trim(0) between batches. grox/flows/reply_spam/strato_loader.py (+17/−0): added to ReplyRankingScoreStratoLoader, which has done Strato put and Kafka v2 emit since Gen4, a fan-out that writes to regional caches (Atla, Pdxa) when score ≤ 1.0. grox/flows/reply_spam/task_filter.py follower threshold 80k → 100k (five times in 11 days: 15k→30k→40k→60k→80k→100k). grox/core/data_loaders/data_types.py, post_mapper.py and lm/post.py: introduced the SpaceMetadata class (currently only title), a space_metadata field on Post, and the renderer includes audio space cards in the LLM input. ads_injection_logging_side_effect.rs exposes the EnableAdsBrandSafetyVerdictV2 experiment bucket through logging as ddg_experiment_bucket
Gen4+8 same repo @ 0d3cdd8 2026-08-25 Rust/Python Added a path where AES validates and executes actions requested by scorers, and VMRanker now passes rank server scores through unchanged (daily). +5,850/−586 (47 files). abuse-enforcement-service/service-lib/src/generic_actions.rs (663 new lines), growthbook.rs (+126), lib.rs (+276/−19), rules.rs (+234): introduced the AES generic_actions passthrough. A scorer sends an added requested_actions list, and AES executes it after double validation against a hardcoded kind allowlist (5 user kinds, 2 post kinds, MAX_REQUESTED_ACTIONS_PER_MESSAGE=16) and a GrowthBook dynamic allowlist (kinds, suspend_policies, labels); rejected requests are recorded as JSON in outcome.info["requested_actions_skipped"] to keep observability. The Decision::ActRequestedActions variant and a rule pair in enforcement_user.yaml (platform_row_without_requested_actions skip, act_requested_actions act). Example of a new scorer input: panda_reports_embedding_v10_rough_spam (embedding-based spam detector v10, temporary PlatformManipulation suspend). home-mixer/scorers/vm_ranker.rs (+11/−128): greatly simplified the VMRanker path. Deleted AUTHOR_DIVERSITY_VALUE_MODEL_ID, the MPN fallback logic and the AuthorColdStart post-processing; the only new constant is DPP_VALUE_MODEL_ID. VMRanker now passes through the scores returned by the rank server unchanged. ranking_scorer.rs (+28/−201): deleted the 88-line effective_head_weights method (client-side head weight generation is gone, moved into the server's own logic). home-mixer/candidate_hydrators/following_blocked_by_hydrator.rs (65 new lines), reconnected filters/author_socialgraph_filter.rs, candidate_pipeline/reverse_chron_posts_pipeline.rs (+27/−2): expanded block relations in the Following pipeline. In addition to viewer→author blocked/muted, four more directions were added for six-way filtering: the author blocks the viewer, the quoted author blocks the viewer, the viewer blocks the quoted author, the viewer blocks the retweeted author. SocialGraphClient calls check_blocked_by in batches. home-mixer/filters/brazil_2026_election_filter.rs (+3,379/−5, 4,947 lines in the whole file): updated the Brazil2026ElectionFilter account list (reflects the 2026-08-25 edition of TSE dadosabertos), and the README Notable Updates also states "(Account list updated August 25, 2026.)". user_id stays obfuscated while @username is exposed as is (a transparency/privacy compromise). phoenix-rankall/src/store/base.rs (+202/−61), config/mod.rs (+51/−12), tests/integration_test.rs (+92): connected rankall prefloor snapshots. For windows with min_age, prefloor_<window>.parquet is loaded concurrently, with PREFLOOR_SLACK_SECS = 6.0 * 3600.0 (6 hours). New candidates are held in a separate pool until they pass the min_age threshold and are reflected in production as soon as they pass. phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs (+155/−0): tokio::spawn hardening. Changing join_all(futures) to join_all(futures.map(tokio::task::spawn)) makes the rate limit keep the in-flight cap exactly without sequential polling. Two regression tests (rate_limit_caps_in_flight_despite_spawn, rate_limit_paces_between_spawned_batches). Renamed README Latest Updates to Notable Updates. Minor extensions in the URT marshalling layer (ad_marshaller +18, client_event +27, wtf_marshaller +14, reverse_chron_following +12). New connections in grox upa/constants.py (+2) and generators.py (+11) (contents not checked). engagement_counts_hydrator +16
Gen4+9 same repo @ 45b48ba 2026-08-26 Rust/Python Removed VF's TES media fallback cache, and the canary workload was dropped from dark traffic mirroring (subsection, daily).
Gen4+10 same repo @ 24c6094 2026-08-28 Rust/Python Added a VF safety label cache warmer and a multi-risk ads blender (not the default at the time), and the trainer began using streamed restore (subsection, daily).
Gen4+11 same repo @ bc8e5f0 2026-08-28 Rust The VF rule file got thinner with no policy change, a 90-case golden corpus was added, and Phoenix requests carried a re-ranking head tag (subsection, daily).
Gen4+12 same repo @ 6384ca7 2026-09-01 Rust/Python Put the 16 NSFW-gated countries of VF into the code defaults and added a PtOS flow that judges separately only videos whose fav count is at or above a threshold (daily). +2,247/−735 (70 files). visibility-filtering/params.rs (216 new lines): the code now states the 16 NSFW-gated countries as defaults (ar au br ca de es fr gb id it kr mx nl ph pt th). Refreshed every 30 seconds from the feature switch rust_vf_nsfw_gating_countries via ArcSwap, compared for drift on every refresh against the sibling YAML country_specific_nsfw_content_gating.yml (Scala VF), and observed through the nsfw_gating_countries_drift counter. If an invalid element is mixed into the array, the whole list falls back to the default rather than a partial list (locked by the malformed_value_falls_back_whole_not_partial test). visibility-filtering/rules/nsfw_age_gating.rs (+46/−30): SensitiveViewerNoStatedAgeDropRule references this country list and forces age-gating as a drop action for viewers with no stated age only in the 16 countries. grox/flows/ptos/plan_safety_ptos_special_video.py (55 new lines), task_safety_ptos_special_video_filter.py (40 new lines), task_special_video_screen.py (49 new lines), grox/libs/video_tools/image.py (+125): introduced the 8-stage PlanSafetyPtosSpecialVideo DAG. Only videos whose fav count is at or above deluxe_fav_count_threshold enter (cost control). If motion_reveal_frames exist (frames in which the static layer is subtracted and the faint motion layer is amplified, revealing evasive content hidden by a static overlay), the video is caught by screening; only the videos that pass get category/policy/safemodel verdicts and Grok 4.5 judge (EAPI_GROK_4_5_X_ALGO) cross-validation. The MOTION_REVEAL_DESCRIPTION constant puts into the Grok prompt even a rubric saying "clothes may look removed, so compare against the original frame". Only the special video path demotes to Soft with fail-close when the judge fails (the other paths keep the original verdict by default). home-mixer/filters/brazil_2026_election_filter.rs (+222/−74): ongoing maintenance. Refactors of Phoenix serving/config in phoenix/crates/common/xai-recsys/src/model_config.rs (+96/−2), phoenix/xrex/configs/xrecsys_two_tower.py (+1/−7) and phoenix/crates/serving/xai-recsys-mm-server/src/mm_embedding_client.rs (+128/−55), among others. New phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py and encrypted_kvstore.py (49 lines + 49 lines): encrypted checkpoint kvstore (direct relation to feed ranking not confirmed)
Gen4+13 same repo @ 7ba7768 2026-09-01 Rust/Python Restructured the VF rules as a pure refactor, and added SID reconstruction embeddings, StableHLO bundle export and encrypted checkpoints, all off by default (daily). +2,964/−2,861 (34 files). Restructured the visibility-filtering rules: 9 deleted (nsfw_age_gating, nsfw_interstitial, nullcast_rule, socialgraph_rules, tes_rules, tweet_flag_rules, tweet_label_drops, user_label_drops, user_rules, 2,670 lines in total) → 3 introduced (rule_spec.rs 89, tweet_rules.rs 1125, author_rules.rs 354). Replaced the per-rule struct + impl Rule with a shared enum RuleSpec (3 variants: Tweet/Author/Custom) and moved Arc<NsfwGatingCountries> from the rule fields to the RuleContext field, which turned the rules into zero-sized literals. Pure refactor: the wired_rule_order_matches_pre_migration_sequence test locks the name order of 28 home rules and 26 recs rules as literals. phoenix/xrex/models/recsys_sid.py (72 new lines): loads an RQ-VAE-style stacked residual codebook (stages: (num_levels, codebook_size, code_dim)) and a 2-layer MLP decoder from an offline .safetensors and injects a dense reconstruction into the ranker post tower, with stop_gradient, L2 normalization, and one trainable project. Gated by RecsysAggregatedModelConfig.sid_embedding_mode: Literal["learned", "recon"] = "learned" and sid_decoder_path: str = "", off by default. phoenix/xrex/train/recsys_bundle_export.py (634 new lines): lowers the ranking forward with jax.export and exports StableHLO MLIR bytecode (.mlirbc), .jax_export and MANIFEST.json, with a separate program per batch bucket (export_bundle_bs_per_device="1,2,4"), target ("cuda",). Embedding lookup is outside the export graph (the input is the pre-gathered merged_embeddings); MANIFEST.embedding holds the shard, width, slice and hash table geometry, and the serving side concatenates the lookup results from the serving embedding server. Off by default via export_stablehlo_bundle: bool = False. phoenix/python/training/xai-checkpointing/xai_checkpointing/orbax_encrypted.py (288 new lines): an envelope-encrypted Orbax writer; every file must start with the b"XAIENC01" magic header, assert_all_enveloped refuses the commit if plaintext remains after finalize, with a KMS-issued DEK (_DEK file) and AES chunks (default 8 MiB). Saving plaintext after restoring from an encrypted checkpoint is explicitly refused (_restored_encrypted flag). Off by default via CheckpointConfig.encrypt=False. DPA proto: added repeated int32 sid_codes = 8 to DpaProductInfo; ads DPA product SIDs are carried in the serving wire format (separate from organic post SIDs). Reserved 39 slots with reserved 214 to 252 in the action enum. Zero-copy refactor of mm_embedding_client; search_query_embeddings are now written repeatedly only for non-padding real candidates (dropped replication across all candidates). The grox mm-emb Kafka publisher migrated to MultiRegionKafkaProducer
Gen4+14 same repo @ 85ac72a 2026-09-02 Rust/Python home-mixer began requesting Phoenix backbone scores, and a path was added for the scarecrow bot to put reports into the XReview queue (subsection, daily).
Gen4+15 same repo @ 9b0dc31 2026-09-04 Rust/Python Added a thread-level reply spam path to grox, and moved the PTOS illegal and regulated categories to Gemma and Grok 4.1-fast (subsection, daily).
Gen4+16 same repo @ 902a06f 2026-09-04 Rust/Python Made impression logging symmetric across the two arms of the cold start experiment, and added an experiment param override to Phoenix requests (subsection, daily).
Gen4+17 same repo @ 49815da 2026-09-08 Rust/Python Added per-action weight perturbation (default 0.0) and a path that re-judges the first 10 seconds of videos longer than 30 seconds (subsection, daily).
Gen4+18 same repo @ 75d93d9 2026-09-09 Rust/Python VF now filters candidates by explicit action values, and a sixth ads format and a live broadcast PTOS path were added (subsection, daily).
Gen4+19 same repo @ fee1d0f 2026-09-10 Rust/Python Unified the VF timeout for TES and Gizmoduck calls at 390 ms, and VF now evaluates author rules even when TES fails (subsection, daily).
Gen4+20 same repo @ 6bb4594 2026-09-12 Rust/Python VMRanker DPP now reduces the gain of candidates close to the seed post, and world-level bystander reports are no longer filtered out automatically (subsection, daily).
Gen4+21 same repo @ 2d4a03c 2026-09-15 Rust/Python Added an observation path that measures response diversity at three stages, and added a continuous head to the Phoenix proto that regresses ads purchase value (daily). +592/−9 (17 files). home-mixer/side_effects/response_diversity_stats_side_effect.rs (186 lines, new): a side effect that observes response diversity at three stages in parallel (pre_heuristic/top10/final) was connected as the sixth one in the Phoenix candidate pipeline. Default sampling is 5%, and when the new param EnableResponseDiversityStatsExperimentBucket places a request in an experiment bucket, it runs at 100% within that bucket. Each stage emits 12–15 histograms with the ResponseDiversity.* prefix (Size·UniqueAuthors·MaxAuthorShare·AuthorHhi·AuthorEntropyNorm·UniqueSources·SourceEntropyNorm·InNetworkShare·SidCoverage·UniqueSidL1/L2·MaxSidL1Share·SidL{1,2}EntropyNorm). pre_heuristic is a top-K counterfactual in descending order of weighted_score (the raw score before DPP, author-decay and OON multipliers), so the actual diversity gain and ranking loss of the heuristic layer can be measured. home-mixer/util/composition.rs (63 lines, new): the Composition primitive (size·unique·max_count·HHI·entropy_norm) computes multiset statistics along any axis in O(n). home-mixer/candidate_hydrators/tweet_type_metrics_hydrator.rs (+100/−4), home-mixer/util/tweet_type_metrics.rs (+27): added 12 slate-axis bits to the TweetTypeMetrics bitset. Global axes (239-244): unique_ratio_lte_50_pct·unique_author_lte_5/10/15·single_author_gte_25/50_pct. Context axes (315-320): author_repeat_in_slate·author_repeat_gte_3_in_slate·author_not_engaged_by_viewer·has_semantic_ids·sid_l{1,2}_repeat_in_slate. home-mixer/models/candidate.rs (+8): semantic_id_prefix(levels) helper. home-mixer/util/viewer_history.rs (61 lines, new): positively_engaged_author_ids extracts, from the Arrow IPC columnar_scoring_sequence, the set of authorIds for which at least one of 18 positive actions is on (fav·reply·quote·RT·click·profile_click·photo_expand·video_open·video_quality_view·share·DM·copy_link·bookmark·follow). Phoenix proto ADS_WEB_CT_PURCHASE_VALUE = 5 (the fifth continuous head of ContinuousActionName, regressing purchase value after a web click-through conversion). PredictNextActionsRequest field 23 repeated PurchaseValueBaseline purchase_value_baselines (impression_id·advertiser_account_id·mean_value_usd_28d; a per-advertiser baseline of mean purchase value over the last 28 days enters through the request payload). phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs (114 lines, new; is_valid_baseline·find_baseline, 5 unit tests for wire tag stability). phoenix/xrex/configs/xrecsys_two_tower.py (+1/−1): H100 training attn kernel pallas_ranker_varlen_attn → cutedsl_ranker_varlen_attn (unified with the same CUTe DSL family as GB300; follows the open-sourcing of the SM80 and SM90 forward and backward kernels in Gen4+17). phoenix/xrex/data/streaming/kafkaloader.py (+2/−2): Kafka OTel export interval 30_000 → 120_000 ms (relaxed 4×). grox/flows/reply_spam/task_filter.py (+1/−1): TaskCoordinatedSpamFilter follower threshold 10k → 30k (the third increase since 1k). home-mixer/params/param.rs mirror timestamp 2026-09-11T16:22:45Z → 2026-09-14T16:22:15Z
Gen4+22 same repo @ fad2f71 2026-09-16 Rust/Python Phoenix started training on click dwell time by default, and the ads brand safety V2 gate was rolled back (daily). +552/−233 (26 files). The Phoenix ads ADS_WEB_CT_PURCHASE_VALUE Huber ratio loss was formally wired into the training graph (weight 0 by default), a new binary continuous loss type was introduced, click_dwell_time is now trained by default with sigmoid BCE (threshold=10s), the click_dwell feature is also on by default in the history encoder, the XAUC (clicked-only pairwise concordance) metric was introduced, and a serving rescale helper for sigmoid heads was introduced. The user_gender categorical index mapping was replaced, from hardcoded (Male=2, Female=1) to the proto enum as is (a silent change to the embedding table indices). The H100 attn kernel override was finally switched from pallas_ranker_varlen_attn → cutedsl_ranker_varlen_attn (the override was cleaned up after the service code change in Gen4+21). The VMRanker gRPC client now sets DSCP=InferenceCritical. EnableAdsBrandSafetyVerdictV2 changed from true → false (rollback of the V2 ads brand safety gate). The grox reply-spam classifier now splits in two at thread followers >150k, sending those to a dedicated oai-gemma4-26b-reply-spam model. The task_filter threshold went from 150k → 180k (GROK_GEMMA_FOLLOWER_SPLIT). The grox quoted-post rendering gained by @handle and a self-quote marker. AES now passes decision_outcome.score_id. Six enums in VF safety_label/hydration/rules/twemcache were unified with the strum::IntoStaticStr macro (−180 lines, no behavior change)
Gen4+23 same repo @ 42266f3 2026-09-17 Rust/Scala Added a per-fav-count-tier holdout filter that is off by default, and raised the default cold start post age cap from 24 hours to 48 hours (daily). +2,288/−3,627 (64 files). home-mixer/filters/fav_holdout_filter.rs (166 lines, new): the per-fav-count-step holdout filter is wired in after InventoryHoldoutFilter (last in the pipeline). 10K+ fav = 15%, 5K+ = 15%, ..., 100+ = 7%, 20+ = 5%, 2+ = 3%, 1+ = 2%, ≤1 = 0% (a 23-tier step table). The holdout decision reuses InventoryHoldoutFilter::is_held_out(post_id, viewer_id, percent) (deterministic, using a splitmix64-family double-mixing hash). Param EnableFavHoldout=false (off by default). home-mixer/scorers/ranking_scorer.rs (+10/−32): EnableClickDwellLowFavRatePenalty, which had existed since the Gen4 release, and its four tuning params (baseline·alpha·floor·cap) were all deleted, and one new parameter, EnableCdwellOnImpr (off by default), took their place. When on, click_dwell_term = click_dwell_time × click_score (that is, the product of click probability and dwell). The gate label also changed: gate.click_dwell_low_fav_rate_penalty → gate.cdwell_on_impr. home-mixer/scorers/author_cold_start.rs (+7/−50): in the cold-start scorer, the is_phoenix_moe(candidate) AND gate was removed from the Treatment arm (it checks only corpus == Treatment), the separate cold_start_freshness_eligible (applying max_age only to the Treatment arm) was deleted, and apply_cold_start was unified to filter every arm with duration_since_creation_opt(c.tweet_id).is_some_and(|age| age <= params.max_post_age). ColdStartMaxPostAgeSecs default: 86400 → 172800 (24h → 48h). Phoenix proto ActionName: LLM_JUDGE_RELEVANT_TO_SEARCH=107 and LLM_JUDGE_NOT_RELEVANT_TO_SEARCH=108 (introduced in Gen4+20) were freed after only 5 days, and CLIENT_TWEET_LIVE_STREAM_REPLY=107 and RESERVED_108 took their place. Ads delayed conversion attribution was expanded to 3 tiers (20 added, 214-233): 10 *_DELAYED_TIER_2 entries in 214-223 (ADS_ATTRIBUTED_KEY_CLICK·ADS_ATTRIBUTED_CLICK·ADS_PURCHASE·ADS_MID_FUNNEL·ADS_ADD_TO_CART·ADS_UPPER_FUNNEL·ADS_WEB·ADS_SEARCH·ADS_SIGN_UP·ADS_CHECKOUT_INITIATED), and 10 symmetric *_DELAYED_TIER_3 entries in 224-233. reserved 214 to 252 → reserved 234 to 252. The entire VF twemcache subtree was removed (2,900 lines): after 11 files under visibility-filtering/twemcache/ were deleted, safety_label_source/twemcache.rs was rebased onto the shared crate xai_cache::{CacheClient, KVCacheError, Key, Value}. The FallbackReason mapping was rearranged (KVCacheError::Timeout(_) → Timeout, Backpressure → Backpressure, _ → Other). The VF evaluate_tweets RPC and a Thrift Action payload decoder were introduced: visibility-filtering-client/evaluated.rs (122 lines, new) exposes decode_action(bytes) → Result<Action>. 64KB payload cap, Compact Thrift, trailing bytes forbidden, Action::NotEvaluated rejected. EvaluationResult { Evaluated(Box<Action>), NotEvaluated, Failed }. An async fn was added to the VisibilityFilteringService trait (the stub is Err(Status::unimplemented("unused"))). UTH daily takedown label reproduction job stack: UthDailyPostTakedownLabelsJob.scala (283 lines)·TakedownDeltas.scala (169 lines)·TakedownLabels.scala (103 lines)·TakedownReplay.scala (80 lines) are new, plus underTheHoodLabels.strato (+164), UthPctdAccountTakedownEventsJob.scala (+392/−63), UthUserMonthMhPublisherJob.scala (+140/−34). Takedown reason redaction filter: takedowns/gizmoduck/RedactUnenforcedTakedownsFilter.scala (81 lines, new), ReadFilters.scala (39 lines), tweet-entity-service/TakedownReasonsTransform.scala (73 lines)·TakedownReasonsColumn.scala (25 lines). Minor Phoenix serving/train adjustments in 8 files. param.rs mirror sync 2026-09-15T16:25:02Z → 2026-09-16T16:23:12Z
Gen4+24 same repo @ c279172 2026-09-18 Rust/Python Merged three ads delayed feedback settings into one switch that is off by default, and added a video spam clone comparison flow to grox (subsection, daily).
Gen4+25 same repo @ 8b25829 2026-09-18 Rust/Python Rolled back the home-mixer dwell-regret mode and GateModel, and wired a cold start slice into Phoenix retrieval (daily). +2,877/−1,828 (41 files). The home-mixer gated_dwell_regret scoring mode and GateModel were rolled back entirely: the whole file home-mixer/scorers/value_model_gate.rs was deleted (537 lines, a 19-feature user-gate logistic: seq_len·n_fav·n_reply·n_rt_quote·n_vqv·n_click·n_bm_share·n_profile_follow·n_photo·n_negfb·n_7d·n_1d·active_days·active_days_7d·days_since_last·span_days·followers·followings·account_age_years). In ranking_scorer.rs (+12/−465), DwellRegretWeights, compute_dwell_regret_base_scores, centered_ratio, the sigmoid helper, the use_dwell_regret branch, and 7 regression tests were all deleted. In param.rs (+8/−110), ValueModelMode and all 17 DwellRegret* parameters (Temperature=10.0·DwellFloor=1.0·AlphaFavorite/Reply/Retweet/Quote/Share/ShareViaDm/ShareViaCopyLink=1.0·NegNotInterested=-10000·NegBlockAuthor=-8000·NegMuteAuthor=-15000·NegReport=-60000·GateWeights·GateBias=1.033918·GateThreshold=-0.634264·GateHysteresisBand=0.0) were deleted. Home-mixer scoring is back to the single weighted path. The Phoenix retrieval cold-start slice was formally wired in: a new param PhoenixColdStartMaxResults (feature switch rust_home_mixer_phoenix_cold_start_max_results, default 0) was added, and phoenix_source.rs (+67/−19) sends two caps (PhoenixMaxResults·PhoenixColdStartMaxResults) together in the retrieval RPC; if a response candidate has dataset_type: u32== HOME_COLD_DATASET_TYPE(=13), its served_type is mapped to the new enum ForYouPhoenixRetrievalCold (otherwise to the existing ForYouPhoenixRetrieval). In scored_stats_side_effect.rs (+25), a new function record_phoenix_retrieval_cold_stats emits selected/non-selected size statistics with the PhoenixRetrievalColdTweets prefix. MoE inference cluster ID Experiment2Memy04 → Experiment3Memy04 roll-forward. A two-tower retrieval path was introduced in the Phoenix bundle export: phoenix/xrex/train/recsys_bundle_export.py (+559/−107) gained the new constants KIND_RANKING="recsys_ranking_forward" and KIND_RETRIEVAL="recsys_retrieval_forward", the RetrievalExport dataclass (large_k·target_dataset_types·use_async_topk·use_radix_select_topk·post_table_shape/dtype), four wire keys (POST_TABLE_KEY="post_embeddings.embeddings"·POST_DATASET_TYPES_KEY="post_embeddings.dataset_types"·POST_IDS_KEY·AUTHOR_IDS_KEY), _make_retrieval_forward_fn (user tower → top-K per target dataset type over the whole post table), and _user_tower_sequence_len (retrieval has no candidate axis, only prefix and history). The mesh_devices: int = 1 argument supports retrieval SPMD export (ranking is still forced to single-device, combining seqpack with two_tower is forbidden, and enable_int8_post_table·enable_bloom_filter·enable_topic_filter are unsupported). build_bundle now also accepts RecsysTwoTowerModelConfig. VF evaluate_tweets gRPC server implementation: the new file visibility-filtering/evaluate_tweets.rs (+258) implements EvaluateTweetsEndpoint::handle: it allows only 3 safety_levels (FILTER_ALL·TIMELINE_HOME·TIMELINE_HOME_RECOMMENDATIONS), forwards the request to FilterTweets::run, and assembles the response into a per-tweet Outcome. The stub Err(Status::unimplemented("EvaluateTweets is not served yet")) in server.rs (+86/−2) was replaced with self.evaluate_tweets.handle(request).await, and the evaluate_tweets_loopback integration test locks the gRPC round trip. In filter.rs (+336/−13), a new enum EvaluationStatus { Evaluated, UnresolvedAuthor, Failed } was added to FilterOutcome, and a failed_ids: HashSet<TweetId> field on HydrationOutput distinguishes tweets whose hydration failed. TES visibility fields migrated to a Strato column hydrator: the new file visibility-filtering/hydration/tes_composite.rs (+607) fetches the 11-field TweetForVisibility (author_id·source_tweet_id·is_nullcast·nsfw_user·nsfw_admin·has_takedown·takedown_reasons·media·is_community_tweet·edit_control·exclusive_conversation_author_id) from the Strato column tweetypie/federated/tweetForVisibility.Tweet (operation fetch) with an MVal batch call. In tes_hydrator.rs (+113/−320), the existing 7-field TweetHydration struct (nullcast·community·nsfw_user·nsfw_admin·takedown_reasons·edit_control·media) was deleted and unified into a single TweetHydrationBatch<TweetForVisibility>. TES still handles PureCoreData (author_id·text·source_tweet_id), and the new Strato column handles the visibility fields. hydration/mod.rs (+78/−22): Completeness<T> wrapping exposes partial hydration state. xrex_cuda_kernels split into an external package: the three kernel loaders phoenix/xrex/cuda/{adler32,top_k_by_key,unique}/__init__.py were rewired from from xrex.cuda.<name>.src import to import xrex_cuda_kernels.<name>_api, with a fallback to the zlib pure_callback reference path and a logging.warning on ModuleNotFoundError. grox media_injected_adult_infrared_video_spam_detection fav-count reasoning gating: in task_safety_ptos_media_injected_adult_infrared_video_spam_detection.py (+14/−9), reasoning mode is activated at or above the new constant MEDIA_INJECTED_REASONING_FAV (value undisclosed) by clearing _strip_thinking_restrictions(prompt) and the THINKING_CONTROL_END marker. param.rs mirror sync 2026-09-17T20:46:44Z → 2026-09-18T16:21:20Z
Gen4+26 same repo @ 3aa0fa33 2026-09-22 Rust/Python VMRanker grew from a DPP wrapper that handled only scores into one that also carries value model inputs, and a pipeline was added that records which retrieval source each candidate came from. The VF rule engine was rewritten so that a verdict is expressed as a two-branch tree of withheld and shown daily. +5,588/−2,601 (72 files). VMRanker was extended from a score-only DPP wrapper to a value-model input carrier. Two new parameters, VMRankerSendValueModelInputs and VMRankerComputeValueModel (both false by default), split it into three modes (dpp_only, send_inputs, compute_value_model). The new file home-mixer/scorers/vm_ranker_request.rs (+74) puts the viewer context, 8 per-candidate fields, 26 PhoenixScores fields, and 2 policy flags into RankRequest. The policy flags are author_policy_zeroed and cold_start_lift_to_rank. The new domain type FsRecipientInputs (12 fields, home-mixer/models/fs_recipient.rs, +55) unifies the information assembled in FS matching into one type so that downstream scorers can reuse it. A new cluster, Experiment6, was registered. A retrieval candidate attribution pipeline was introduced. The new file home-mixer/side_effects/retrieval_candidates_kafka_side_effect.rs (+209) publishes a RetrievalCandidateBatch proto to RETRIEVAL_CANDIDATES_TOPIC. The sample is 5% (RetrievalCandidatesKafkaSamplePercent=5.0) and the candidate cap is 200 (RetrievalCandidatesKafkaMaxCandidates=200). The field backing this, PostCandidate::retrieval_sources: Vec<RetrievalSource> (served_type × cluster × score × position), was introduced in home-mixer/models/candidate.rs. The three retrieval RPC sources (Phoenix, MoE, Topics) moved to the new helper candidates_from_retrieval_response, and the dedup filter merges the origins of multiple sources. The corresponding aggregate metric families Phoenix.RetrievalContribution.{Selected,PhoenixTopK,SelectedTotal} were wired into side_effects/scored_stats_side_effect.rs (+90). The VF rule engine was fully rewritten from a flat VfAction to a two-branch Verdict tree. The rewrite covers about 2,500 lines. The new file visibility-filtering/models/verdict.rs (+62) defines the two-branch tree of Verdict::Withheld{Drop|Tombstone} and Shown{media?, engagement?}. An optional media interstitial and optional limited engagement can overlap, giving 6 combinations. The new file visibility-filtering/treatment.rs (+446) projects a verdict into three forms: Thrift Action, proto, and metric label. The TombstoneReason::LocalRegulations and LimitedEngagementReason::ConversationControl slots exist only on the wire and have no rule yet; they are marked #[expect(dead_code)]. The rule representation changed from functions to data. RuleClause { rule_name, when: &[Condition], applies_to: Audience, action: ActionSpec } in visibility-filtering/rules/rule_spec.rs (+240/−69) represents a rule. The predicate catalog has four families (11 TweetPredicate, 8 AuthorPredicate, 5 ViewerPredicate, 5 RelationshipPredicate). The new TimelineHomeHydration is the fourth SafetyLevel, wired to a 6-rule policy dedicated to the hydration path. Thrift enum 4 is now recognized. The new helper tweet_timestamp_ms(tweet_id) exposes a Snowflake epoch helper. The new rule NsfwHighPrecisionAdultInterstitialRule applies InterstitialReason::Nudity(true) to tweets after CreatedAfter(1705536000000) (2024-01-18 UTC). It applies the existing Sensitive(true) to earlier tweets. Regression tests fix the rule order at 30 rules for TimelineHome and 56 for TimelineHomeRecommendations. The bloom filter and topic filter in Phoenix xrex serving were merged into model_runner. phoenix/xrex/inference/serving_filters_runner.py (−543) and phoenix/xrex/models/recsys_two_tower_serving_filters.py (−173) were deleted. Both filters were merged into RetrievalModelRunner in phoenix/xrex/inference/model_runner.py (+328). The topic filter uses six option parquet files (1fav_topic_option_{0..5}_1day.parquet). The bloom filter mask lives in per-bucket pinned host memory and is filled by rust (RetrieveRequestBatch::build_eligible_mask_into_shards()). If either filter is on, live post-embeddings hotswap is disabled (drained finalize path). quantize_post_table() in the new phoenix/xrex/inference/int8_post_table.py (+11) converts bf16 to int8 with per-post absolute-max scaling. The post embedding pspec was extended to 4-axis sharding of ("stage", "expert", "replica", "data"), adding pipeline and data parallel axes. The Phoenix engine's copy_port was refactored at large scale. phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs (+879/−93) replaced the batch-per-batch approach with tokio JoinSet-based streaming continuation. This resolves the problem of slow-tail transfers blocking the next batch. There are two new env vars. COPY_PORT_TRAINER_CONNS_PER_SOURCE clamps parallel connections per trainer to 1–16, and XAI_RECSYS_RDMA=1 disables multiplexing on RDMA. The new spawn_striped_trainer_downloads() splits the files owned by a trainer evenly across the connections and pipelines them. Two hardcoded home-mixer constants were promoted to FS. NEW_USER_OON_WEIGHT_FACTOR became NewUserOonWeightFactor (default 0.00001). ANN_MAX_POST_CANDIDATE_AGE_HOURS became SimclustersMaxCandidateAgeHours (default 48). The Simclusters cache key was extended to (signal_id, max_candidate_age_hours). Cold-start decisions are left on candidates as tags. AuthorColdStart::apply_with_decisions() returns ColdStartOutcome { scores, author_policy_zeroed: Vec<bool>, lift: Option<ColdStartLift> }. RankingScorer fills the two decision tags into PostCandidate.author_policy_zeroed and cold_start_lift_to_rank. Dedup of the same author is locked by the test control_viewer_dedups_author_rules_eval_for_duplicate_authors. A kill-switch decider was added to the Phoenix MoE source. When PHOENIX_MOE_SOURCE_KILL_SWITCH_DECIDER = "disable_home_mixer_phoenix_moe_source" is on, the source is turned off immediately. PhoenixMoeColdStartMaxResults (default 0) was introduced. It exposes to MoE the non-MoE cold-start slice cap from Gen4+25. Grox SimpleReplyScorer was extended. A reply with a quoted post is delegated to the full ReplyScorer. Three-stage JSON recovery (json_repair.repair_json() fallback, with regex as the last defense) and a 4-bucket histogram of ranked_replies_scores were added. The param.rs mirror sync time is 2026-09-21T16:23:47Z.
Gen4+27 same repo @ 1b3fec20 2026-09-23 Rust/Python The VM-Ranker service now computes the value model directly, and the parameter mirror was released as a second channel. In VF, the conversation-control slot that was only reserved the day before was filled with new rules that limit reply engagement from viewers outside a conversation's reply scope daily. +4,993/−3,132 (92 files). The VM-Ranker service computes the value model directly. compute_or_fallback in the new file vm-ranker/scoring/value_model.rs (+191) calls the shared crate function xai_value_model::compute_value_scores() inline. It is not a separate model server. There are three inputs: ValueModelWeights with 34 fields, QueryScoringContext with the OON multipliers, and CandidateScoringInputs with the Phoenix engagement probabilities. The output is two vectors, scores and weighted. The parameter mirror was released as a second channel. Positive weights in the new vm-ranker/params.rs (+211, 56 parameters) include FavoriteWeight=0.5, ReplyWeight=5.0, RetweetWeight=1.0, QuoteWeight=5.0, ShareWeight=2.0, FollowAuthorWeight=4.0, ClickWeight=0.4, VideoOpenWeight=0.07, and PhotoExpandWeight=0.05. Negative weights are NotInterestedWeight=-43.2, BlockAuthorWeight=-31.2, MuteAuthorWeight=-58.8, and ReportWeight=-234.0. Other values include DppEnabled=true, DppTheta=0.65, DppMaxSelectedRank=150, ComputeValueModel=false (off by default), OonWeightFactor=0.75, TopicOonWeightFactor=0.5, NewUserOonWeightFactor=0.00001, EnableAuthorDiversity=true, AuthorDiversityDecay=0.5, and AuthorDiversityFloor=0.25. The new vm-ranker/config_sync.rs (+235) handles parameter updates. Every 30 seconds it sparse-clones and resets a git remote, polls features/home-mixer/main/rust_home_mixer.yml and abdecider/abdecider.yml, and hot-reloads without a process restart. A path that skips head recomputation for cached candidates was introduced. The home-mixer param CachedPostsReuseWeightedScore, RankingScorer::reuses_cached_weighted_score(), and unoffset_score() make up this path. An experiment payload channel was introduced. The home-mixer param VMRankerSendDebiasInputs and the new home-mixer/scorers/vm_ranker_debias_payload.rs (+52) put per-candidate content features and the request context into the experiment_payload proto field. The content features are has_video, max_video_duration_ms, has_photo, media_count, weighted_text_len, newline_count, has_url, and their quoted equivalents. The request context is engine_time_hint_ms, a columnar user-sequence ArrowIPC, product_surface, and client_app_id. The home-mixer vm_ranker.rs became thinner (15/−31). The four-mode branching that was inline is now encapsulated in the struct OptionalInputs { value_model, compute_value_model, cached_weighted_score, debias }. The response tuple was extended to (score, weighted_score). New metrics on the value model path are vm_ranker_value_model_requests_total{mode=passthrough|value_model|fallback}, vm_ranker_value_model_fallback_total{reason=...}, and vm_ranker_value_model_stage_total{stage=heads|cached_weighted}. Five vm_ranker_config_sync_* metrics and vm_ranker_params_resolved_total were also introduced. The VF conversation-control slot was filled with four rules. The LimitedEngagementReason::ConversationControl slot reserved the day before (3aa0fa33) is now actually wired. The new hydrator visibility-filtering/hydration/conversation_control_hydrator.rs (+310) looks up the arm, root author, and invited users through TES get_conversation_controls(). For the Community arm, the new socialgraph call batch_check_followed_by(viewer_id, root_authors) (reverse edge) checks whether the root author follows the viewer. For the Subscribers arm, batch_check_super_follows(viewer_id, root_authors) checks whether the viewer super-follows the root. The result goes into ConversationControlFeatures { control, root_author_follows_viewer, viewer_super_follows_root_author } in the new type visibility-filtering/models/conversation_control.rs. The four new rules are in visibility-filtering/rules/tweet_rules.rs:328-383. Each rule assigns LimitedEngagementReason::ConversationControl to the reply engagement of viewers who do not meet the condition of its arm. LimitRepliesByInvitationConversationRule targets viewers who were not invited, and LimitRepliesCommunityConversationRule targets viewers who are not followed. LimitRepliesSubscribersConversationRule targets viewers who do not super-follow, and LimitRepliesVerifiedConversationRule targets unverified viewers. Viewer::LoggedIn(u64) was expanded to Viewer::LoggedIn { id, profile: ViewerProfile { allows_sensitive_media, viewer_age, has_verified_badge, account_country_code } }. has_verified_badge is true when the Gizmoduck value is VerifiedType::Business/Government or is_blue_verified is set. VF hydration was reorganized to be lazy. The new enum Hydrator (7 variants) and the bitset Hydrators(u8) were introduced in visibility-filtering/hydration/mod.rs (+360/−77). Each predicate declares its requirements with const fn hydrators(), and RuleClause::hydrators() takes their union. RuleEngine::hydrators_for(safety_level) runs only the hydrators that the active policy actually references. A large amount of TES hydration code was deleted (tes_hydrator.rs -333, models/mod.rs -223, safety_label_source/lookup.rs -89). This is a consequence of TweetForVisibility having been migrated to the Strato column the day before. The PureCoreHydration wrapper and the cache-recovery logic were removed, and renaming fetch_pure_core → fetch_author_ids simplified it to TweetHydrationBatch<AuthorId>. A key-frame pipeline was introduced for grox video safety. KeyFramesExtractor in the new grox/libs/video_tools/key_frames.py (+206) detects scene cuts with PyAV and OpenCV. A frame counts as a scene cut when the L2 delta of a 32×32 grayscale signature exceeds max(6.0, 4.0×median_delta). It then decodes the top 3 midframes per shot by salience rank, frame-exact by PTS (3-second window, 15 fps; blank shots with stddev<8.0 rank lower). The DAG in the new grox/flows/ptos/plan_safety_ptos_key_frames.py (+52) starts with rate limit → key frames filter → media hydration → adult-only init. It continues with policy detection → safemodel sex/nudity → adult content cross-validation → sink. The gating condition of the filter task_safety_ptos_key_frames_filter.py (+81) is: video required, fav ≥ HIGH_FAV_THRESHOLD=128, and (an existing adult label OR fav ≥ KEY_FRAMES_UNCONDITIONAL_FAV=512) (grox/flows/ptos/constants.py:25, 43). The rate limit is a 60s TTL with a 10k dedupe. Media storage switched from blobstore to the O2 S3 protocol. The new S3Store (boto3, https://o2-https.o2-prod.atla-prod-storage.cluster.kube.int-x.ai) in grox/core/data_loaders/media_reference_bundle.py replaced it by parsing s3://<bucket>/<prefix> URIs. MediaHydrationConfig gained use_key_frames (default False), key_frames_window_seconds=3.0, key_frames_max_fps=15.0, and key_frames_max=3. The MOE cold-start experiment was migrated. The default placements of PhoenixMOEMaxResults and PhoenixMoeColdStartMaxResults were swapped, and the PhoenixRetrievalMOEInferenceClusterId cluster constant was replaced.
Gen4+28 same repo @ 44d37eb 2026-09-24 Rust/Python The entire ranking scorer file in home-mixer was deleted, and the shared value model crate that was called inline the day before was released as actual files. 22 ranking computation parameters left home-mixer param.rs and remain only in the vm-ranker/params.rs mirror, and if the vm-ranker call fails, home-mixer serves the local weighted sum as is daily. +2,569/−2,257 (62 files). The entire ranking_scorer.rs file (1,255 lines) in home-mixer was deleted. The shared crate that was the inline call target the day before (1b3fec20) was released this time as 6 actual files (807 lines in total). The new files are xai-value-model/lib.rs (+14), inputs.rs (+35), phoenix_scores.rs (+28), proto.rs (+92), scoring.rs (+444), and weights.rs (+194). The exposed symbols are ValueModelWeights (34 fields), CandidateScoringInputs, QueryScoringContext, PhoenixScores (26 fields), compute_value_scores, compute_value_scores_with_adjustment, fuse_heads, compute_weighted_score, offset_score, post_fusion_multipliers, apply_cold_start_decisions, diversity_multiplier, author_pool_counts, PostFusionMultiplier, ValueScores, and NEGATIVE_SCORES_OFFSET=0.001. The only replacement adapter on the home-mixer side is the new home-mixer/scorers/value_model.rs (+114). weights_for(query) fills the 34 fields of ValueModelWeights with 29 FS parameters and 5 fixed values, and weighted_score() calls fuse_heads of the shared crate as is. 22 ranking computation parameters were deleted from home-mixer param.rs. The deleted parameters are EnableAuthorDiversity, AuthorDiversityDecay, AuthorDiversityFloor, OonWeightFactor, MultiplierPreOffset, EnableOonRescoreForInNetworkRepliesRetweets, TopicOonWeightFactor, NewUserAgeThresholdSecs, NewUserOonWeightFactor, EnableMultiplicativePostUnexplored, MultiplicativePostUnexploredAlpha, PostUnexploredWeightInNetworkOnly, EnableCdwellOnImpr, ContActiveSecs5mResidualNormWeight, WeightPerturbationSigma, WeightPerturbationSalt, EnableVMRanker, VMRankerDppTheta, VMRankerDppMaxSelectedRank, VMRankerSendValueModelInputs, VMRankerComputeValueModel, and CachedPostsReuseWeightedScore. The only mirror of these values is now vm-ranker/params.rs (56 parameters, introduced the day before). The param.rs sync time changed as 2026-09-22T17:39:32Z → 2026-09-23T16:28:43Z. home-mixer/scorers/vm_ranker.rs was restructured as local-first, RPC-refine (+84/−76). The new function local_scores() first computes the per-candidate local weighted sum and the cold-start decisions of AuthorColdStart::apply_with_decisions(), and then requests a refinement from the vm-ranker gRPC. If the RPC fails, the local weighted sum is served as is. Previously, Err was propagated per candidate. The EnableVMRanker gate was removed and migrated to EnableRanking. The HOME_HOT = 14 dataset was introduced in Phoenix retrieval. In phoenix/xrex/data/retrieval_dataset.py (+103/−8), HOME_HOT is registered next to HOME_COLD = 13. Both come from the same 1fav_1day.parquet source. What separates them is cold_pool_mask(post_ids, meta_path, max_favs=8, max_views=500) × fresh_post_mask(post_ids, max_age_seconds) in the new phoenix/xrex/data/cold_pool_filter.py (+78). HOME_COLD is cold_keep, and HOME_HOT is its complement. Phoenix bundle export was extended to carry per-target index metadata. The RetrievalExport dataclass in phoenix/xrex/train/recsys_bundle_export.py (+170/−46) gained 4 fields: dataset_capacities, rows_from, max_age_seconds, and optional_targets. The same file also gained the constants POST_SCALES_KEY and DATASET_RANGES_KEY and gating for enable_int8_post_table, enable_bloom_filter, and enable_topic_filter. VF merges the verdicts of a retweet and its source. Verdict::merge_retweet_verdict(retweet, source) was added in visibility-filtering/models/verdict.rs (+60). The source's Withheld takes precedence, and the retweet's Withheld is kept when it wins over a non-Withheld source. If the retweet is fully unrestricted it inherits the source; in all other cases the retweet is kept. The actual flow is in visibility-filtering/evaluate_tweets.rs (+157/−18). After the first evaluation, it collects the source_tweet_id of the evaluated retweets and splits them into in_batch and fetched. For fetched, it calls filter_tweets.run() a second time. It then looks up the source verdict and applies merge_retweet_verdict. The new metric is evaluate_tweets_retweet_sources{outcome=in_batch|fetched}. As supporting changes, pure_cores: TweetHydrationBatch<PureCore> and a retry-oriented PureCoreFallbackCache were added to visibility-filtering/hydration/mod.rs (+244/−18). VF's clock_cache.rs (412 lines) was deleted. The primitive that was introduced as "unwired" on 2026-09-10 (Gen4+19) was removed after 14 days. grox PTOS gained a new category, AgeGatingHarmfulText. It has 5 subtypes: AdultContent, EatingDisorder, HarmfulSubstance, DangerousStunt, and GraphicDescription. It was added to the USE_GEMMA_CATEGORIES set in grox/flows/ptos/state.py (+6) and classifier.py (+5). As of this commit, the only other categories in this set are Spam and IllegalAndRegulatedBehaviors. The prompt helper is age_gating_harmful_text_policy_prompt(), and the template .j2 body is not in this commit's tree. GROK_GEMMA_FOLLOWER_SPLIT for grox reply spam rose from 225k to 250k. This is the eleventh increase since it started at 15k in the Gen4 release. Attribution of SimClusters retrieval contribution was added to home-mixer scored_stats. The Phoenix.RetrievalContribution.SimclustersTopK family was introduced with the buckets [50, 100, 200, 400, 600, 800]. Phoenix.RetrievalContribution.ScoredTopK observes the top-k source distribution over all selected and non-selected candidates.
Gen4+29 same repo @ bf7db1b 2026-09-25 Rust/Python VF hydration was rewritten around a data table and a scheduler, and the viewer's country is now looked up from About This Account (subsection, daily).
Gen4+30 same repo @ 4c5cfe8 2026-09-26 Rust/Python An overturn-hold gate was introduced in abuse-enforcement, and Phoenix Cold channel candidates were shown only to the cold-start Treatment arm (subsection, daily).
Gen4+31 same repo @ a707cc2 2026-09-29 Rust/Python The overturn-hold ledger server was released, the click weight dropped to 0.3, and the post-click dwell weight rose to 0.4 (subsection, daily).
Gen4+32 same repo @ 77d431a 2026-09-30 Rust/Python VF hydration changed to a structure that remembers per-key request state, and 6 home-mixer cold-start parameters changed substantially (subsection, daily).
Gen4+33 same repo @ b79b947 2026-10-01 Rust/Python The VF verdict response now carries the list of actions to block and the wording for each restriction reason, and multiple restriction reasons can now accumulate (subsection, daily).
Gen4+34 same repo @ 76843a5 2026-10-02 Rust/Java/Python The microservice client, training code, and an off-by-default candidate source for generative SID retrieval were released together (subsection, daily).
Gen4+35 same repo @ b412112 2026-10-03 Rust/Python The SID retrieval server crate and an off-by-default popular posts source were introduced, and the ads V2 verdict switch was turned on again (subsection, daily).
Gen4+36 same repo @ e62790c 2026-10-06 Rust/Python Three zero-weight dwell-seconds terms were added to the value model, and the popular posts source now reads an offline global list (subsection, daily).
Gen4+37 same repo @ 78460ca 2026-10-07 Rust/Python The popular posts source became on by default, and the new-author boost in the home-mixer mirror dropped to 0.015 (subsection, daily).
Gen4+38 same repo @ 35650fb 2026-10-08 Rust/Python The For You vertical video carousel was introduced as off by default, and the dwell time head is now trained with the CREAD loss (subsection, daily).
Gen4+39 same repo @ cb45b55 2026-10-09 Rust/Python A feed request context field was added to the Phoenix ranking request (nothing reads it), and the H100 home ranker training preset changed (subsection, daily).
Gen4+40 same repo @ e1a0375 2026-10-13 Rust/Python In the home-mixer mirror, the favorite and cont_dwell_time weights rose and video_open dropped to 0.02, and retrieval gained a dwell head (subsection, daily).
Gen4+41 same repo @ 17c96bf 2026-10-14 Rust/Python Training code for a page RL re-ranker for the immersive surface was released (serving off), and favorite and not_interested in the home-mixer mirror became 1.1× (subsection, daily).
Gen4+42 same repo @ 92f0918 2026-10-15 Rust/Python A VQV prediction term (weight 0.0) was added to the video carousel, and the ads author brand-safety fallback became on by default (subsection, daily).
Gen4+43 same repo @ bc3948b 2026-10-16 Rust/Python Popular posts candidates now carry their own label, and VF counts a verdict as complete even if an auxiliary lookup fails (subsection, daily).
Gen4+44 same repo @ 862a123 2026-10-17 Rust/Python Three video carousel dedup checks became on by default, and the carousel is no longer inserted when Who-to-Follow is present (subsection, daily).
Gen4+45 same repo @ 74d49a4 2026-10-20 Rust/Python The carousel now excludes the same media as the main feed, and topic novelty measurement was introduced as off by default (daily).

Summary by release

After the weight figures were released in the Gen4 release (2026-08-13), later commits revised ads brand-safety verdicts, Grok-based policy verdicts, Phoenix training and serving, and visibility-filtering several times.

  • On 2026-08-13, the Gen4 release published the weight figures in the production cron mirror file. (daily)
    • The published file is home-mixer/params/param.rs.
    • Example values are reply 5.0, favorite 0.5, retweet 1.0, share via copy link 20.0, and report −234.
    • Original posts from mutual follows get a reply +15 boost.
  • The 2026-08-14 commit (Gen4+1) added a code comment that corrects a misreading of the weights and a Brazil 2026 election filter. Cold-start Thompson sampling, 14-day stale post gating, and a partial release of the BDSM enforcement note generator came with it. (c65aa17, daily)
    • The code comment explicitly warns “do not read ratio as a raw-count equivalent”.
    • The Brazil 2026 election filter hardcoded 665 accounts and has a following exception.
  • The 2026-08-17 commit (Gen4+2) added ads brand-safety verdict V2 v1 and a cross-validation layer for adult content verdicts. The indexing gate in Phoenix retrieval was removed. (b089ce6, daily)
  • The 2026-08-18 commit (Gen4+3) redesigned the ads safety V2 helper after one day. A Following-only VF hydrator, the SID connection in VMRanker, Phoenix Kafka v3, and the grox reply_spam unification also came with it. (11a71f8, daily)
  • The 2026-08-19 commit (Gen4+4) changed the default Phoenix training optimizer from Adam to Muon and expanded Phoenix to search ranking. It also included Following pipeline hardening, a Grok 4.6 dial, and VF shadow mirroring. (aad7179, daily)
    1. Phoenix training switched from Adam to the Muon optimizer.
      • The default dense-parameter optimizer changed to optax-based Muon (Newton-Schulz orthogonalization).
      • It is a hybrid in which embeddings, norms, and logits still use Adam.
      • The new base learning rate is 7.1e-4; the previous value was 1e-3.
    2. Phoenix expanded to search ranking.
      • A new config xrecsys_search appeared and uses a separate vocab (100M user/item, 30M author).
      • history_seq_len is 1022, and it is set with use_product_surface=True and condition_search_relevance_on_prompt=True.
    3. A RewardOutputs message (values, policyLogits) and a rewardRerankSlotProb field were introduced in the Phoenix proto.
      • These two are scaffolding for reward-based re-ranking.
      • A 5-quantile head for link-open probability (P10/P25/P50/P75/P90) was also added.
    4. A Following-side FollowingViewerMutedKeywordFilter and a QuotedPostTextHydrator were newly introduced, continuing the Following pipeline hardening.
      • The two new pieces of code are 65 lines and 106 lines, respectively.
    5. A Grok 4.6 internal 10% dial was introduced in grox PTOS (_GROK_4_6_INTERNAL_DIAL = 0.1).
      • A new prior_nsfw.py was also introduced. Posts already flagged NSFW by a Manhattan lookup skip reclassification.
    6. dark_traffic_setup.rs (109 new lines) was added to visibility-filtering.
      • Gated by env, only specific ordinal pods mirror requests to the xai-vf-shadow.prod.visibility shadow deployment.
    7. Four features were promoted to train-default ON in the default Phoenix training config _home_direct_packed_base().
      • The four features are enable_stale_post=True, enable_is_author_followed_by_viewer=True, enable_is_author_following_viewer=True, and enable_engagement_counts=True.
      • The three bool features whose values were populated in the 2026-08-14 commit (Gen4+1) became default training inputs starting with this commit.
  • In the 2026-08-20 commit (Gen4+5), a hydrator that attaches AI trend labels to For You candidates was added, and the slate context produced by serving began to be connected to ranking. (d0cef2f, daily)
    1. An AI trend feedback context hydrator was introduced (157 lines).
      • It attaches an "AI trend" label to For You candidates as a second context channel alongside topic feedback.
      • It picks the top-2 trends by candidate frequency and randomly picks one candidate per trend.
      • The landing URL is https://x.com/i/trending/{trend_id}.
      • The target condition is standalone original post, non-topic, non-in-network.
    2. The wiring of served_slate_context began.
      • The actual pipeline started consuming the proto prepared in the 2026-08-19 commit (Gen4+4).
      • PhoenixScorer extracts candidate_slate_context() from the prediction response and sets it.
      • Depending on the UseServedSlateContext gate, RankingScorer uses the slate context produced by serving as is, instead of local computation and cache. There are three fallback paths.
      • This slate context holds pool_rank, fatigue, pre_diversity, three SID layers, and three SID gap layers.
    3. emb_table.rs was refactored (+189/−169).
      • It split load_tensor into a pyo3-free core (load_tensor_into) and a Python wrapper.
      • It replaced the tokio runtime unwrap() with map_err.
      • It added a checkpoint path too short guard.
    4. The Grok 4.6 internal dial rose from 0.1 to 0.3, a 3× increase in one day.
    5. grok_sampler gained a stop_predicate callback and aclosing.
      • Streaming early termination and the llm.sample.early_stop.count counter were added together.
    6. A profile_image field was introduced in the grox User schema.
  • The 2026-08-21 commit (Gen4+6) introduced 20 ads conversion actions and a training-loss branch for delayed feedback. The Muon optimizer was formally included in the export, and Grok 4.5 internal was removed entirely. (28e414f, daily)
    1. 20 ads conversion actions were introduced at proto ActionName 171–190.
      • 6 MACT in-app event types × 2 click/view families = 12. The events are purchase, add_to_cart, level_achieved, tutorial_complete, sign_up, and custom.
      • On top of these come 8 view-through conversions: purchase/add_to_cart/checkout_initiated/sign_up/site_visit/session/landing_page_view/upper_funnel.
    2. split_head_training_by_source branches delayed feedback out of the training loss.
      • When the is_delayed_feedback column is passed through kafka, it arrives as the sample_source batch field.
      • The loss mask then splits so that the engagement head trains only on fresh samples and the conversion head trains only on delayed samples.
      • The fresh/delayed_clicked/delayed_non_clicked metric slices were introduced.
      • mact_in_app_loss_weight, train_view_through_heads, and conversion_keep_mask were exposed as tuning knobs.
      • The new metric group ads_slim consists of 3 positives, 4 negatives, and non-primary conversion click.
      • CLIENT_TWEET_SEARCH_QUERY_REFORMULATED was added to negative feedback.
    3. The Muon optimizer was formally included in the export.
      • TRAINING.md and the README were rewritten to say "flagship ranking config + home_direct_packed_nano trained end-to-end with the production Muon recipe".
      • Embedding rowwise AdaGrad includes three options: accumulator half-life decay, lazy per-row decay, and decoupled weight decay.
    4. The nano config inherited the LR and optimizer settings of the NVIDIA GB300 hardware override that has existed since the Gen4 release.
      • _GB300_OVERRIDES defines bs_per_device=512, ep=64, and attn_impl="cutedsl_ranker_varlen_attn".
      • remat_policy=RematType.SAVE_GB300_RECSYS, unroll_layer_stack=True, and LR 5e-4 are also defined in the same override.
      • home_direct_packed_nano inherits this override.
    5. The interface of the xai-o2 storage crate was released.
      • The BaseO2Client trait of an S3-compatible object storage client and 15 builder methods were exposed.
      • However, build() always returns the error "the xai-o2 backend is not included in this build". The implementation is not public.
    6. SlateContext was added as field #9 of NextActionDistribution (a slate context per action distribution).
    7. The grox reply_spam follower threshold rose from 60k → 80k.
      • Four step-ups followed over 10 days, and this one is a modest 1.33×.
    8. The visibility-filtering dark_traffic target changed from shadow to staging.
      • xai-vf-shadow.prod.visibility was renamed to xai-vf-service.staging.visibility:grpc, and the TLS domain also moved to .staging..
      • ⚠ This change reinterprets the shadow description in the 2026-08-19 commit (Gen4+4).
    9. Grok 4.5 internal was removed completely.
      • The 4.6 dial started in the 2026-08-19 commit (Gen4+4) went 0.1→0.3→100% and finished within two days. It targeted the child safety judge and the deluxe reclassification, not the adult content judge of Gen4+2.
      • _eapi_4_5_internal_breaker, _sample_4_5_internal, and _GROK_4_6_INTERNAL_DIAL were all deleted.
    10. Added 65k-row chunking to retrieval post-embedding.
    11. Added a --hotswap_malloc_trim option to hotswap, and documented a memory leak of ~196MiB per cycle.
  • In the 2026-08-24 commit (Gen4+7), a staging-only harness appeared that compares VF verdicts against a reference VF on every request, and ads brand safety verdicts grew to four levels. Grok 4.5 re-entered as a judge that only allows demotion in three PTOS categories. (d011592, daily)
    1. The VF reference comparison harness was added as a new file, visibility-filtering/reference_compare.rs (812 lines).
      • On every request, this harness calls StratoVfClient, the Strato-based reference VF, in parallel and strictly compares the result with the verdict the service produced.
      • The two count as equal only if action and reason match exactly (is_exact_match).
      • When they differ, it logs a JSON line. The line budget is 12KiB.
      • The metrics are vf_reference_compared/exact_match/differed/error/skipped/enabled.
      • It is gated by the environment variables VF_DUAL_CALL_HARNESS_ENABLED and APP_ENV. With APP_ENV=prod it panics at boot, which enforces staging-only use.
      • This is a new canary that compares actual verdicts, separate from dark_traffic (which only mirrors traffic).
    2. The brand safety verdict expanded to four levels.
      • BrandSafetyVerdict::HighRisk = 4 was introduced.
      • Five labels that were in medium were promoted to HighRisk: NSFW_HIGH_PRECISION, NSFW_HIGH_RECALL, GORE_AND_VIOLENCE_HIGH_PRECISION, PDNA, EGREGIOUS_NSFW.
      • The new SOFT_NSFW is also HighRisk.
      • V2 moved symmetrically.
      • The ads utility has_avoid() treats both Medium and High as avoid.
      • The NSFW ancestor promotion in ads_brand_safety_vf_hydrator.rs changed from Medium to High.
      • scored_posts_server.rs introduced proto HM enum mappings for five V2 labels and exposes them downstream. The labels are GROK_SFA_V2, GROK_NSFA_LIMITED_V2, GROK_NSFA_V2, GROK_NSFA_EXPANDED_V2, and PTOS_REVIEWED.
    3. The PTOS policy verdict cross-validator expanded to three categories.
      • After Grok 4.5 internal was removed in the 2026-08-21 commit (Gen4+6), the new judge class uses EAPI_GROK_4_5_X_ALGO, which the adult content judge has used since Gen4+2. It is judge-only, not primary.
      • The new class SafetyPtosPolicyCrossValidator rechecks the primary verdict with 4.5 in three categories, ChildSafety, ViolentMedia, and IllegalAndRegulatedBehaviors, and allows only demotion.
      • When there is a disagreement, the verdict changes to NoViolation or a lighter policy.
      • It exists alongside the adult content judge (SafetyPtosAdultContentCrossValidationJudge) from the 2026-08-17 commit (Gen4+2).
      • USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm} and USE_THREAD_RENDERER_CATEGORIES were introduced.
      • Only in these two categories does it lift the thinking constraint and feed the parent thread to the renderer (harm cannot be judged without context).
    4. The Phoenix checkpoint loader was hardened for multi-node production.
      • The following arguments were introduced in xai_checkpointing/load.py:
        • domains: per-tensor ts.IndexDomain slices
        • tag: checkpoint subdirectory, default "orbax-ckpt"
        • concurrent_gb: byte throttle for concurrent decoding
      • The XAI_RESTORE_NODE_SERIALIZE environment variable (on by default) serializes restore on each node with fcntl.flock(). The path is /dev/shm/xai_restore_node_lock.
      • It calls gc.collect() and malloc_trim(0) between batches.
      • This responds to the problem that, after the Muon generation, Phoenix models grew and concurrent restores by multiple workers caused host OOM.
    5. A regional cache fan-out was added to the reply_spam score writeback.
      • ReplyRankingScoreStratoLoader in grox/flows/reply_spam/strato_loader.py has put scores to Strato and emitted them to Kafka v2 since the Gen4 release (47c1bcd, 32 lines). The Kafka emit is for downstream real-time pipelines.
      • This commit (+17/−0) added a step after put that also writes in parallel to the regional caches (Atla, Pdxa) when score ≤ 1.0 (d011592).
    6. The reply_spam follower threshold was raised from 80k to 100k.
      • Both TaskSpamFilter and TaskReplyRankingFilter were raised.
      • It was raised five times in 11 days (15k → 30k → 40k → 60k → 80k → 100k), so the range of accounts subject to spam verdicts keeps widening.
    7. X Space (audio) rendering was added to grox.
      • The SpaceMetadata class was introduced. At the time of this commit it has only title.
      • A space_metadata field was added to Post, and post_mapper.py maps this value from Strato.
      • The lm/post.py renderer puts it into the LLM input as an audio space card.
      • Grok safety verdicts began to recognize posts with an attached audio space.
    8. ads_injection_logging_side_effect.rs exposes the bucket of the EnableAdsBrandSafetyVerdictV2 experiment in logging as ddg_experiment_bucket.
      • DDG (the ads reporting pipeline) began tracking the V2 experiment group.
  • In the 2026-08-25 commit (Gen4+8), abuse-enforcement-service began executing actions requested by the scorer after double verification, and the VM Ranker path was greatly simplified. Following also blocks block evasion via quotes and retweets. (0d3cdd8, +5,850/−586, 47 files, daily)
    1. A generic_actions passthrough was added to abuse-enforcement-service.
      • Through service-lib/src/generic_actions.rs (663 new lines), growthbook.rs (+126), and lib.rs (+276), actions requested directly by the scorer are executed after double verification against two allowlists.
        • One is a hardcoded kind allowlist (5 user kinds, 2 post kinds, MAX 16 per msg).
        • The other is a GrowthBook dynamic allowlist (kinds, suspend_policies, labels).
      • Rejected requests are explicitly observable through the outcome.info["requested_actions_skipped"] JSON and a metric.
      • The new rule act_requested_actions and the skip rule platform_row_without_requested_actions are linked as a pair.
      • The first client is panda_reports_embedding_v10_rough_spam (embedding-based spam detector v10), and the action is a temporary PlatformManipulation suspend.
    2. The VM Ranker path was greatly simplified.
      • All of the following were deleted:
        • AUTHOR_DIVERSITY_VALUE_MODEL_ID
        • MPN fallback logic (128 lines)
        • AuthorColdStart post-processing
        • ranking_scorer::effective_head_weights (88 lines)
      • The only new constant is DPP_VALUE_MODEL_ID = "dpp".
      • VMRanker passes through the scores returned by the rank server as is, and DPP diversity is handled by the server's own logic.
      • This is an architecture refactor paired with the slate context migration in the 2026-08-20 commit (Gen4+5). home-mixer is kept thin and the gRPC servers handle computation.
    3. The block relationships in the Following pipeline were extended.
      • following_blocked_by_hydrator.rs (65 new lines) looks up check_blocked_by in batch from SocialGraphClient for the quoted/retweeted author list of each candidate.
      • author_socialgraph_filter.rs filters in six ways: viewer→author mute/block, author→viewer block, quoted author↔viewer block, and retweeted author→viewer block.
      • It respects block relationships down to the original author in a quote/retweet chain. In other words, it explicitly blocks block evasion via quote/retweet.
      • It is wired into reverse_chron_posts_pipeline.rs (+27/−2).
    4. The account list of Brazil2026ElectionFilter was updated to the TSE dadosabertos 2026-08-25 edition (+3,379 lines, 4,947 lines in the whole file).
      • The README also states this explicitly: "(Account list updated August 25, 2026.)".
      • user_id stays obfuscated and @username is exposed as is (a transparency/privacy compromise).
    5. A prefloor snapshot was wired into phoenix-rankall.
      • store/base.rs (+202/−61) and config/mod.rs (+51/−12) changed.
      • For windows that have min_age, it loads prefloor_<window>.parquet concurrently. PREFLOOR_SLACK_SECS = 6.0 * 3600.0.
      • New candidates are held in a separate pool until they pass min_age, and can be reflected in production as soon as they pass.
    6. The Phoenix copy_port rate limit was hardened.
      • copy_port_client.rs (+155) changed join_all(futures) to join_all(futures.map(tokio::task::spawn)) so that the in-flight cap is enforced accurately even without sequential polling.
      • Two regression tests were added.
    7. The README's Latest Updates was renamed to Notable Updates.
      • It is a declaration that the daily follow-up commits will not be reflected in the README every day inferred.
    8. There were also the following changes.
      • There were minor extensions in the URT marshalling layer (ad_marshaller +18, client_event +27, wtf_marshaller +14, reverse_chron_following +12).
      • grox upa/constants.py and generators.py were newly wired (contents not confirmed).
      • engagement_counts_hydrator had a +16 change.
  • In the 2026-09-01 commit (Gen4+12), the 16 countries targeted by NSFW gating were stated explicitly in code for the first time, and a new special video verdict path appeared. This path is the first case of X publishing in code a layer that responds to active evasion. (6384ca7, +2,247/−735, 70 files, daily)
    1. The 16 countries targeted by NSFW gating were stated explicitly in code for the first time.
      • visibility-filtering/params.rs (216 new lines) holds ar au br ca de es fr gb id it kr mx nl ph pt th as the default.
      • The feature switch rust_vf_nsfw_gating_countries refreshes it every 30 seconds.
      • On every refresh it compares against the sibling YAML country_specific_nsfw_content_gating.yml (Scala VF stack) for drift and observes it with the nsfw_gating_countries_drift counter.
      • If an invalid element is mixed in, the whole list falls back to the default rather than a partial list.
      • SensitiveViewerNoStatedAgeDropRule references this country list and enforces age-gating on viewers with unverified age only in the 16 countries.
    2. The 8-stage PlanSafetyPtosSpecialVideo DAG was introduced.
      • New or changed files include the following:
        • grox/flows/ptos/plan_safety_ptos_special_video.py (55 new lines)
        • task_safety_ptos_special_video_filter.py (40 new lines)
        • task_special_video_screen.py (49 new lines)
        • grox/libs/video_tools/image.py(+125)
      • Only videos whose fav count is at least deluxe_fav_count_threshold enter (cost control).
      • If motion_reveal_frames exist, the video is screened.
      • Motion-reveal frames subtract the static layer and amplify the faint motion layer. They expose evasive content hidden by static overlays (captions, watermarks, transparent masks).
      • The Grok 4.5 judge (EAPI_GROK_4_5_X_ALGO) receives the original and the motion-reveal frames together.
      • The prompt even contains a rubric saying "clothes may appear removed, so compare against the original" (the MOTION_REVEAL_DESCRIPTION constant).
      • The image preprocessing pipeline consists of CLAHE, adaptive brightness, saturation boost, and sharpen.
      • Only on the special video path, if the judge fails, it demotes to Soft with fail-close. On the other paths, keeping the original verdict is the default.
    3. home-mixer/filters/brazil_2026_election_filter.rs (+222/−74) continued to be maintained.
    4. There was a Phoenix serving/config refactor.
      • It was done in phoenix/crates/common/xai-recsys/src/model_config.rs (+96/−2) and mm_embedding_client.rs (+128/−55).
    5. phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py and encrypted_kvstore.py were newly added (49 lines + 49 lines).
      • It is an encrypted checkpoint kvstore, and no direct relation to feed ranking was confirmed.
  • In the 2026-09-01 commit (Gen4+13), nine visibility-filtering rule files were restructured into three. The same commit first published the connection of RQ-VAE-family SIDs to the ranker input and a StableHLO bundle export, but both are off by default. (7ba7768, +2,964/−2,861, 34 files, daily)
    1. The visibility-filtering rules files went from 9 to 3, and the line count from 2,670 to 1,568.
      • What was a struct + impl Rule per rule became a shared enum RuleSpec (three variants Tweet/Author/Custom, one-line literals).
      • The deleted rule files are nsfw_age_gating.rs, nsfw_interstitial.rs, nullcast_rule.rs, socialgraph_rules.rs, and tes_rules.rs.
      • tweet_flag_rules.rs, tweet_label_drops.rs, user_label_drops.rs, and user_rules.rs were also deleted.
      • The new rule files are tweet_rules.rs (1125), author_rules.rs (354), and rule_spec.rs (89).
      • The rule list and order are unchanged. The regression test wired_rule_order_matches_pre_migration_sequence verifies the names of 28 home rules and 26 additional recs rules as literals, which locks in that this is a pure refactor.
      • Arc<NsfwGatingCountries> moved from the rule struct fields to a RuleContext field, so rules became zero-sized data literals.
      • Incidentally, tes_rules.rs was split into home-common rules and recs-only rules. Home-common covers stale/takedown, and recs-only covers DMCA/geo-restricted media.
      • The split made the recommendations-only policy axis explicit.
    2. A SID recon mode was introduced in the Phoenix ranking model.
      • phoenix/xrex/models/recsys_sid.py (72 new lines) loads two things from an offline .safetensors artifact:
        • an RQ-VAE-style stacked residual codebook (stages, shape (num_levels, codebook_size, code_dim))
        • 2-layer MLP decoder(dec_w0/b0/dec_w1/b1)
      • After restoring a dense vector from the SID codes, it injects it into the ranker post tower through L2 normalization, stop_gradient, and a trainable project.
      • sid_embedding_mode: Literal["learned", "recon"] = "learned" and sid_decoder_path: str = "" were added to RecsysAggregatedModelConfig.
      • The default remains learned. That is, it is wired-but-off.
      • X published in code for the first time the connection that attaches RQ-VAE-family SIDs to the ranker input (ranker input, not a retrieval model).
    3. A StableHLO bundle export connection was introduced.
      • phoenix/xrex/train/recsys_bundle_export.py (634 new lines) lowers the ranking forward with jax.export and saves it as StableHLO MLIR bytecode and MANIFEST.json.
      • It builds a separate program for each batch-size bucket. The default buckets are "1,2,4".
      • Embedding lookup is outside the exported graph. The input is the pre-gathered merged_embeddings.
      • MANIFEST.embedding holds the shard, width, slice, and hash table geometry so that the serving side can pass the embedding server lookup results straight through.
      • The target is ("cuda",).
      • It is off by default with export_stablehlo_bundle: bool = False.
      • X published the StableHLO standardization of the JAX training → CUDA serving handoff for the first time.
    4. An envelope-encrypted Orbax checkpoint writer, orbax_encrypted.py (288 new lines), was added.
      • Every file must start with the b"XAIENC01" magic header.
      • assert_all_enveloped refuses the commit if plaintext remains after finalize.
      • It encrypts with a KMS-issued DEK and AES chunks. The DEK is placed in the file _DEK, and chunks default to 8 MiB.
      • Trying to save as plaintext after restoring from an encrypted checkpoint is explicitly rejected.
      • It is off by default with CheckpointConfig.encrypt=False.
    5. repeated int32 sid_codes = 8 was added to DpaProductInfo in the DPA proto.
      • Ads DPA product SIDs are carried in the serving wire format. This is a separate pipeline from organic post SIDs.
      • In the action enum, 39 slots were reserved as a wall with reserved 214 to 252.
    6. There were also the following changes.
      • mm_embedding_client was refactored to zero-copy.
      • The search_query embedding was optimized so that it is written repeatedly only for non-padding real candidates instead of being duplicated per candidate.
      • Migrated grox mm-emb kafka to MultiRegionKafkaProducer.
  • In the 2026-09-02 commit (Gen4+14), home-mixer began forcing requests for backbone scores to Phoenix on all five feed endpoints. (85ac72a, +1,081/−525, 34 files, daily)
    1. All five gRPC servers of home-mixer request backbone scores.
      • ScoredPosts, ForYouFeed, ForYouFeed polling, RankedFollowingFeed, and FollowingFeed hardcode query.return_backbone_scores = true just before handing the request to Phoenix.
      • The request field returnBackboneScores (#20) was introduced in the Phoenix proto.
      • As response fields, backboneTopLogProbs (#10) and backboneContinuousValues (#11) were introduced.
      • A PostCandidate.backbone_scores: Option<PhoenixScores> field was added, and PhoenixScorer parses candidate_backbone_scores().
      • It was confirmed in code for the first time that the Phoenix ranker has two layers: a shared backbone and reranker heads.
      • The rerankerHeadTag in the Gen4+11 commit exposed multiple heads, and this commit exposes the output of the backbone layer beneath them.
    2. A gRPC pipeline from scarecrow to XReview intake was introduced.
      • New files include the following:
        • scarecrow/legacy/XReviewIntakeRegistry.scala (179 new lines)
        • XReviewReportBuilder.scala (59 new lines)
        • XReviewReportSubmitter.scala (91 new lines)
      • XReviewIntake was registered in DownstreamServices.scala.
      • When the scarecrow rule DSL calls the CreateXReviewReportProd/Staging function units, a SubmitReportRequest is passed to XReview intake over mTLS gRPC.
      • This request is populated with report_source="proactive", report_surface="x_app", subject="Botmaker report", reporter_id="0", and detection_bot_id=<bot id>.
      • entityType is a "post"/"profile" whitelist.
      • An XReview allowlist report_type such as bystander_impersonation is the discriminator for lane tag routing.
      • detection_timestamp_ms goes into the uniqueness of the content-hash report id.
      • It was confirmed in code that the XReview queue is one of the destinations of automated detection bots, not only of user reports.
    3. Phase 2 of the VF rules restructuring progressed.
      • The Rule trait, the evaluate_rules function, and the Vec<Box<dyn Rule>> collection were all removed.
      • Three policies (FILTER_ALL_POLICY, TIMELINE_HOME_POLICY, TIMELINE_HOME_RECOMMENDATIONS_POLICY) are placed in rodata as &'static [&'static [RuleSpec]].
      • Policies no longer owns state and was renamed RuleEngine.
      • The composition of 9 home-common slices and 5 recs-only slices is unchanged.
      • A new test verifies that the names of the 15 rule slices (9 home-common, 5 recs-only, 1 FILTER_ALL) are unique.
    4. The checkpoint encryption approach changed.
      • The reader uses the xai_encrypted tensorstore driver directly instead of the xai_kms.KvServe KMS-proxy KvStore. It unwraps the DEK once and then decrypts symmetrically locally.
      • adopt_tree_dek uses the _DEK sidecar if there is one, and otherwise unwraps from the envelope header of the checkpoint file, so it is backward-compatible.
      • The envelope header is 4096 bytes, the magic is b"XAIENC01", and the derived form is xai-dek1:<salt>:<master>.
      • The writer requires two keys, domain and run, in encryption_context.
      • It automatically puts mint_nonce = sha256(path) into encryption_context (if the checkpoint path is moved, KMS unwrap fails).
      • _uses_tensorstore_save is no longer coupled with encrypt.
    5. The defaults of the two-tower training config changed to qk_norm=True and attn_logit_cap=-1.
      • They became the combined base defaults 12 days after the 2026-08-21 commit (Gen4+6) put the two values in the nano config.
    6. A zero-fill guard was added to batch preparation in Phoenix serving.
      • The candidate embedding and search-query embedding slices are zero-initialized before fill, and a copy_len > 0 condition was added.
      • This guards against leftover leakage from reused buffers and empty-slice panics.
    7. In the production config, the PhoenixRetrievalAggregationType default changed from DENSE_WITH_SHORT_DWELL to DENSE_WITH_LONG_DWELL.
      • The param.rs header sync time is 2026-09-02T16:03:45Z.

Gen4+4 (2026-08-19) — visibility-filtering shadow deployment (xai-vf-shadow) wiring

The 2026-08-19 commit (Gen4+4) attached request mirroring (dark traffic) to the VF service, so production requests can be copied to a shadow deployment verified. This created a base for shadow-verifying VF policy changes with production traffic, but which rule experiments run on top of it is not public. This commit was covered in the daily.

Request mirroring (dark traffic) infrastructure was attached to the VF service itself verified — visibility-filtering/dark_traffic_setup.rs (109 lines, new). This layer is connected in visibility-filtering/main.rs verified.

Mirroring is gated by three environment variables:

  • DARK_TRAFFIC_ENABLED (bool, default false) is the master switch.
  • ORDINAL_NUMBER is the index of this pod (StatefulSet ordinal, etc.).
  • DARK_TRAFFIC_MAX_ORDINAL (default 1) is the threshold that turns dark traffic on only when ord < max. In other words, by default only pod ordinal 0 mirrors, and max is raised only in specific deployments so that several pods mirror.

The mirroring target is xai-vf-shadow.prod.visibility:grpc, hardcoded through StaticShadowDiscovery. The per-datacenter domain override is visibility.visibility-filtering-service.prod.{DATACENTER}.s2s.twttr.net (default atla). mTLS is required (TlsMode::mtls_from_env).

⚠ In Gen4+6 (2026-08-21, 28e414f), the name of this target changed to xai-vf-service.staging.visibility:grpc. The TLS domain also moved to visibility.visibility-filtering-service.staging.{dc}.s2s.twttr.net, and StaticShadowDiscovery became StaticStagingDiscovery. In other words, dark_traffic was redefined within three days from "mirroring a production shadow workload" to "mirroring the staging environment". There are two interpretations.

  • One is that this is an infrastructure cleanup: staging is used as the shadow instead of maintaining a separate shadow workload.
  • The other is that a production-traffic shadow was judged to have value for verifying staging deployments.

Either way, VF experiments now run on the staging resource budget rather than on production shadow resources.

⚠ In Gen4+16 (2026-09-04, 902a06f), the mirroring targets expanded to two app_env values, staging and devel verified — visibility-filtering/dark_traffic_setup.rs.

  • The function name changed from parse_staging_listener to parse_mirror_listener, and it iterates over the two app_env values (STAGING_APP_ENV = "staging" and DEVEL_APP_ENV = "devel").
  • MirrorChannelFactory assigns a channel factory by choosing the devel TLS domain if the listener xds_dest contains .devel., and the staging TLS domain otherwise.
    • The devel TLS domain is visibility.xai-vf-service.devel.{dc}.s2s.twttr.net.
  • An example that passes is xai-vf-service-pr-123.devel.visibility:grpc. It suggests a practice of preview deployments based on GitHub PR numbers inferred.
  • The rejection cases are strict. Similar names such as development, staging-devel, and grpc-extra are explicitly rejected.

Which requests to mirror is decided by the rules in /config/dark-traffic/dark_traffic.yaml. ReloadableDarkTrafficConfigBuilder re-reads this dynamic config whenever the file changes.

Why it matters: This created a base for shadow-verifying VF service policy changes with production traffic. Examples of policy changes are the removal of the favoriteCount>=1 gate in Gen4+3, dark_traffic itself in Gen4+4, and future rule tuning. VF is fail-closed (the fail-closed item in the safety section), so rule changes carry high risk, and a shadow deployment is naturally required infrastructure for this pipeline. Which rule experiments actually run on top of it is not public. The actual dark_traffic.yaml is not in the repo.

Gen4+7 (2026-08-24) — Wiring the VF reference comparison harness (reference_compare.rs)

The 2026-08-24 commit (Gen4+7) added a harness in which staging VF also calls a Strato-backed reference VF on each request, strictly compares the two verdicts, and logs only the differences verified. Viewed together with the dark traffic introduced in Gen4+4 and redirected to staging in Gen4+6, this harness is infrastructure that measures, at production traffic scale, how closely the VF rewrite matches the original so far. This commit was covered in the daily.

While dark_traffic only mirrored traffic, this release added a canary that compares actual verdicts verified — visibility-filtering/reference_compare.rs (812 lines, new). The files changed alongside are visibility-filtering/filter_tweets.rs, visibility-filtering/server_deps.rs, and visibility-filtering/config.rs.

The harness does not change the response; a separate task compares against the verdict of the reference VF.

  • FilterTweetsEndpoint holds an Option<Arc<ReferenceCompareHarness>>. On each request it calls harness.begin_compare(viewer_id, country_code, safety_level, tweet_ids), spawns a separate tokio task, and sends a parallel request to the Strato-backed reference VF.
    • The reference VF is wrapped by StratoVfClient and is likely the Gen1 and Gen2 Scala VF stack inferred. The Gen3 public code is Rust/Python, so it has no Scala VF.
    • The main path sends its own verdict to the harness task through a oneshot::channel, but the response is not affected (read-only).
  • Once the harness task receives both results, it strictly compares the verdict for each tweet_id with compare_batch(). The criterion is is_exact_match(): the service action and reference action labels must be exactly equal, and FilteredReason must also match completely.
  • Differing verdicts are collected as Diff { tweet_id, service, reference }. After grouping them into tweet_id lists by identical (service, reference) string pairs, it emits JSON lines chunked to a 12KiB line budget to stdout with println!(), into the log stream.
    • The format of each line is {h: "vf_reference_compare", v: 1, batch, chunk, build, dc, level, viewer, diffs: [...]}.
  • There are six metrics. Every metric carries a build_sha tag, so regressions of a specific build can be tracked.
    • vf_reference_compared (comparisons completed), _exact_match (matches), _differed (mismatches)
    • _error (kind=timeout|reference_item|missing_result), _skipped (reason=level_unmapped|logged_out_viewer), _enabled (once at harness boot)

The harness restricts when it is enabled, where it runs, what it compares, and how long it waits.

  • The master switch is the VF_DUAL_CALL_HARNESS_ENABLED environment variable (bool).
  • It also checks APP_ENV. should_build_harness(flag, app_env) returns Err("VF_DUAL_CALL_HARNESS_ENABLED is set but APP_ENV=prod; the reference comparator is staging-only") for (true, Some("prod")). server_deps.rs escalates this Err into a panic!(), so boot fails. In other words, the harness is enforced as staging-only at the code level.
  • Only SafetyLevel::TimelineHome and TimelineHomeRecommendations are compared. FilterAll, which is not in the reference VF code either, is skipped. Logged-out viewers are also skipped.
  • The reference call timeout is 1.5 seconds. If it is exceeded, only an ERROR "timeout" metric is recorded and the task ends quietly.

How this harness differs from dark_traffic and shadow becomes clear when the three commits are placed side by side:

  • Gen4+4 (aad7179, 08-19): dark_traffic_setup.rs copies the production traffic requests themselves to xai-vf-shadow.prod.visibility. It only consumes traffic resources and does no verdict comparison. The dark side's response is not visible anyway.
  • Gen4+6 (28e414f, 08-21): The name of the dark_traffic target changed from shadow prod to staging. In other words, production traffic verifies the staging deployment as a shadow.
  • Gen4+7 (d011592, 08-24): reference_compare.rs runs in the staging deployment (the currently running process is staging). On each request it calls the reference VF (the Strato-backed earlier generation) in parallel, strictly compares the verdicts, and logs only the diffs. In other words, when traffic sent by the dark_traffic stack arrives at this staging service, the service computes its own verdict and at the same time checks it against the Strato reference.

Combining the two layers gives this chain: production traffic → dark_traffic → staging VF → comparison against the Strato reference in reference_compare → diff log. In other words, what this release really delivers is infrastructure that measures, at production traffic scale, how closely the VF rewrite matches the original so far. The VF rewrite was one of Gen4's headline sub-repos, and VF is item #1 on the canon caveat list (fail-closed, high rule-change risk). That verification channel is now complete.

Undisclosed:

  • Exactly which VF stack StratoVfClient wraps (whether a remnant of the Gen1 and Gen2 Scala VF or a separate stack)
  • By what rules dark_traffic.yaml selects requests
  • Whether diffs go out only as logs or a separate dashboard is attached
  • The production promotion threshold (for example, "promote if the diff rate is below X%")

Phoenix checkpoint stack save/restore double hardening — Gen4+7 (restore, 2026-08-24) + Gen4+9 (save, streamed restore, warm-start staging, 2026-08-26)

The 2026-08-24 commit (Gen4+7) capped the bytes read concurrently when restoring a checkpoint and serialized restores per node verified. We read it as a change to prevent the host OOMs that occurred when several workers restored a model enlarged since the Muon generation at the same time inferred. The 2026-08-26 commit (Gen4+9) added a byte budget and per-node serialization to the save side, plus streamed restore that feeds batches and warm-start staging verified. The restore side was covered in the Gen4+7 daily, the save side in the Gen4+9 daily.

Gen4+7 refactored the checkpoint loader to cap host memory use of concurrent restores verified — phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+178/−36). The log string in the code states the purpose of this throttle as bound host transient (line 212 of the same file). The account that models grew after the Muon generation and concurrent restores by multiple workers caused host OOMs is an interpretation not found in the code inferred.

Three new arguments were added to load_checkpoint.

  • domains: dict[str, Any] | None: specifies a ts.IndexDomain slice for each tensor. It is used to load only specific tensors partially, or to state the slice explicitly when the local shape differs from the checkpoint shape.
    • It supports two forms: a JSON spec (ts.IndexDomain(json=domain)) and an indexing expression (ts.IndexDomain(shape=host_state[name].shape)[domain]).
  • tag: str | None = None: the checkpoint subdirectory name, with a default of "orbax-ckpt". The actual path is built as path / tag.
    • That is, the practice of keeping several checkpoint tags (for example "orbax-ckpt", "post-warmup", "muon-recipe-v3") under one base path is reflected in the code.
  • concurrent_gb: float | None: the cap on tensor bytes decoded concurrently. It is converted with int(concurrent_gb * 10**9), and reads are then cut into batches (if cur_bytes + nbytes > concurrent_bytes: flush batch). If None, all reads are issued in parallel without throttling.
    • The log states ACTIVE or no throttle, and it warns when an individual tensor is larger than the throttle budget.

Restore is serialized at the node level verified.

  • It is toggled with the environment variable XAI_RESTORE_NODE_SERIALIZE. The default is "1", and "0"|""|"false" turns it off.
  • The lock file path is set by XAI_RESTORE_NODE_LOCK_FILE. The default is /dev/shm/xai_restore_node_lock, or /tmp/xai_restore_node_lock if /dev/shm does not exist.
  • The _NodeBatchLock class takes an exclusive lock with fcntl.flock(fd, LOCK_EX). It prevents multiple JAX workers on the same node from restoring at the same time. It forces a node to process only one batch at a time, and it logs if it waits one second or more.
  • Between batches, _release_batch_memory() returns glibc allocator slack with gc.collect() and ctypes.CDLL("libc.so.6").malloc_trim(0).

Why it matters: In Gen4+6, TRAINING.md was rewritten as "the flagship ranking config + home_direct_packed_nano train end-to-end with the production Muon recipe" and nano inherited the GB300 override; we infer that the OOM issue in the restore path was found during follow-up batch verification in that generation inferred. Before Gen4, models were small and this infrastructure was not needed. However, at Muon and GB300 scale, multi-worker restore exceeds node memory, so spreading restores over time with the node lock and limiting batch size with concurrent_gb became essential. It is a signal that Gen4's training stack has matured.

Undisclosed:

  • Which tensors are loaded as partial slices (domains), such as which optimizer state for Muon, or embedding table chunking
  • The production tuning value of concurrent_gb
  • How much the node lock lengthens restore time

Gen4+9 (2026-08-26, 45b48ba) unified valid_step gating in trainer_recsys verified — phoenix/xrex/train/trainer_recsys.py (+29/−19).

The param update order changed in two places: the dense sync path and the async_no_deferred path. Previously it proceeded in this order:

  1. Check the dense grad-norm to get valid_step.
  2. Gate the dense params update with valid_step.
  3. After segment_sum, check the emb grad-norm to get emb_valid_step.
  4. Gate only the emb table update with emb_valid_step.
  5. Compute valid_step & emb_valid_step in the metric.

That is, if dense passes and only emb fails, an asymmetric state lasting a few steps could arise in which dense params are updated to new values and only emb is rolled back.

In the changed order, the emb check comes before the dense update.

  1. Check the dense grad-norm to get valid_step.
  2. First, after segment_sum, check the emb grad-norm to get emb_valid_step.
  3. Compute keep_step = valid_step & emb_valid_step.
  4. Gate the dense params update with keep_step.
  5. Gate the emb update with sparse_update(..., valid_step=keep_step) as well.
  6. Unify the metric on keep_step too.

The async_no_deferred path was also aligned to AsyncEmbGradientUpdate.pending=keep_step (previously jnp.asarray(True)). Whether the async emb gradient is actually applied at the next step is decided by this step's gate result. Also, assert self.grad_norm_keep_threshold is None in _create_async_emb_executables was removed. Previously it assumed that the async deferred path was incompatible with grad-norm gating; now async also actually uses that threshold.

Why it matters: In the Muon and GB300 generation, dense and embedding grads were probably observed spiking at different scales inferred. Lock-step gating blocks the residual effects of that divergence at the source, and because dense and the embedding table move only in lock-step, reproducible training consistency is secured. The metrics also keep three separate observation fields: valid_step is dense only, emb_valid_step is emb only, and deferred_emb_grad_norm/valid_step is for the deferred family. These fields make it possible to diagnose at which layer the gate triggered.

Gen4+9 (2026-08-26, 45b48ba) hardened the save side symmetrically to the restore side, and added streamed restore and warm-start staging verified — phoenix/xrex/utils/checkpointing.py (+215/−6). The files changed alongside are phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+311/−88) and phoenix/xrex/train/trainer.py (+39/−8).

  • Save-side ThrottledD2HArrayHandler: the D2H budget can now be set with the save_checkpoint(..., save_concurrent_gb=<N>) argument.
    • It overrides Orbax ArrayHandler.serialize and groups tensors into batches by addressable_nbytes. For each batch it calls super().serialize(), waits for future.result(), and then moves on to the next batch.
    • Previously, Orbax's write-limiter only capped the save bottleneck at a 96GB default, while the D2H (device-to-host copy) itself staged everything, and host OOM occurred at this stage.
    • If compressed, it registers ThrottledD2HArrayHandler; otherwise it registers ThrottledNoCompressionArrayHandler (multiple inheritance with NoCompressionArrayHandler).
  • Save node-serialize: fully symmetric to the node-serialize pattern of the Gen4+7 restore.
    • It takes a flock exclusive lock with the XAI_SAVE_NODE_SERIALIZE=1 env and XAI_SAVE_NODE_LOCK_FILE (default /dev/shm/xai_save_node_lock).
    • It calls _release_batch_memory() between batches (gc.collect() and malloc_trim(0)).
  • load_checkpoint_streamed: a new function. The earlier load_checkpoint first built host_state (a pinned-host copy of all tensors). The streamed version leaves device_state as is and, for each batch, goes through pinned-host stage of only that batch's tensors → file read → move to device with device_put → free the batch's host memory.
    • The auto-window is min(max(max_leaf, total/16), 32GB) (window_gb=None by default), and window_gb can also be set explicitly.
    • It sorts with plan.sort(key=lambda item: item[3], reverse=True) so that large tensors are processed first.
    • When the old/new array pairs of a completed batch are passed to the on_replaced callback, the caller updates and releases the references.
    • copy_aliased_arrays separates aliased references beforehand.
  • Trainer.warm_start_staging_spec hook: in a warm start that restores only params from the checkpoint and re-initializes opt_state, it no longer makes an opt_state copy in pinned-host either.
    • Previously, when it did device_put with the staging sharding for restore, the entire state went up to pinned-host. Now it cuts out the opt_state fields in advance with warm_purge(self.state), stages to pinned-host, and then device_puts only the loaded part back to device. The original opt_state field is kept.
    • RecsysTrainer supports a keep_emb_opt_state=True option through an override (keeps only emb_table_state).
    • The save and restore stack atomically supports the standard flow in which flagship Muon training starts with pretrained params and a fresh optimizer.
  • admission.reset_estimates and request_metrics.reset_admission_estimates: reset the service_time and pipeline_time EWMAs of the Phoenix serving admission controller to zero. Prometheus gauges are aligned as well, and there are two regression tests. This is likely for resetting the EWMA immediately after a sudden change in traffic profile or a hot-swap inferred.
  • copy_port_client logging cleanup: the completion logs of the dense weights load and the emb_table load now include total bytes, elapsed time, and GB/s (with a divide-by-zero guard). The start log was removed, and the end log now serves as the summary.

Why it matters: After Gen4+6 formally included flagship Muon training in the export, there was probably an observation that when multiple workers save, checkpoint save causes host OOM in the D2H stage outside wait_until_finished inferred. The logic is symmetric to the restore side of Gen4+7. The warm start scenario points toward standardizing the flow in which the flagship starts with pretrained params and a fresh optimizer, so both the save stack and the restore stack were optimized for this usage pattern. The Gen4+7 → +9 flow is understood as "reorganizing the whole Muon+GB300 checkpoint round trip into a batched pipeline with an RSS cap".

Undisclosed:

  • The production tuning value of save_concurrent_gb
  • When load_checkpoint_streamed replaces the default load_checkpoint
  • The first config in which warm_start_staging is active
  • The actual call trigger of admission reset_estimates (whether hot-swap completion or traffic shift detection)
  • The target GB/s for copy_port

Gen4+8 (2026-08-25) — AES generic_actions passthrough wiring

Since the 2026-08-25 commit (Gen4+8), AES (abuse-enforcement-service) can pass through not only the static rules written in YAML but also actions requested directly by a scorer, through two layers of allowlists. Attaching a new scorer now needs a shorter YAML deployment cycle, but the actual allowlist values that decide which score maps to which action are outside the public source. This commit was covered in the daily.

In Gen4+8, abuse-enforcement-service/service-lib/src/generic_actions.rs (663 lines, new) was added. Previously, AES executed only rules statically encoded in enforcement_user.yaml / enforcement_post.yaml. For example, it suspends if "llm_slop_user" in score.labels && "SpamHighRecall" in user.labels. Now, when a scorer sends its own score.requested_actions list, AES can pass it through.

Requests are verified in two layers.

  • Hardcoded kind allowlist: contains 5 user kinds (suspend, label, bounce_captcha, bounce_arkose, spam_liveness_check) and 2 post kinds (post_label, suspend_author). The limit is 16 per message.
  • GrowthBook dynamic allowlist: verifies along three axes: kinds, suspend_policies, and labels.

Rejected requests are not discarded; they are recorded in the outcome.info["requested_actions_skipped"] JSON and the GENERIC_ACTION_TOTAL{outcome=skipped, reason} metric, which keeps observability explicit. Scorer teams can immediately see why their request was rejected. The first client was the panda_reports_embedding_v10_rough_spam label of the embedding-based spam detector v10, which appeared as a temporary PlatformManipulation suspend.

Why it matters: Attaching a new scorer now needs a shorter AES YAML deployment cycle, and A/B gating is also possible from the GrowthBook console. However, the mapping itself of "which score maps to which action" (the actual allowlist values in GrowthBook) is still outside the public source.

Gen4+9 (2026-08-26) — TesHydrator media fallback cache removed entirely + dark_traffic canary opt-out

The 2026-08-26 commit (Gen4+9) deleted the fallback cache that could return media features from an earlier lookup when VF failed a TES lookup, and removed canary workloads from dark traffic mirroring verified. The deleted cache was a safety net for the stabilization period after the early VF rewrite inferred. This commit was covered in the daily.

Deleted the media fallback cache verified — visibility-filtering/hydration/tes_hydrator.rs (+4/−292). The following three files changed with it:

Previously, TesHydrator (Tweet Entity Service, X's internal tweet metadata lookup service) held a fallback cache based on a 1M-entry LRU. When a TES call failed and no media entity came back, behavior depended on the mode.

  • With FallbackCacheMode::ServeStale, it returned stale but valid media features from a previously successful lookup.
  • With Shadow, it filled the cache but did not use it for serving.
  • With Disabled, it failed without a fallback.

This cache was removed in full this time, and the related code was deleted with it:

  • The CACHE_CAPACITY = 1_000_000 constant and the FallbackCache::new("media", ...) instance field
  • The resolve_hydration_batch call
  • Related regression tests (media_stale_recovery_respects_cache_mode, no_media_tweets_cache_default_entries_that_serve_stale)
  • The mock client (MediaFailingAfterFirstClient)
  • Two environment variables (VF_MEDIA_FALLBACK_CACHE_SERVE_STALE_ENABLED, VF_MEDIA_FALLBACK_CACHE_POPULATE_ENABLED)
  • Config helpers (media_fallback_cache_serve_stale_enabled(), media_fallback_cache_populate_enabled())

The media_fallback_cache_mode: FallbackCacheMode parameter disappeared from the signature of HydrationPipeline::new. The TesHydrator struct now has only one field left: pub tes_client: Arc<dyn TESClient + Send + Sync>.

Why it matters: This fallback cache was a safety net for the stabilization period after the early VF rewrite (the Gen4 headline) inferred. If a failure occurred during the TES infrastructure rewrite, candidates would be caught by fail-close filtering without media features, and this cache mitigated that destructive result. Removing it now means three things:

  • The VF rewrite can now be audited for consistency with the Gen4+7 reference_compare harness.
  • The failure rate of the TES media path has dropped, so the marginal utility of the fallback cache has shrunk.
  • It follows the same "remove the safety net once stabilized" pattern as Gen4+8's major simplification of VMRanker.

However, the fallback caches of other hydrators (such as the gizmoduck author metadata lookup) remain unchanged. The fallback_cache_mode env also still exists.

dark_traffic gained a canary opt-out verified — visibility-filtering/dark_traffic_setup.rs (+40/−17).

  • should_enable(ordinal, max_ordinal) was renamed to should_mirror(workload, ordinal, max_ordinal) and expanded. The new rules are three:
    • If workload=None (env WORKLOAD_NAME unset), it does not mirror, failing closed.
    • If workload.ends_with("-canary"), it does not mirror. Canary workloads are never mirrored to dark_traffic.
    • Otherwise, it decides by ordinal < max_ordinal as before.
  • There are two new tests, canary_never_mirrors (checks the PROD and CANARY constants) and missing_workload_name_fails_closed. The log message was also generalized from "disabled (ordinal >= max)" to "disabled (not a mirror host)".
  • Why it matters: dark_traffic was introduced in Gen4+4 and redefined from shadow to staging in Gen4+6. Now dark_traffic prevents the incident in which a canary workload accidentally enters mirroring and canary issues contaminate shadow verdicts. Because production and canary workloads follow a convention of using the same name prefix inferred, suffix matching suffices.

Gen4+10 (2026-08-28) — sampled async warmer wiring for safety-label twemcache misses

The 2026-08-28 commit (Gen4+10) added a warmer that samples keys missed in the cache while VF looks up safety labels, then rereads them asynchronously to fill the cache verified. Rule verdicts are unchanged; the warmer is a low-level infrastructure optimization that raises the cache hit rate of label lookups. This commit was covered in the daily.

A new file warmer.rs (203 lines) was added under visibility-filtering/safety_label_source/ and attached to the endpoint with RemoteSource::with_warmer(...) verified — visibility-filtering/safety_label_source/warmer.rs (203 lines, new). The following five files changed with it:

The target is the Strato column visibility/baseTweetSafetyLabelMap. twemcache sits in front of this column as a look-aside cache, and on a miss it falls back to Manhattan. This release adds a warmer channel that re-fetches those misses asynchronously and fills them into twemcache.

The warmer works in four steps:

  1. In RemoteSource::get(ids), twemcache results split into four kinds. Of these, only Miss is collected into a separate vector warm_ids.
    • Hit
    • NotFound: definitively nonexistent
    • Miss: the cache does not know this key
    • FallThrough(reason): cache error, timeout, etc.
    • NotFound, a definitive nonexistence, and FallThrough, which has a fallback reason, are not warming targets. The principle is that warming aims to resolve a state where the cache is not filled, not to respond to misses for other reasons.
  2. Before the Manhattan fallback request goes out, it calls warmer.warm(warm_ids) (fire-and-forget). This does not affect request response latency.
  3. SampledWarmer samples with probability using the VF_CACHE_WARM_SAMPLE_PCT environment variable (0–100, panic on out-of-range). Only the IDs that pass are try_send-ed to a tokio bounded mpsc (capacity 1024).
  4. A background drain task takes vectors from the queue. During a 500ms linger it merges vectors that arrive later and batches up to 50 IDs. It makes a batch_call to Strato with StratoWarmFetcher::fetch(chunk) (WARM_COLUMN_PATH = "visibility/baseTweetSafetyLabelMap").
    • Strato's batch fetch writes the result back to twemcache. We infer that a separate read-through cache layer exists inferred.

Even when the channel is full, the request path is not blocked. If try_send fails (channel full), it only increments the WarmKeyResult::DroppedChannelFull counter and continues request handling. It was designed so that even if the warmer channel saturates, no backpressure is ever applied to the request path. The regression test full_warm_channel_does_not_affect_fallback_result asserts this explicitly.

The observability counter record_cache_warm_keys(result, count) counts 6 outcomes.

  • EligibleMiss: total amount that became warmer targets because of a miss (before sampling)
  • SampledOut: number dropped by sampling
  • Enqueued: number successfully put into the queue
  • DroppedChannelFull: number discarded because the queue was saturated
  • FetchIssued: number actually sent out as Strato calls
  • FetchFailed: number for which the Strato call returned an error

With these 6 outcomes, the pass rate of each warmer stage and its bottleneck can be observed. For example, a high FetchFailed / FetchIssued indicates a Strato infrastructure issue, and a persistent DroppedChannelFull means the channel capacity (1024) is insufficient.

Three side cleanups were also made in the same config.rs. Three new envs appeared: VF_GIZMODUCK_CLIENT_ID, VF_TWEMCACHE_CLIENT_NAME, and APP_ENV. When no value is set, gizmoduck_client_id() assembles the name automatically from the visibility-filtering-service.{APP_ENV|prod} template. That is, the client name for the gizmoduck author metadata lookup splits automatically by deployment environment (staging/prod). We infer that the purpose is to separate the staging gizmoduck quota and rate limit from production when VF runs on staging via dark_traffic (see Gen4+4, +6, +7) inferred.

Why it matters:

  • A twemcache miss has two cases. One is a fresh state where the tweet has not yet received any labeling (neither cache entry nor label exists). The other is TTL expiry or eviction. This warmer addresses especially the second case, that is, the waste of querying Manhattan on every request because the cache missed although Manhattan has the label.
  • Because a sample-pct gate is attached, the warmer itself does not cause a Strato flood even when traffic spikes. A dial-up rollout is possible, starting with VF_CACHE_WARM_SAMPLE_PCT low (for example, 5–10) and raising it gradually. This is a practice similar to Gen4's other dial-up pattern (Grok 4.6 10%→30%→100%).
  • This does not mean a new "request-path layer" of VF has opened. Rule verdicts (3-tier / 54 rules) are unchanged, and this change is a low-level infrastructure optimization that raises the cache hit rate of label lookups. However, VF performs label lookups for tens to hundreds of candidate tweets on every request, so the cache hit rate directly affects P99 latency.

Undisclosed:

  • The initial production value of VF_CACHE_WARM_SAMPLE_PCT
  • How the twemcache miss rate changes before and after the warmer was introduced
  • How the warmer delay (linger 500ms) interacts with the twemcache stale window

Gen4+10 (2026-08-28, 24c6094) — MultiRiskAdsBlender introduced (BSR-high ads tier + medium-risk fallback slot)

The 2026-08-28 commit (Gen4+10) added a fourth ads blender that, only for ads whose advertiser allows impressions in medium-risk context, opens slots between medium-risk posts before safe slots verified. This blender is not the default yet; it is enabled only when selected by a param. This commit was covered in the daily.

A fourth variant, multi_risk, was added to the set of three ads blenders (safe_gap, partition_organic, time_gap) verified — home-mixer/ads/multi_risk_blender.rs (332 lines, new). The other changed files are the test home-mixer/ads/tests/multi_risk_blender_tests.rs (215 lines, new) and home-mixer/selectors/blender_selector.rs (+8/−4).

  • This blender is selected when the AdsBlenderType param receives the string "multi_risk".
  • The same commit added the "partition_organic_low_risk" alias to PartitionOrganicAdsBlender, bringing the options to 5 in total.
    • safe_gap / partition_organic / partition_organic_low_risk / time_gap / multi_risk

The slot policy is set by an adjacency matrix of ad tier and post tier. It combines two axes, BrandSafetyVerdict::HighRisk (introduced in Gen4+7, 2026-08-24; see the section above) and the ad-side AdAdjacencyControl.brand_safety_risk = BsrNormal | BsrHigh, to decide the slot policy.

Ad tier Next to Safe Next to MediumRisk Next to HighRisk
BsrNormal (default) Placed Not placed Not placed
BsrHigh (advertiser opt-in) Placed Placed Not placed

In other words, the earlier blenders (safe_gap, partition_organic) enforced a symmetric policy of "all ads → Safe slots only". By contrast, multi_risk opens medium-medium slots when the advertiser voluntarily allows exposure in medium-risk context (BsrHigh). A HighRisk post cannot be adjacent to any ad.

The safe slot budget is the smallest of three caps.

safe_budget = min(num_ads, spacing_cap, safe_count / 2)
  • spacing_cap = (n − 1) / requested_spacing (default 3): the cap through which feed length enforces ad spacing.
  • safe_count / 2: each ad slot sits between 2 posts, above and below, so half is the cap.

Which cap was the binding constraint is stated explicitly by the tag MultiRisk.serving_limitation{factor=ads_supply|spacing|safe_posts}. With this tag, the cause of ads not being shown can be sliced in real time into insufficient ad supply, insufficient spacing, and insufficient safe posts.

BsrHigh ads use the spots between medium-risk posts first. If a medium-risk post pair remains, a BsrHigh ad is placed above and below that pair first, and if it fails the overlap check while safe slot budget remains, it moves to a safe slot verified — multi_risk_blender.rs:96-130. However, it must still pass the handle overlap check (handle_drop) and the keyword overlap check (keyword_drop). It is dropped if the advertiser handle is mentioned in a neighboring post or the ad keyword overlaps the neighboring post text. In other words, BsrHigh's medium-risk placement only resolves "the advertiser's contract that placing next to a medium-risk post is acceptable", and the prevention of adjacency self-interference (same advertiser mention, same topic) stays as it was.

Slot outcomes are counted in finer detail with the MultiRisk.slot_outcome{outcome=filled|unfilled_bsr_low|unfilled_handle|unfilled_keyword|unfilled_no_ads} counter.

  • filled: an ad was successfully placed in the slot
  • unfilled_bsr_low: the candidate ad was dropped by the BSR-low signal, and the slot ended up empty
  • unfilled_handle, unfilled_keyword: dropped in the self-interference checks above
  • unfilled_no_ads: ad supply itself was exhausted

SlotRejections::stuck_outcome() picks the most frequent of the three rejection reasons as the slot's representative cause. That is, an A/B dashboard can show "the safety filter hit most often when a slot was empty".

Enforcement results are aggregated in the MultiRisk.enforcement{action=drop|ok|handle_drop|keyword_drop} counter.

  • drop/ok: BSR-low ad decision (dropped or placed)
  • handle_drop/keyword_drop: dropped in the self-interference checks

Why it matters:

  • The five-day flow of Gen4+7 (2026-08-24) HighRisk tier introduced → Gen4+9 ad pixel·conv_asset_ids → Gen4+10 tier×tier matrix blender introduced is now complete. Since HighRisk was split out as a tier and the ad pixel conversion head was attached, a blender option that lets advertisers opt in on the tier axis for their own exposure has now arrived.
  • Crucially, MultiRiskAdsBlender is not yet the default. It is enabled only when the AdsBlenderType param string is "multi_risk". That is, it sits behind an experiment gate, so the actual exposure ratio is not public. The next checkpoint to watch is when the default of AdsBlenderType is promoted to multi_risk.
  • For advertisers, it signals that the incentive structure of BSR-high opt-in is now explicit in the code. Accepting medium-risk context earns medium-risk slots and increases total impressions. Previously, no such slot existed in the code.

Undisclosed:

  • The experiment rollout ratio of AdsBlenderType
  • The actual share of the BsrHigh ad segment (advertiser opt-in adoption rate)
  • How much uplift medium-medium placement produces in revenue and brand safety metrics
  • Deployment practice for the other AdAdjacencyControl fields (handles, keywords)

Gen4+10 (2026-08-28, 24c6094) — trainer actually consumes streamed restore + gRPC trailer preservation bugfix + copy_port shard-schedule shuffle

Depending on a config flag, the Phoenix trainer now actually calls streamed restore, which loads a checkpoint while moving tensors to the device one at a time verified. This commit also fixed a bug where the gRPC status trailer disappeared when compressing serving responses, and shuffled the order in which each pod receives tensor shards so that requests do not pile onto the peer holding the first shard verified — daily covering this commit.

Three-way hardening came out of one commit. The scaffolding that Gen4+9 laid (save symmetry, the load_checkpoint_streamed API, copy_port GB/s logging) matured in three ways in this commit.

  • Connected the consumer on the trainer side.
  • Restored the integrity of the serving response trailer.
  • Spread the peer load of copy_port evenly.

1. The trainer reads the restore_streamed flag and actually uses streamed restore verified — phoenix/xrex/train/trainer.py (+102/−31), phoenix/xrex/configs/xrecsys.py (+1), phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+85/−30):

maybe_load_checkpoint now reads the checkpoint_config.restore_streamed flag and actually calls load_checkpoint_streamed. The streamed path is enabled under three conditions, and all must be true.

  • restore_streamed=True
  • ctx.checkpoint.format == "orbax"
  • save_method != "tensorstore"

If any one does not hold, it logs and falls back to the existing load_checkpoint (full pinned-host stage). The use_streamed_restore=True path itself calls checkpointing_load.copy_aliased_arrays(self.state) to separate aliased state leaves in advance. The new function _graft_replaced(pairs) grafts the loaded tensors in place at the positions of the original state tree. If an aliased leaf is in two places, it becomes ambiguous which slot to put it in, so this case is blocked early with an assertion (multiple loaded tensors share one device array).

The two new arguments that load_checkpoint_streamed takes were also routed in this commit.

  • window_gb: the pinned-host budget for that batch. If None, auto-window is used.
  • on_replaced callback: receives a list of old/new device array pairs for the completed batch. The caller uses this list to graft onto the state tree.

window_cap_gb is a safety belt that is set from restore_concurrent_gb only when window_gb is not specified.

The core of this flow is not making a copy of the optimizer state in pinned-host, but moving tensors to the device one by one and then grafting the original reference. The benefit is especially large in the warm_start case (load pretrained params, start with fresh opt state). The existing do_not_load_opt_state=True path cuts the opt state fields in advance with warm_purge(self.state). The streamed path leaves host_state as None and grafts only the loaded params, so no pinned-host copy of the opt state is ever created. The checkpoint stack now atomically supports the standard flow in which the Gen4+6/+7 Muon and GB300 flagships start with pretrained params and a fresh optimizer.

2. The response compression path fixed a bug that dropped gRPC trailers, and added a zstd encode time histogram verified — phoenix/crates/serving/xai-recsys-engine/src/grpc_compression.rs (+109/−13), phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs (+22/−5), phoenix/crates/serving/xai-recsys-engine/src/request_metrics.rs (+17/−4):

When GrpcCompressionService compressed a response body with zstd, it previously extracted only the body with collected.to_bytes() and rewrapped it in a new axum::body::Body::from(data). In this process, gRPC trailers (especially grpc-status and grpc-message) were all lost. A gRPC frame carries the status code in a trailer after the body, and that part was discarded. Clients may have received the response normally yet treated it as UNKNOWN/EOF because the status trailer was missing.

The new code first extracts the trailers with collected.trailers().cloned(). Then the rebuild_response(parts, data, trailers) helper rebuilds the response body by reattaching the trailers with Full::new(data).with_trailers(async move { Some(Ok(tr)) }). Both paths were unified so that trailers are kept whether compression succeeds or fails. Two regression tests (compressed_response_forwards_trailers, non_compressed_response_forwards_trailers) pin down this behavior.

A new histogram, recsys_engine_response_compress_ms{method}, was also added to the same file. It takes Instant::now() right before entering tokio::task::spawn_blocking(compress_grpc_frame) and observes with elapsed().as_secs_f64() * 1000.0 afterward. Measured this way, the queuing and CPU time of zstd encoding can be viewed separately per method. The buckets share the request latency buckets (crate::request_metrics::latency_buckets_ms()).

Why it matters: Trailer loss is a serious bug that silently breaks the serving contract. If a client cannot determine the response status, retry logic, error logging, and circuit breakers all misbehave. This commit also added regression tests, which greatly lowers the chance of the same bug recurring. The _compress_ms histogram shows how much zstd encoding of large responses on GB300 contributes to response latency, which gives a basis for tuning the compression level.

3. copy_port shuffles the shard schedule per pod to spread peer load evenly verified — phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs (+113/−10):

shuffle_sharded_schedule(n, tensor_name) is new. Previously, when pods received multiple shards of one tensor in parallel, all of them requested shards in order starting from index 0. As a result, the owner peer of the first shard was momentarily overloaded.

The new logic mixes POD_NAME (or HOSTNAME) and the tensor name with a hash. It shuffles with StdRng::seed_from_u64(hash), seeded by this hash, so that each pod requests shards in a different order. Because the seed is deterministic, the same pod keeps the same order even after a restart (log reproducibility). The restore_piece_order(shuffled, schedule) -> Option<Vec<T>> helper restores results received in the shuffled order to the original order. Both consumers, emb_table.rs and copy_port_client.rs, were unified to use this new helper. The shuffle applies only to sharded ownership, and replicated keeps in-order. With replicated, all pods receive the same data, so shuffling gives no benefit.

The log records only the first 8 indexes (copy_port: shard schedule shuffled name={tensor} n={total} first={:?}). This log can diagnose whether the shuffle was actually applied. On encountering TRANSFER_FAILED_SENTINEL (we infer it is u32 max inferred), it returns early with an error before restore.

Why it matters: When many pods restore large embedding tables and dense weights, traffic spikes concentrate on the peer that owns shard 0. This problem was mitigated by Gen4+7's node-serialize and concurrent_gb, but the peer-side spike remained. The shuffle spreads that last bottleneck inversely proportional to the number of pods. Deterministic shuffle matters for two reasons:

  • The shuffle itself can be reproduced from logs.
  • Even if the same pod loads the same tensor several times, the order is the same, so cache hit rate observation stays stable.

Undisclosed:

  • The first active config of restore_streamed (currently presumed false by default inferred; only one added line in the config was confirmed)
  • A realistic consumer of keep_emb_opt_state=True in warm_start_staging
  • Evidence that the gRPC trailer loss bug was observed in production (customer tickets? retry storms?)
  • The P99 target of recsys_engine_response_compress_ms
  • How the request rate distribution of the shard 0 peer changed before and after the copy_port shuffle was introduced

Gen4+11 (2026-08-28, bc8e5f0) — visibility-filtering rule-layer refactor + 90-case golden corpus (no policy change)

The 24 VF rule files were thinned to use domain methods while leaving the verdict logic unchanged, and a new 90-case regression corpus now pins the verdicts verified. The policy did not change, and CI now breaks if a rule is added to the policy without adding a corpus case verified — the daily on this commit.

The 24 rule files in the VF sub-repo were thinned all at once (+1,522/−1,151 in total, a pure refactor). The RuleContext definition was split out of mod.rs into a new file, context.rs. The rule verdict logic stays as it was, and every direct access of the form context.candidate().foo was unified into domain methods (context.is_stale_tweet(), context.viewer_country_in(...)). To back this unification, the commit added a 90-case regression corpus (golden_corpus.rs, +1039 new) and a candidate builder. The candidate builder is fixtures.rs (+116 new). verified — visibility-filtering/rules/context.rs, visibility-filtering/rules/golden_corpus.rs, visibility-filtering/rules/fixtures.rs, visibility-filtering/rules/registry.rs.

RuleContext exposes about 25 domain methods. verified Grouped, they are:

  • Viewer state: viewer_is_logged_out, viewer_is_underage, viewer_has_no_stated_age, viewer_allows_sensitive_media, viewer_country_in
  • Viewer↔author relationship: is_author_viewer, viewer_follows_author, viewer_blocks_author, viewer_mutes_author, viewer_mutes_retweets_from_author
  • Tweet shape: is_retweet, is_stale_tweet, is_nullcast, is_community_tweet, has_media, has_dmca_media
  • NSFW: is_nsfw_flagged, has_tweet_nsfw_user_flag, has_tweet_nsfw_admin_flag
  • Country regulation: legal_takedown_in_viewer_country, local_laws_takedown_in_viewer_country, media_restricted_in_viewer_country
  • Author state: author_is_suspended/deactivated/erased/offboarded/protected/nsfw_user/nsfw_admin, author_has_user_label
  • Exclusive content: is_exclusive_tweet, viewer_is_conversation_author, viewer_super_follows_author

The WORLDWIDE_COUNTRY_CODE = "xx" constant and the takedown reason extraction function were also moved here from tes_rules.rs. verified

The 90-case corpus is divided into 13 categories. The categories are filter_all, baseline, author_state, relationship, tweet_label, tweet_shape, age_gating, exclusive_content, interstitial, oon_media, oon_tweet_label, oon_user_label, and interaction. Each case has the form Case { name, level, viewer, candidate, expected_action, expected_decided_by }, and three assertions apply. verified

  • golden_corpus_pins_policy_verdicts: checks that every case still produces the same action and the same decided_by.
  • every_wired_rule_decides_a_corpus_case: the set of rule names wired into Policies, minus the set of names the corpus actually uses as the decider, must be empty. CI breaks if a rule is added to the policy without adding a corpus case.
    • For this assertion, a new helper wired_rule_names(level) was added to registry.rs under #[cfg(test)].
  • corpus_case_names_are_unique: case names must not overlap.

Individual rule files keep their logic; only the expression got thinner. For example, in DropStaleTweetsRule in tes_rules.rs, the three earlier early returns (the retweet check, the source_tweet_id.is_some() check, and the is_stale check) shrank to two lines. verified — visibility-filtering/rules/tes_rules.rs

if context.is_stale_tweet() && !context.is_retweet() {
    return VfAction::Drop(FilteredReason::UnspecifiedReason);
}

Places that took an fn(&TweetFeatures) -> bool callback, such as tweet_flag_rules.rs and user_rules.rs, changed their signature to fn(&RuleContext<'_>) -> bool. The callback bodies moved over as well, as in |t| t.nsfw.user → |context| context.has_tweet_nsfw_user_flag(). Of the −298 in registry.rs, we infer that half is individual rule tests moved into the corpus and the other half is repeated inline setup such as Viewer::LoggedIn(999) that was removed. inferred

Why it matters: The policy itself is unchanged. This commit reads as the next step in the flow in which, since the initial Gen4 rewrite, VF has been "removing safety nets (the Gen4+7 reference_compare harness, the Gen4+9 media fallback cache removal) while accumulating regression-defense assets internally". As RuleContext grows richer in domain language (for example legal_takedown_in_viewer_country), the grounds for a verdict read alongside the file name whenever a rule is added or changed. In addition, the mechanism that ties rule wiring to an assertion (every_wired_rule_decides_a_corpus_case) blocks "wiring without tests" at the CI level even as the rule library grows.

Undisclosed: It remains possible that the semantics of some rule file changed subtly during the refactor. This commit alone does not show whether the 90 golden corpus cases match earlier production verdicts exactly. Because the corpus was first introduced in this commit, comparison with the earlier version has to be done by hand. If the Gen4+7 reference_compare harness keeps running after this commit, it is in effect being verified against real traffic.

Gen4+11 (2026-08-28, bc8e5f0) — Phoenix serving: rerankerHeadTag parameter wiring + emb_table gRPC receive moved to the blocking pool

On the serving side of the same commit (bc8e5f0), the value that selects the reranker head started to be carried in Phoenix requests, but this commit contains no serving logic that uses it verified. The large copy performed when receiving the embedding table over gRPC was moved to the blocking pool so that it no longer holds runtime threads verified — the daily on this commit.

The change has two parts.

  • The parameter that selects the reranker head is connected end to end, from home-mixer through two proto locations to the Phoenix request path. This commit has no serving-side logic that consumes it.
  • In the emb_table gRPC receive callback, the non-temporal copy and the Adler-32 computation were split off into spawn_blocking. Tokio runtime threads are no longer held up by a large memcpy.

1. The RerankerHeadTag parameter is connected from home-mixer to the Phoenix request verified — home-mixer/params/param.rs, home-mixer/models/candidate.rs, home-mixer/scorers/phoenix_scorer.rs, home-mixer/util/phoenix_request.rs, phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto:

param!(RerankerHeadTag, i64, "rust_home_mixer_reranker_head_tag", 0);

A pub reranker_head_tag: Option<u32> field was added to PostCandidate, and PhoenixScorer sends this parameter value along when it makes a request.

reranker_head_tag: Some(query.params.get(RerankerHeadTag) as u32),

The reranker_head_tag returned in the scoring result is attached back to the candidate with candidate.reranker_head_tag = scored.reranker_head_tag (a round trip). phoenix_request.rs also fills scoring_sequence.reranker_head_tag with the parameter value and puts it in the request.

The proto field was added symmetrically to two messages.

message ScoreInfo {
  ...
  optional double rewardRerankSlotProb = 5;
  optional uint32 rerankerHeadTag = 6;
}

message UserActionSequence {
  ...
  repeated Mask masks = 8;
  optional uint32 rerankerHeadTag = 9;
}

The Rust proto and the Python proto were updated with the same field number. verified

Why it matters: inferred Because the default is 0 and this commit has no change to server-side consumption logic, this is a wiring step before head routing is turned on. The name head_tag, and its placement alongside UserActionSequence (the input to the feed scorer), read as "specify which reranker head to use for one sequence". The interpretation is a switch that picks one of several reranker heads inside the model for an A/B test during the experiment period. The pattern of training several ranker heads together and routing among them by tag at serving time is common at the mature stage of A/B testing.

2. emb_table.rs hands the copy of data received over gRPC into the destination buffer to the blocking pool verified — phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs (+79/−12)

Previously, inside send_entries, the gRPC proto callback performed the non-temporal copy directly into the destination slice and updated the Adler-32. The new code splits the data into 4 MiB chunks and pushes them into a sync_channel(2). The copy is handled by a consumer run separately through tokio::task::spawn_blocking.

const GRPC_COPY_PIECE: usize = 4 << 20;
let dest_ptr = buf.as_mut_ptr() as usize;
let dest_len = buf.len();
let (tx, rx) = std::sync::mpsc::sync_channel::<bytes::Bytes>(2);
let copy_join = JoinOnDrop::new(tokio::task::spawn_blocking(move || {
    let dest = unsafe { std::slice::from_raw_parts_mut(dest_ptr as *mut u8, dest_len) };
    let mut pos = 0;
    let mut checksum = 1u32;
    while let Ok(chunk) = rx.recv() {
        install_grpc_chunk(dest, &mut pos, &mut checksum, &chunk);
    }
    (pos, checksum)
}));

The callback only sends a chunk wrapped with bytes::Bytes::copy_from_slice into the channel. The non-temporal copy and the Adler-32 computation run on blocking pool threads. On the RDMA path as well, even the final Adler-32 summation was moved to spawn_blocking.

A new helper, JoinOnDrop<T>, was added as a safeguard. On the normal path, the result is collected with join().await. When dropped, it always terminates the blocking task with block_in_place and block_on(handle). verified The purpose is presumably to prevent file descriptor and handle leaks inferred.

Why it matters: inferred The gRPC callback runs on a Tokio runtime thread. So if the non-temporal copy and the Adler-32 computation hold a runtime thread for long, other tasks (request routing, admission decisions, health checks, and so on) are delayed. With this change, the gRPC receive thread only passes chunks along without a memcpy, and the blocking pool handles the CPU-bound copy. The 4 MiB chunk size and the sync_channel capacity of 2 regulate the receive rate through backpressure. This change is part of the same flow as the Gen4+9 emb_table core split (the load_tensor_into signature, pyo3-free) and the Gen4+10 copy_port shuffle. The serving infrastructure for large embedding tables is refining the boundary between the tokio runtime and CPU-bound work in several places.

Side change: In copy_port_client.rs (+16/−15), the join_all(...spawn) → collect → map_err idiom was extracted into a new join_transfers helper. Only duplication was removed, and behavior did not change. Two test names also changed. ..._despite_spawn was removed, and ..._between_spawned_batches became ..._between_batches. We infer this cleans up wording that was added yesterday when the commit introduced the deterministic shuffle. verified

A Bytes callback type was added in proto_parser.rs (+11). A Bytes callback now sits beside the existing Str callback (FnMut(usize, &[u8])), so chunks can be passed by ownership transfer. The signature of the Bytes callback is FnMut(usize, Bytes). In this commit emb_table still uses the Str signature, so true zero-copy is a point to watch in a later commit. verified

Undisclosed:

  • How serving-side Phoenix actually consumes RerankerHeadTag (this commit has no consuming logic)
  • Whether UserActionSequence.rerankerHeadTag and ScoreInfo.rerankerHeadTag are symmetric between request and response (the request says which head to score with, the response says which head was actually used), or serve a different purpose
  • Why emb_table does not yet use the Bytes callback (preparation for other consumers vs. a plan to switch emb_table in the next commit)
  • How much the tail latency of the tokio runtime improved after the gRPC callback was moved to the blocking pool

Gen4+12 (2026-09-01, 6384ca7) — 16 NSFW-gating countries published + Rust↔Scala VF drift monitoring + motion-reveal-based special video safety pipeline

The 16 countries in which NSFW media is filtered for viewers who have not stated their age were stated explicitly as a default in VF code for the first time, and a mechanism was added to monitor whether the lists of the two VF stacks, Rust and Scala, diverge verified. A safety pipeline also appeared that accepts only high-popularity videos whose fav count is at or above a threshold, and shows Grok the original together with motion-reveal frames that expose content hidden under a static overlay, to reach a verdict verified — the daily on this commit.

This commit adds two axes to the safety stack. One is that the age-gating policy was stated in code for the first time, and the other is that a layer that actively counters evasion was newly connected.

The 16 NSFW-gating countries were stated in code as a default verified — visibility-filtering/params.rs (216 lines, new). default_nsfw_gating_countries() returns the following list.

["ar", "au", "br", "ca", "de", "es", "fr", "gb", "id", "it", "kr", "mx", "nl", "ph", "pt", "th"]

In country names, these are Argentina, Australia, Brazil, Canada, Germany, Spain, France, the United Kingdom, Indonesia, Italy, South Korea, Mexico, the Netherlands, the Philippines, Portugal, and Thailand. This list can be overridden at runtime through the feature switch rust_vf_nsfw_gating_countries. The value is wrapped in an ArcSwap and looked up again every 30 seconds.

It monitors whether the Rust VF and Scala VF lists diverge (drift). On every refresh, refresh_and_check_drift in the same file also reads the sibling YAML country_specific_nsfw_content_gating.yml (which the Scala VF stack references). If the Rust result and the Scala list diverge, it emits a tracing::warn! and increments the nsfw_gating_countries_drift counter of xai_stats_receiver. The two stacks are running in parallel, and the design makes drift observable.

If a malformed value is mixed in, the whole list reverts to the default (fail-whole fallback). If a malformed element (for example a number) is mixed into the feature switch value, it falls back to the full default 16 countries rather than using a partial list. The regression test malformed_value_falls_back_whole_not_partial locks in this contract.

A country list was injected into three rules verified — visibility-filtering/rules/nsfw_age_gating.rs. Three rules operate in parallel on media candidates labeled NSFW/GORE.

  • SensitiveViewerLoggedOutDropRule: drops unconditionally if the viewer is logged out. It is independent of country.
  • SensitiveViewerUnderageDropRule: drops if the viewer is confirmed to be under 18. It is independent of country.
  • SensitiveViewerNoStatedAgeDropRule: drops if a viewer whose age is unconfirmed belongs to the 16 countries above. That is, this rule does not apply in countries such as the United States, Japan, and Russia.

A new 8-stage DAG for high-popularity videos, PlanSafetyPtosSpecialVideo, was introduced verified — grox/flows/ptos/plan_safety_ptos_special_video.py (55 lines, new). The dependency graph is as follows.

special_video_filter → annotation_rate_limit → media_hydration → special_video_screen
    → category_detection → policy_detection
        → safemodel_sex_nudity
        → adult_content_cross_validation → result_sink

There are three entry conditions (_skip_reason).

  • There is a user.
  • The media contains at least one Video.
  • The fav count is at or above grox_config.media_hydration.deluxe_fav_count_threshold.

That is, only high-fan videos confirmed as viral enter. Because the whole process of screening, verdict, and LLM cross-validation is expensive per candidate, it is not widened to low-fan videos.

special_video_screen filters videos by whether motion-reveal frames exist verified — grox/flows/ptos/task_special_video_screen.py. _technique_signals returns a dict, and the only technique currently detected is motion_reveal. Only videos in which this field is non-empty move on to the next task.

@classmethod
def _technique_signals(cls, convo_video: ConvoVideo) -> dict[str, bool]:
    return {"motion_reveal": bool(convo_video.motion_reveal_frames)}

The prompt sets the meaning of motion-reveal frames and the verdict criteria (rubric) verified — MOTION_REVEAL_DESCRIPTION in grox/core/lm/convo.py. A motion-reveal frame is a frame in which the static layer is subtracted from the original video and the faint motion layer is amplified. This processing exposes the real content that was hidden under a static overlay (a caption image, a transparent mask, a watermark, and so on). The Grok LLM receives both the original and the motion-reveal frame sets, and the prompt states two caveats explicitly.

  • Motion-reveal creates distortion. It can make "opaque clothing look like bare skin". Recall may be high but precision low, so it is not used to issue an unconditional hard verdict; it serves only as an entry signal that sends the video to downstream verdicts and cross-validation.
  • The original and motion-reveal frames must be treated separately in the verdict. The prompt states the rubric explicitly: "a person already visible in the original frames is judged on the original alone; only what newly appears in the motion-reveal frames is truly hidden content."

Image-processing functions that produce motion-reveal frames were added verified — grox/libs/video_tools/image.py (+125). They include CLAHE (contrast limited adaptive histogram equalization), adaptive brightness, saturation boost, and sharpen. X's safety pipeline put into production a preprocessing step that actively transforms video frames to expose evaded content.

Only the special video path operates fail-close verified — grox/flows/ptos/task_safety_ptos_adult_content_cross_validation.py:106-117, 130-148. If the Grok judge call fails with an exception, then for task_type == SAFETY_PTOS_SPECIAL_VIDEO the original verdict is not kept and is forcibly demoted to Soft (Grok 4.5 Cross Validation failed). On other paths, keeping the original verdict is the default. The Soft enforcement on the special video path sets the result to a single Soft level regardless of the original verdict. If the original verdict was Hard, this is a relaxation, and if there was no AdultContent violation, a Soft violation is newly attached verified — same file, lines 130-148.

This commit means four things at the policy level.

  • X stated in code, for the first time, the countries where it enforces age-gating. This list originally existed in Scala VF as well. This time it entered Rust VF as a default, and divergence between the two lists can now be observed through a metric. These two changes show a flow toward completing the Rust migration safely. The United States is not in the list.
  • The safety stack now has a layer that actively counters evasion. Before, it was a passive stack of "the model attaches a label → rules issue the verdict". Now a layer was added in front of it: "detect the evasion technique → actively remove the evasion → re-judge on those frames".
  • The evasion countermeasure is limited to high-fan videos.
    • It manages cost by applying the expensive countermeasure only to content confirmed to be spreading.
    • Because the evasion incentive is large given the scale of spread, it strengthens evasion detection.
    • That evasion detection does not trigger while a video is in a low-fan state is an open weakness of this architecture. Whether retroactive processing is connected could not be confirmed.
  • The _technique_signals dict has a shape that anticipates new techniques. Which technique (caption overlay, splash frame, chroma key, and so on) will be added after motion-reveal is a point to watch inferred.

Undisclosed:

  • The value of deluxe_fav_count_threshold
  • The exact algorithm for generating motion-reveal frames (the criteria for separating the "static layer" from the "faint motion layer")
  • Candidates for other evasion techniques and when they will be introduced
  • Whether retroactive processing for the low-fan state is connected

Gen4+13 (2026-09-01, 7ba7768) — visibility-filtering rules restructuring (struct-per-rule → data-driven RuleSpec, no policy change)

This is a refactor that changed how VF rules are written from one struct per rule to a list of data literals verified. The rule list and order are unchanged and this commit alone does not change safety verdicts, but in the same commit the default ads blender type in home-mixer was changed to multi_risk verified — the daily on this commit.

In the second commit of the same day (20:47 UTC), the rules/ subtree of VF was reorganized from 9 files into 3 files and a shared enum. It deleted 2,670 lines and added 1,568, but the rule list and order are unchanged, and a regression-prevention test locks this in as literals. It is the next step after the RuleContext cleanup and the introduction of the 90-case golden corpus in Gen4+11 (bc8e5f0).

The representation of rules changed from structs to data literals verified — visibility-filtering/rules/rule_spec.rs (89 lines, new).

#[derive(Clone)]
pub(super) enum RuleSpec {
    Tweet {
        name: &'static str,
        when: fn(TweetPredicates<'_>) -> bool,
        action: RuleAction,
        exempt_author: bool,
    },
    Author {
        name: &'static str,
        when: fn(AuthorPredicates<'_>) -> bool,
        reason: FilteredReason,
        exempt_follower: bool,
    },
    Custom {
        name: &'static str,
        evaluate: fn(&RuleContext<'_>) -> VfAction,
    },
}

#[derive(Clone)]
pub(super) enum RuleAction {
    Drop(FilteredReason),
    SensitiveMediaInterstitial(FilteredReason),
}
  • The approach of writing struct + impl Rule for each rule is gone, and each rule became a one-line literal such as RuleSpec::Tweet { name, when, action, exempt_author }.
  • There are three variants.
    • Tweet: a tweet-condition predicate and drop/interstitial
    • Author: an author-condition predicate and drop
    • Custom: an arbitrary function pointer. It is used when the condition cannot be captured as a predicate.
  • Common exemptions were absorbed into spec fields. exempt_author is the self-view exemption of a tweet rule, and exempt_follower is the follower exemption of an author rule. The check that used to be repeated in each rule is gone.
  • There are only two kinds of action: Drop and SensitiveMediaInterstitial. An interstitial automatically checks viewer opt-in.

Tweet-axis rules were reorganized into per-group constants in tweet_rules.rs (1,125 lines) verified — visibility-filtering/rules/tweet_rules.rs. Rather than carrying over the old per-file rules as they were, the commit regrouped them by policy trigger type.

Constant (pub(super) const &[RuleSpec]) Rule count Old file replaced
TWEET_LABEL_DROPS 8 tweet_label_drops.rs (in-network drops)
NSFW_MEDIA_INTERSTITIALS 3 nsfw_interstitial.rs (label-triggered)
OON_TWEET_FLAG_DROPS 2 tweet_flag_rules.rs
OON_TWEET_LABEL_DROPS 9 tweet_label_drops.rs (OON)
EXCLUSIVE_TWEET_DROP 1 exclusive part of socialgraph_rules.rs
NSFW_AUTHOR_INTERSTITIAL 1 author-flag part of nsfw_interstitial.rs
NULLCAST_DROP 1 nullcast_rule.rs
TES_HOME_DROPS 3 tes_rules.rs (stale + takedown, shared by home)
FILTER_ALL 1 FilterAllRule in the old registry.rs
RECS_MEDIA_DROPS 2 DMCA/geo part of tes_rules.rs (recs only)
SENSITIVE_VIEWER_DROPS 3 nsfw_age_gating.rs

Two splits are especially meaningful.

  • nsfw_interstitial.rs was split into label-triggered interstitial and author-flag-triggered interstitial. The former goes into the home base rules, and the latter went out to a separate constant.
  • tes_rules.rs was split into shared by home (stale and takedown) and recs only (DMCA and geo-restricted media). That is, a recommendations-only rule axis became explicit in the code.

SensitiveViewerNoStatedAgeDropRule kept its name and condition but changed how it stores state verified. The rule that in Gen4+12 (6384ca7) was a struct with an Arc<NsfwGatingCountries> field in nsfw_age_gating.rs was moved into a Custom spec.

fn sensitive_viewer_no_stated_age(context: &RuleContext<'_>) -> VfAction {
    if context.viewer().has_no_stated_age()
        && context.viewer().country()
            .is_some_and(|country| context.nsfw_gating_country(country))
        && sensitive_base_condition(context)
    {
        VfAction::Drop(FilteredReason::ContainNsfwMedia)
    } else {
        VfAction::Allow
    }
}
  • Previously, the rule itself held Arc<NsfwGatingCountries> as a field. Now RuleContext holds the reference, and the rule looks up through context.nsfw_gating_country(code). The rule became a stateless, zero-sized data literal.
  • The three rule names (SensitiveViewerLoggedOutDropRule, SensitiveViewerUnderageDropRule, SensitiveViewerNoStatedAgeDropRule) are kept as they were, because audits, metrics, and regression tests refer to these names as literals.

Author-axis rules were gathered by group in author_rules.rs (354 lines) verified — visibility-filtering/rules/author_rules.rs.

Constant Rule count Replaces
AUTHOR_STATE_DROPS 5 (Suspended·Deactivated·Erased·Offboarded·Protected) old user_rules.rs
OON_NSFW_AUTHOR_DROPS 2 (NSFW user·admin) NSFW part of the old user_rules.rs
OON_USER_LABEL_DROPS 11 old user_label_drops.rs
SOCIALGRAPH_DROPS 3 (Custom) old socialgraph_rules.rs

SOCIALGRAPH_DROPS stayed as Custom because it has to look at viewer state (logged-out, blocks, mutes) together with the relationship.

The registry.rs regression-prevention test locks in "no policy change" as literals verified — visibility-filtering/rules/registry.rs.

The new test wired_rule_order_matches_pre_migration_sequence verifies, as literals and including order, the rule names of 28 rules for TimelineHome, 26 additional rules for TimelineHomeRecommendations, and 1 rule for FilterAll. The rule order of the home timeline is as follows (for reference).

SuspendedAuthorRule → DeactivatedAuthorRule → ErasedAuthorRule → OffboardedAuthorRule
→ ProtectedAuthorDropRule → ViewerBlocksAuthorRule → ViewerMutesAuthorRule → MutedRetweetsRule
→ PdnaTweetLabelRule → BounceTweetLabelRule → SpamTweetLabelRule → ForEmergencyUseOnlyDropRule
→ FosnrHatefulConductDropRule → FosnrViolentSpeechDropRule → FosnrAbuseDropRule → FosnrCivicIntegrityDropRule
→ NullcastedTweetDropRule → DropStaleTweetsRule → DropLegalTakendownPostRule → DropLocalLawsTakendownPostRule
→ SensitiveViewerLoggedOutDropRule → SensitiveViewerUnderageDropRule → SensitiveViewerNoStatedAgeDropRule
→ DropExclusiveTweetContentRule
→ NsfwHighPrecisionInterstitialRule → GoreAndViolenceInterstitialRule → NsfwCardImageInterstitialRule
→ NsfwAuthorInterstitialRule

The Recommendations policy appends 26 rules after the home list. The appended order is RECS_MEDIA_DROPS → OON_NSFW_AUTHOR_DROPS → OON_TWEET_FLAG_DROPS → OON_TWEET_LABEL_DROPS → OON_USER_LABEL_DROPS. A field nsfw_gating_countries: Arc<NsfwGatingCountries> was added to the Policies struct. On evaluate, it passes this reference through RuleContext::new(..., &self.nsfw_gating_countries).

Why it matters

  • The policy is unchanged. This commit alone does not change safety verdicts. Paired with the 90-case golden corpus of Gen4+11 and the every_wired_rule_decides_a_corpus_case assertion, it is the next step in the direction of "locking wiring, tests, and order with CI even as the rule library grows".
  • Understanding and auditing the rule list becomes much easier. Before, answering "which NSFW/drop rules exist in this repository" meant skimming 9 files. Now opening tweet_rules.rs alone shows all rules at a glance, grouped by constant. This form also suits quoting the original text in regulatory responses or transparency reports (Under the Hood).
  • The recs-only axis is stated explicitly in code. RECS_MEDIA_DROPS (DMCA/geo) coming out as a separate constant means that when a recs-only rule is added in the future, it attaches only to this constant without mixing with the home base.
  • Side change: In home-mixer/params/param.rs, the AdsBlenderType default changed from partition_organic_low_risk to multi_risk. It is not directly related to the VF refactor. verified

Undisclosed: It remains possible that rule semantics changed subtly during this refactor. wired_rule_order_matches_pre_migration_sequence verifies only names and order, and the condition predicate of each rule depends on the Gen4+11 golden corpus (90 cases). Consistency at production-traffic scale continues to be verified by the Gen4+7 reference_compare harness.

Gen4+14 (2026-09-02, 85ac72a) — home-mixer requests Phoenix backbone scores + scarecrow → XReview intake introduced + VF rules Box<dyn> removed + checkpoint in-envelope derived master key

This commit confirmed in code for the first time that the Phoenix ranker has two layers, a shared backbone and a reranker head. In the same commit, home-mixer started requesting backbone scores at all five feed endpoints, and a path was added for scarecrow's automated detection bots to put reports into the same XReview queue as user reports verified (daily).

This single commit contains the following changes:

  • The two-layer structure of the Phoenix ranker, backbone and head, became visible in the response wire.
  • A new gRPC pipeline lets scarecrow's automated detection bots submit reports to XReview programmatically, posing as user reports.
  • The VF rules refactor, which started in Gen4+11 and turned into data literals in Gen4+13, moved on to phase 2, so policy now sits in rodata rather than on the runtime heap.
  • The checkpoint encryption that Gen4+13 started now supports an in-envelope derived master key, and the encryption_context schema is enforced.
  • The Phoenix retrieval dwell default also changed from short to long.

A. Phoenix backbone scores response forced on the 5 feed endpoints

home-mixer requests backbone scores from Phoenix at all five feed endpoints verified — home-mixer/server.rs. Each endpoint hardcodes query.return_backbone_scores = true right before passing the request to Phoenix. There is no parameter gating. The five gRPC endpoints are ScoredPostsService, ForYouFeedService, the polling variant of ForYouFeed, RankedFollowingFeedService, and FollowingFeedService.

Backbone fields were added to both the request and the response of the Phoenix proto verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto. The request field is returnBackboneScores (#20). The response fields are backboneTopLogProbs (#10, repeated float) and backboneContinuousValues (#11, repeated float). By naming convention, Top corresponds to the top log probabilities of the discrete head and Continuous to the dense regression output.

PostCandidate gained a backbone score field, and the scorer parses the response into two layers verified — home-mixer/models/candidate.rs, home-mixer/scorers/phoenix_scorer.rs. The new field is backbone_scores: Option<PhoenixScores>. predictions.candidate_scores() is the output of the existing reranker head, and predictions.candidate_backbone_scores() is the output of the newly exposed backbone layer. The candidate carries the scores of both layers to the next stage from a single prediction round.

Why it matters This confirmed in code for the first time that the Phoenix ranker has a two-layer structure of a shared backbone and a reranker head. The previous canon had observed two things. From Gen4 it knew the top-level semantics, a "24-document engagement probability Transformer", and the rerankerHeadTag field in Gen4+11 (bc8e5f0) showed "multiple re-ranking heads". This commit confirms that a backbone layer sits below those heads and that its output is exposed as separate response fields. However, backbone_scores is only stored on the candidate, and no downstream consumer appears in this diff. So which of MultiRiskAdsBlender, VMRanker, DPP re-rank, and the attribution logger consumes the backbone logits is the point to watch.

Two other things changed alongside it:

  • The default of the Phoenix retrieval aggregation approach changed from short dwell to long dwell verified. In home-mixer/params/param.rs, the PhoenixRetrievalAggregationType default changed from DENSE_WITH_SHORT_DWELL → DENSE_WITH_LONG_DWELL.
    • This is a mirror commit whose file-header sync time was updated from 2026-09-01T16:42:25Z to 2026-09-02T16:03:45Z, so the value reflects the source catching up with a production feature switch that actually changed within a day.
  • A buffer guard was added to the batch preparation code of Phoenix serving (phoenix/crates/serving/xai-recsys-engine/src/python.rs). A .fill(0) guard and a copy_len > 0 condition were added together for the candidate embedding and search-query embedding slices. They prevent values left in a reused buffer from leaking out and a panic on an empty slice.

B. scarecrow (formerly Botmaker) → XReview intake gRPC pipeline introduced (new canon)

This commit created a programmatic path connecting scarecrow and XReview. It is a gRPC path through which scarecrow's automated detection bots put reports into XReview intake as if they were user reports. scarecrow is X's internal rule-based engine for automated detection and action, and XReview is the user report and moderation queue. scarecrow was inherited from Twitter's old Botmaker, and the com.twitter.botmaker package remains as is.

Two new function units that scarecrow can call were added verified — scarecrow/legacy/XReviewIntakeRegistry.scala (179 lines, new).

  • The action level of CreateXReviewReportProd is PROD_AGENT_WORKFLOW_ACTION.
  • The action level of CreateXReviewReportStaging is NO_ACTION, and it is a dry run.
  • There are 7 arguments: entityType ("post"/"profile"), entityId, userId, reportType (XReview allowlist), botId, optional note, and optional victimId.

The report payload fills the fields of the SubmitReportRequest proto as in the table below verified — scarecrow/legacy/XReviewReportBuilder.scala (59 lines, new).

XReview report key Value Meaning
subject "Botmaker report" Fixed report title
report_surface "x_app" Same surface as user reports
report_type Argument value (e.g., "bystander_impersonation") Discriminator for lane tag routing
report_source "proactive" A bot detection, not a user report ("user")
reporter_id "0" Bot reports have no reporter user id
detection_bot_id Argument value Which bot produced the report
detection_timestamp_ms context.startMillis() Goes into the uniqueness of the content-hash report id
victim_user_id Argument value (optional) Victim user id

Facts worth noting about the schema:

  • entityType is restricted to a "post"/"profile" whitelist. If an invalid value arrives, normalizeEntityType throws an IllegalArgumentException.
  • The XReview server distinguishes user reports from bot reports with the report_source = "proactive" field.
  • The uniqueness of a report is determined by the content-hash report id and detection_timestamp_ms. Even if the same bot detects the same content twice, the reports are separate when the timestamps differ, and XReview later aggregates them as an evidence rollup.
  • report_type is the discriminator for routing (lane tag) that selects the reviewer team inside XReview. The document quoted from scarecrow's own docs says: "Impersonation bots should use bystander_impersonation (lane tags key on that type, not generic impersonation)." If the wrong type is chosen, the lane is assigned wrongly, and the review is delayed or ignored.
  • The XReview connection goes through the shared library useng.common.xreview. This suggests that not only scarecrow but several internal X backends call XReview intake programmatically.

The submitter has 5 observability counters verified — scarecrow/legacy/XReviewReportSubmitter.scala (91 lines, new). The counters are attempted, submitted, skipped, unsupported_entity, and rpc_failed. The submitter attaches to XReview intake over mTLS (the xreview.tls.ca-cert flag) and is a noop in localMode()/personalAccountMode().

Why it matters This commit expanded X's moderation pipeline. The previous canon knew two layers: inline visibility-filtering and grox PtOS labeling. This commit reveals a third layer in code. When scarecrow reports to XReview automatically, XReview collects the reports as an evidence rollup and routes them to a human reviewer lane. It is not a path that filters inline but one that reports and aggregates asynchronously. ⚠ Claim reinforced: previously we understood the handoff from X's automated detection to human review as roughly "grox PtOS → policy service". This commit confirmed in code that one branch of that handoff shares the same XReview queue as user reports. The existence of XReview allowlist types such as bystander_impersonation means the impersonation response line is subdivided by lane.

C. VF rules restructuring phase 2 — Box<dyn Rule> removed, policy as a static table

The VF rule trait and the vector for dynamic dispatch disappeared from the code verified — visibility-filtering/rules/mod.rs (−155 lines). The targets are the Rule trait and Vec<Box<dyn Rule>>. The Rule trait definition, the evaluate_rules function, and 5 related unit tests were all removed.

Policy is now carried in rodata as a static table verified — visibility-filtering/rules/registry.rs. Its form is &'static [&'static [RuleSpec]].

pub(super) struct Policy {
    rules: &'static [&'static [RuleSpec]],
    additional_rules: &'static [&'static [RuleSpec]],
}

static TIMELINE_HOME_SHARED_RULES: [&[RuleSpec]; 9] = [
    author_rules::AUTHOR_STATE_DROPS,
    author_rules::SOCIALGRAPH_DROPS,
    tweet_rules::TWEET_LABEL_DROPS,
    tweet_rules::NULLCAST_DROP,
    tweet_rules::TES_HOME_DROPS,
    tweet_rules::SENSITIVE_VIEWER_DROPS,
    tweet_rules::EXCLUSIVE_TWEET_DROP,
    tweet_rules::NSFW_MEDIA_INTERSTITIALS,
    tweet_rules::NSFW_AUTHOR_INTERSTITIAL,
];

static TIMELINE_HOME_RECOMMENDATION_ONLY_RULES: [&[RuleSpec]; 5] = [
    tweet_rules::RECS_MEDIA_DROPS,
    author_rules::OON_NSFW_AUTHOR_DROPS,
    tweet_rules::OON_TWEET_FLAG_DROPS,
    tweet_rules::OON_TWEET_LABEL_DROPS,
    author_rules::OON_USER_LABEL_DROPS,
];

static TIMELINE_HOME_POLICY: Policy = Policy::new(&TIMELINE_HOME_SHARED_RULES);
static TIMELINE_HOME_RECOMMENDATIONS_POLICY: Policy = Policy {
    rules: &TIMELINE_HOME_SHARED_RULES,
    additional_rules: &TIMELINE_HOME_RECOMMENDATION_ONLY_RULES,
};

Policies was renamed RuleEngine verified. This struct no longer owns a rule vector and wraps only a NsfwGatingCountries reference, so the new name states explicitly that it is the dispatch layer. RuleEngine::new() does no heap allocation.

The semantics of rule evaluation are unchanged.

  • Drop short-circuits.
  • If a Drop comes after an Interstitial, Drop wins; if there is none, the first one wins.
  • If the result is Allow, decided_by = None.

The composition of 9 home-common slices and 5 recommendation-only slices is the same as in Gen4+13. The new test wired_rule_names_are_unique_and_nonempty verifies that the names of the 15 rule slices are non-empty and do not overlap. The 15 is the 9 home-common and 5 recommendation-only slices plus FILTER_ALL verified — tweet_rules.rs:1130-1157.

Why it matters The direction of the refactor across the four commits from Gen4+11 to +14 is now clear. Rules move from code to data, policy moves from the heap to rodata, and naming is organized around the dispatch view. The next step is to load rule literals from a config file, which would allow changing rules without a code deployment inferred. However, that direction gives up the benefit of static dispatch, so it can only be confirmed by later commits.

D. Checkpoint encryption evolution — in-envelope derived master key + encryption_context enforced + mint_nonce auto-attached

The envelope header format of checkpoint files was defined verified — phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py.

  • The header is 4096 bytes, and the magic is b"XAIENC01".
  • wrapped_len (big-endian u32) is at offset 28–32, and context_len (u32) is at offset 32–36.
  • If the wrapped field has the form xai-dek1:<salt>:<master>, it is judged the "derived" approach; otherwise it is judged legacy per-file wrapping.

The reader uses the xai_encrypted tensorstore driver directly verified — phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py. Gen4+13 used the xai_kms.KvServe KMS-proxy KvStore, which calls KMS on every IO. Now it unwraps the DEK once and decrypts symmetrically on the local machine. adopt_tree_dek uses the _DEK sidecar if there is one, and otherwise unwraps from the derived master key in the checkpoint file's envelope header, which keeps it backward-compatible.

The writer requires both keys, domain and run, in encryption_context verified — phoenix/xrex/train/checkpoint_write.py. If either is missing, a ValueError is raised. domain is the system domain (e.g., "recsys", "grox"), and run is the training run id. This rule forces the KMS audit log to record which run of which domain unwrapped which checkpoint and when.

mint_nonce = sha256(path) is automatically added to the encryption_context verified. The hash of the checkpoint path goes into the context automatically, so if an attacker copies a checkpoint to another path and tries to unwrap it, KMS rejects it because the context does not match. This uses the standard AWS-KMS encryption-context pattern.

_uses_tensorstore_save no longer enforces encrypt verified — phoenix/xrex/train/trainer.py. The two options were separated. save_checkpoint takes kms_client as an argument and assembles orbax_encrypted.get_encrypted_checkpointer itself. _AsyncCheckpointer waits through wait_until_finished of the orbax_encrypted module, so the flush guarantees completion of the encrypted IO.

Why it matters With this commit, enforced validation on the write path and support for two read-path formats (sidecar and in-envelope) are complete. Because the file format remains in code, audit tools and viewer tools can parse it later. With the KMS-proxy KvStore gone, restore latency drops significantly. When Phoenix loads a checkpoint made of tens of thousands of files, KMS calls drop from tens of thousands to one. However, whether CheckpointConfig.encrypt = True is on by default in the actual training config is still not in this diff either.

E. two-tower training config qk_norm=True, attn_logit_cap=-1

The two-tower training config turned on QK-norm and turned off the attention logit cap verified — phoenix/xrex/configs/xrecsys_two_tower.py. The values changed from qk_norm: False → True and attn_logit_cap: 80.0 → -1 (off). The two keys had been in the combined base as False and 80.0 since the Gen4 release verified — xrecsys_two_tower.py:274-275 at 47c1bcd. The combined base default became the same value 12 days after the 2026-08-21 commit (Gen4+6) put True and -1 into the nano config verified — :429-430 at 28e414f. Because QK-norm bounds the QK dot product, a separate logit cap is no longer needed.

Gen4+15 (2026-09-04, 9b0dc31) — grox thread-level multi-step reply spam classifier introduced + PTOS Illegal category dual setup with Gemma+Grok 4.1-fast + Phoenix serving SID sidecar fully removed + H100 remat policy + FA4 attention variable-length history support + large simplification of VF hydration/safety-label

grox gained a second path that looks at an entire reply thread at once and judges, together as spam, the replies that the same author added under their own replies verified. For the PTOS illegal and regulated behavior category, Gemma now handles the first pass and Grok 4.1-fast acts as the judge, and Phoenix serving uses the history SIDs carried in the request instead of an SID sidecar verified (daily).

A single commit changed 6 areas verified — xai-org/x-algorithm@9b0dc31. It touched 74 files with +1,363/−1,876, so the line count shrank on net.

  • In grox reply_spam, a new pipeline of five files and about 500 lines was added, and a second reply spam verdict path was formally wired in. This path puts the entire reply thread into the LLM and receives back "a list of post indexes in this thread to treat as spam".
  • Grok 4.1-fast (EAPI_GROK_4_1_FAST_X_ALGO) appeared in the PTOS policy judge stack as the cross-validator for the IllegalAndRegulatedBehaviors category. This started an asymmetric assignment against 4.5 X_ALGO (Gen4+7), the judge for the other categories. The same category was also promoted to the Gemma path (USE_GEMMA_CATEGORIES) in the first pass.
  • sid_client.rs (189 lines) and sid_retrieval_runner.py were deleted entirely from the Phoenix serving engine, and the xai-recsys-sid-proto dependency was removed. The SID retrieval sidecar is gone, and history SIDs now arrive directly in the request.
  • SAVE_H100_RECSYS = 32 was introduced in the Phoenix training remat policy. This is the first code signal that training also runs on the H100 generation besides GB300.
  • The FA4 attention kernel (ranker_attention_fa4.py, +139/−75) was rewritten. It now handles variable-length history directly (hist_valid_len, valid_block_upper/lower), and the cache is gone.
  • The hydration and safety_label_source stack of visibility-filtering got thinner overall, with a net reduction of 800+ lines.
    • The reductions are −378 in registry.rs, −206 in safety_label_source/codec.rs, −90 in hydration/mod.rs, and so on.

In addition, the Brazil2026ElectionFilter account list also received minor maintenance (166 account IDs added from a TSE update).

A. grox PlanMultiStepReplySpam — a second reply spam path that feeds the whole thread to Gemma and judges several follow-up replies as spam at once

This commit introduced a second verdict path that runs alongside the existing reply spam verdict. It puts the whole thread into the LLM prompt and has it mark several follow-up replies at once as "these indexes are spam". The existing grox reply_spam pipeline gave a one-shot verdict on a single reply and stored the result. That pipeline received flow unification, a follower threshold raised up to 100k, and a Kafka-first storage order across Gen4+2–+10. The multi_step in the name means the unit of judgment is several reply chains rather than an individual reply.

Six files were introduced (Plan/State/Task/Classifier/Prompt, plus the generators change) verified:

The conditions for entering a verdict are set by the entry gating TaskMultiStepReplySpamFilter verified — task_multi_step_reply_spam.py:

  • The target must be a reply. post.ancestors must exist, and none of them may be deleted (None).
  • The authors must exist. The users of the target post, the thread root, and the direct parent of the target post must all be alive.
  • The author must differ from the thread root author. Replies that the original poster adds to their own thread are skipped.
  • If the author is GROK_USER_ID or GORK_USER_ID, it is skipped. Replies posted by Grok/Gork themselves are not verdict targets.
  • The thread depth must be 2 or more. This means the reply chain must have two or more levels. That is, this pipeline is not entered for the single level of "root → first reply".
  • The direct parent's author must be the same as the target post's author. In other words, only a chain in which the same person replies again to their own reply enters. This is the typical self-thread spam pattern.
  • The root author must have 1,000 or more followers ("blast radius"). A verdict is made only when the root is an account with wide exposure. Reply spam under low-exposure accounts is pointless relative to the effort, so it is excluded.
  • If the reply author is a high page-rank v2 user or a grey badge user, it is skipped. Established accounts are out of scope. UserStratoLoader.is_high_page_rank_v2_user and is_grey_badge_user are looked up in parallel with asyncio.gather.

That exactly these 8 axes make up the gating is fixed in the canon. In particular, the sixth axis (the same author replying again to their own reply) explicitly defines the pattern this pipeline aims to catch. That pattern is a reply spam chain that steals root exposure through a self-conversation chain.

The LM prompt is built by MultiStepReplySpamScorer._to_convo verified — classifier_multi_step_reply_spam.py.

  • The system prompt is multi_step_reply_spam_system_prompt(). The README policy is that the original text is excluded from the export to prevent gaming.
  • The human message has two sections.
    • The "Reply Author Info" section renders the user profile of the reply author (UserRenderer.render(post.user)).
    • The "Thread" section lists all posts in the thread, numbered by index in the #### Post N format.
  • The prompt states explicitly that "Post 0 is the root (never flag), and the highest number is the reply being evaluated".
  • If the thread exceeds MAX_THREAD_SIZE = 10, only the first 5 and last 5 remain, and the middle is cut out.
  • The options are include_follower_count=True for each post, include_bio=True for the root only, and max_media=2.

This prompt design is also fixed in the canon. The task does not ask the LM whether an individual post is spam; it asks the LM to extract the set of spam indexes within the thread. The response is JSON with spam_post_indexes: list[int] and reason: str.

The LM is Gemma 2. The setting is model_name = GEMMA_2, and it is called through OaiSampler (grok_sampler/oai_sampler.py). Spam was already in grox's USE_GEMMA_CATEGORIES as of Gen4+9 (45b48ba), and this commit also added IllegalAndRegulatedBehaviors (section B below). The Gemma path appears to be a stack that X is pushing to cut its paid grok API budget inferred. That multi-step reply spam is Gemma-only from the start fits this trend.

Post-processing does not trust the LM output and goes through a 3-step safeguard verified:

  1. _drop_if_newest_reply_not_flagged discards the whole result if the verdict target (the last post of the thread) is not among the candidate indexes the LM returned. The Task entry condition is that "this reaction must be treatable as spam for the pipeline to run", so a result that judges the last reaction as not spam is a consistency violation.
  2. _drop_other_author_posts removes a post if the LM points to a post by another author in the thread as spam. This pipeline targets a chain in which the same author replies to their own reply, so a post by another author appearing in the result is ignored.
  3. _drop_singleton discards the result if it has exactly 1 item. Chain spam by definition targets "several follow-up replies", so a result with only one caught is not a valid result for this pipeline.

The set of indexes remaining after these three filters is the actual spam verdict.

The verdict is stored by TaskWriteMultiStepReplySpamReplyRanking verified:

  • _apply_reply_spam_label(post_id, author_id) is applied to each flagged post. It is the same helper the single-verdict pipeline uses.
  • The score is stored with ReplyRankingScoreStratoLoader.save_reply_ranking_score(..., ReplyRankingScore(score=0.0, reasoning=reasoning[-500:])). The score is forced to 0.0 (lowest confidence), and only the last 500 characters of the reasoning are kept.
  • It emits to v2 Kafka with save_reply_ranking_kafka_v2(..., ReplyRankingScoreKafka(...)).
  • DISABLE_RULES = [DisableTaskForNonProd] skips the write in non-prod. That is, no real label is applied in dev or staging.

The Plan DAG (PlanMultiStepReplySpam) chains tasks in the order filter, media hydration, detection, write verified:

task_multi_step_reply_spam_filter →
  task_media_hydration →
    task_multi_step_reply_spam_detection →
      task_write_multi_step_reply_spam_reply_ranking

The point to notice is that TaskMediaHydration runs only after the filter passes. Media hydration is costly, so it runs only after the filter has cut out a substantial share.

The new Plan joins two existing streams. Both stream generators add this Plan to their own PLANS_TO_INJECT set. One is ReplyRankingTaskGenerator (kafka TOPIC_UNIFIED_POSTS_V3), and the other is ReplyRankingRecoveryTaskGenerator. That is, this multi-step verdict runs in parallel from the same v3 event source, alongside the existing reply ranking, comment spam, and coordinated spam verdicts.

Why it matters This commit added a third axis to reply spam verdicts. That axis is the pattern of one author stealing root exposure through their own reply chain. The previous canon knew two flows. reply_spam judges individual replies, and coordinated_spam judges "repeated replies coordinated by multiple accounts". If coordinated spam is coordination on the account axis, multi-step is chain coordination on the time axis. Because the gating is set at a low threshold of 1k root followers (100k for the single-reply verdict), this pipeline appears to run broadly over threads whose root author has substantial exposure. It also fits the direction of running verdicts on Gemma to cut the grok API budget.

Some things are not yet public.

  • The system prompt original text (excluded from the export per the README)
  • The initial share of traffic on which this pipeline is active (the experiment gating is not in this diff)
  • Initial production metrics (measured values of counters such as multi_step_reply_spam.scored.count)
  • The tuning history of the gating constant (FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION = 1000)
  • How often the singleton drop actually fires (whether the LM tends to catch only the first seed of a chain)

B. PTOS SafetyPtosPolicyCrossValidator migrates the IllegalAndRegulatedBehaviors category from Grok 4.5 to Grok 4.1-fast + Gemma also assigned to first-pass (judge and primary axes move together)

The PTOS cross-validation judge starts using a different model per category with this commit verified. The SafetyPtosPolicyCrossValidator introduced in Gen4+7 (d011592) was a single-judge stack. It confirmed all three categories (ChildSafety, ViolentMedia, IllegalAndRegulatedBehaviors) on EAPI_GROK_4_5_X_ALGO. In this commit only IllegalAndRegulatedBehaviors switches its judge to a different model.

Grok 4.1-fast was registered as a judge verified — grox/flows/ptos/classifier.py:

eapi_4_1 = grox_config.get_eapi_model(ModelName.EAPI_GROK_4_1_FAST_X_ALGO)
self.eapi_4_1_x_algo = EapiSampler(EapiModelConfig(**eapi_4_1.model_dump()))

The primary breaker and sample lines of _validate_illegal_and_regulated_behaviors changed. Previously they were _eapi_4_5_x_algo_breaker.guard() and self.eapi_4_5_x_algo.sample(...); now it is self.eapi_4_1_x_algo.sample(...). That is, the judge model for this category migrated from 4.5-X_ALGO to 4.1-fast-X_ALGO. The disappearance of the circuit-breaker guard is also worth noting. 4.1-fast is called directly without a breaker, while 4.5 was wrapped in a breaker. The metric name also changed from ..._cross_model_validate_with_grok_4_5 to ..._cross_model_validate_with_grok_4_1.

Why it matters The code now states explicitly that judge models are assigned per category. ChildSafety and ViolentMedia still use 4.5-X_ALGO, and only IllegalAndRegulatedBehaviors switched to 4.1-fast. The policy is to tune the judge model separately according to each category's difficulty, latency requirement, and judge disagreement characteristics. Given the fast suffix in the name and the direct call without a breaker, 4.1-fast is presumably a model that responds faster than 4.5 and is tolerable. The X_ALGO suffix suggests fine-tuning on the x-algorithm domain inferred.

IllegalAndRegulatedBehaviors was also promoted to the Gemma path at first-pass verified:

USE_GEMMA_CATEGORIES = {
    SafetyPolicyCategory.Spam,
    SafetyPolicyCategory.IllegalAndRegulatedBehaviors,
}

Previously only Spam was there. Now Gemma runs as first-pass for two categories.

The probability dial gate of SafetyPtosPolicyClassifier (use_oai_gemma4_dial) was removed verified. The dial value was 1.0 from the Gen4 release until just before this commit (classifier.py:490, 715 at 85ac72a). Previously there was a probability gate: if sample_for_gemma and not self.deluxe and self.use_gemma and random.random() < self.use_oai_gemma4_dial:. This commit removed the gate entirely, and the condition became if sample_for_gemma: ... oai_gemma4.sample(...). That is, categories in USE_GEMMA_CATEGORIES are routed 100% to Gemma, and grok handles only the fallback. When Spam was first introduced as a Gemma candidate in Gen4+9 (45b48ba), it started with a probability dial and was promoted unconditionally 15 days later. This commit adds IllegalAndRegulatedBehaviors, so both categories are now 100% Gemma.

IllegalAndRegulatedBehaviors was added as a target of the classify_policy_for_violation cross-model reclassify verified — grox/flows/ptos/task_safety_ptos_policy.py:

elif violation.category in (
    SafetyPolicyCategory.ViolentMedia,
    SafetyPolicyCategory.IllegalAndRegulatedBehaviors,
):
    policy = await active_classifier.classify_policy_for_violation(post, violation)

Previously only ViolentMedia took this reclassification path. In other words, IllegalAndRegulatedBehaviors now has a three-layer stack in this order: first-pass Gemma, deluxe cross-model reclassification (which is costly), and the Grok 4.1-fast judge cross-validator.

Why it matters It becomes clear that the PTOS verdict layer is evolving separately per category. Spam has used Gemma first-pass since the Gen4 release (dial 1.0). ChildSafety and ViolentMedia have used the Grok 4.5-X_ALGO judge since Gen4+7. With this commit, IllegalAndRegulatedBehaviors has three layers: Gemma first-pass, the Grok 4.1-fast judge, and reclassification. The dual stack that tunes judge and primary models separately per category continues. The Gemma promotion appears to aim at saving grok budget, and the 4.1-fast judge is lower in latency and cost than the 4.5 judge, which fits the budget-management direction inferred.

C. SID retrieval sidecar removed entirely from the Phoenix serving engine — history SIDs now arrive in the request

The sidecar path through which the Phoenix serving engine looked up SIDs separately was deleted, and history SIDs now arrive in the request verified.

SID sidecar code was deleted from the serving engine and inference code verified — phoenix/crates/serving/xai-recsys-engine/:

  • src/sid_client.rs (−189 lines) had the SemanticIdClient Rust client and the Python binding (PySemanticIdClient) removed entirely.
  • pub mod sid_client; was removed from src/lib.rs.
  • The xai-recsys-sid-proto dependency was removed from Cargo.toml. The same applies to pyproject.toml.
  • The SID client field was removed from the RecsysPredictorImpl and RecsysRetrievalPredictorImpl structs in src/python.rs.
    • The removed field is sid_client: Option<Arc<crate::sid_client::SemanticIdClient>>.
    • The sid_client: Option<&crate::sid_client::PySemanticIdClient> argument that the server_impl macro took was also removed.
  • Five lines of PySemanticIdClient stubs were removed from xai_recsys_engine.pyi.
  • phoenix/xrex/inference/sid_retrieval_runner.py (−17 lines) was deleted completely. It was the Python entry point of the SID retrieval runner.
  • The --sid_endpoint CLI argument and the assignment runner.sid_endpoint = args.sid_endpoint were removed from phoenix/xrex/inference/launch_inference.py.
  • The sid_endpoint: str | None = None field was removed from phoenix/xrex/inference/model_runner.py.
    • The 12 lines in create_server that assembled sid_client = xai_recsys_engine.PySemanticIdClient(...) were also removed.

The semantics that replaced it show in the remaining log verified. The log reads "Parsing history SIDs from the request (sid_num_levels=%d); no sid_endpoint". That is, history SIDs now arrive directly in the request. Previously, when sid_endpoint was set, there was a sidecar path in which the serving engine called a separate SID service to look up the SID codeword for each post_id in the user history. This commit removed that path. It has now migrated to a contract in which upstream (home-mixer and others) already includes the SIDs when it builds the request.

The default of use_pinned_d2h also changed False → True. D2H transfers through CUDA pinned host memory run at ~50 GB/s, versus ~3 GB/s for the JAX default. This saves ~29ms per inference. The option was introduced in Gen4+6, and this commit changed its default.

Why it matters The structure of the Phoenix retrieval SID pipeline was substantially cleaned up. The previous canon knew that phoenix-rankall manages the SID index window (video_14day and others) in Gen4. It also knew that seven SID fields were connected to VMRanker in Gen4+3, and understood the serving engine to have an optional SID sidecar. This commit confirms that the SID sidecar is gone from the serving engine entirely and that SIDs are carried directly in the request. That is, responsibility for SID codeword lookup migrated out of online serving. home-mixer extracts SIDs in advance at the candidate hydration stage and puts them in the request, and phoenix-rankall consumes them as they are. One extra gRPC call per request disappears, and the fault domain of the SID infrastructure narrows.

Some things are not yet public.

  • The name and source of the hydrator that fills SIDs in home-mixer (or the candidate hydration layer)
  • SID codeword storage(Manhattan? Strato?)
  • The point in time of the migration (whether there was a dual-write period under an experiment)

D. H100 policy introduced in Phoenix training remat (first signal that training also runs on H100 besides GB300)

A value for H100 was newly added to the remat policy of Phoenix training verified. The evidence is the enum in phoenix/xrex/models/remat.py:

class RematType(enum.IntEnum):
    WHOLE = 0
    SAVE_GB300_RECSYS = 29
    SAVE_H100_RECSYS = 32

SAVE_H100_RECSYS was registered with the new value 32. An elif policy == RematType.SAVE_H100_RECSYS branch was added to custom_remat_policy(policy). This branch stores the same name list as GB300 as rematerialization checkpoint targets.

  • The name list is attn_outputs, dense_outputs, dense_outputs_individual, attn, gate_up_proj, dense_up_proj, query_heads_rope, key_heads_rope, cutedsl_attn_outputs, query_heads, key_heads, value_heads, and scalar_stats.

The two policies share the same name list but have separate enum values. This structure leaves open the possibility that different tuning (for example, presets for stored-tensor priority) will later be attached to GB300 and H100 separately.

Why it matters By stating the H100 remat policy in code, this commit left the first signal that X distributes training across two hardware generations, GB300 and H100. The previous canon confirmed that Phoenix production training runs on GB300 when _GB300_OVERRIDES appeared in Gen4+6. Whether H100 is used for training was not mentioned. GB300 is the flagship, and H100 may be for secondary, backfill, or research use inferred. A hardware-specific config block such as _H100_OVERRIDES does not yet exist in this commit. It is a checkpoint to watch in the next commit.

E. Phoenix ranker_attention_fa4 — native support for variable-length history + cache removal

The FA4 attention kernel now handles histories of differing lengths directly, without padding.

The FA4 kernel previously assumed that "history is always filled exactly to a multiple of the tile size (128)" verified — phoenix/xrex/cutedsl/ranker_attention_fa4.py. FA4 is flash attention v4 based on NVIDIA CUTLASS/CUTe DSL. The previous signature was build_dense_block_sparse_layout(batch_size, seq_len, hist_len, num_q_heads) (four arguments, including a batch argument). It is now (seq_len, hist_len, num_q_heads, hist_valid_len). The new argument hist_valid_len takes the actual number of valid history tokens for each item in the batch.

The computation moved from numpy to jax.numpy. Previously, import numpy as np was used to build the layout in advance on the python host and cache it (_DENSE_BS_LAYOUT_CACHE). This time it was rewritten in a JIT-friendly way with jnp.where, jnp.arange, and jnp.broadcast_to, and the cache was removed. The reason is clear. Because hist_valid_len differs per batch, the layout became a function of batch data rather than a deterministic function, and python host caching becomes meaningless.

New fields valid_block_upper and valid_block_lower were added. So that the attention mask can handle partial-history blocks, the upper and lower bounds are computed per batch and passed to the kernel. A partial-history block is a case where the number of actually valid tokens within a history tile is at most the tile size. is_partial = is_hist & (t == n_full) & (has_partial == 1) marks the position of the partial block. full_q_seq and mask_q_seq compute valid token positions as indices in the forward and backward passes respectively.

The signature of ranker_attention_fa4 widened. Two optional arguments, valid_block_upper and valid_block_lower, were added, and when no value is given they fall back to zero-fill. custom_vjp packs and unpacks them as bs_args = (*fwd_bs, *bwd_bs, valid_block_upper, valid_block_lower) so that the new arguments are also included in the gradient computation. input_output_aliases shifted from {12: 0, 13: 1, 14: 2} to {14: 0, 15: 1, 16: 2}. This is because the two new arguments were attached at the front.

Why it matters The FA4 kernel now handles varlen history directly, without needing padding. Previously, short histories (for example, new users) were padded up to the tile size, so the set of positions to attend to grew or the actual attention semantics were likely contaminated. This kernel handles upper and lower bound masking of partial blocks internally, so it computes exact attention without padding. Training and inference accuracy improve for new users and short sessions. In Gen4+6, attn_impl="cutedsl_ranker_varlen_attn" was the attention kernel of the GB300 override. We can understand that varlen semantics as having been extended to the history axis in this commit. A new observation is added to the attn kernel axis of sequence-transformer-ranking.

F. Large-scale simplification of visibility-filtering hydration and safety-label (net reduction of 800+ lines)

Code shrank substantially in the hydration and safety-label stack of visibility-filtering verified.

The net change per file is in the table below verified — xai-org/x-algorithm@9b0dc31:

File +/−
visibility-filtering/config.rs +7/−118
visibility-filtering/rules/registry.rs +8/−378
visibility-filtering/rules/tweet_rules.rs +50/−129
visibility-filtering/hydration/mod.rs +11/−90
visibility-filtering/hydration/fallback_cache.rs +25/−87
visibility-filtering/hydration/batch.rs 0/−19
visibility-filtering/hydration/gizmoduck_hydrator.rs +36/−42
visibility-filtering/hydration/tes_hydrator.rs +3/−33
visibility-filtering/get_safety_labels.rs 0/−40
visibility-filtering/safety_label_source/codec.rs +15/−206
visibility-filtering/safety_label_source/manhattan.rs 0/−10
visibility-filtering/models/safety_labels.rs +13/−19
visibility-filtering/filter_tweets.rs +17/−65
visibility-filtering/rules/author_rules.rs +3/−27

Because overall size shrank on net, this change is not a policy expansion but work that clears out safety nets, duplicated code, and over-designed layers. This commit continues the flow of the commits below in the same direction. It is a flow in which, after the rewrite stabilized, the fallback, duplicated, and over-designed code that had been bolted on is cleared out.

  • Removal of the media fallback cache in Gen4+9 (45b48ba)
  • Deletion of 128 lines of VMRanker in Gen4+8 (0d3cdd8)
  • Rules refactor in Gen4+11
  • Elimination of Box<dyn Rule> in Gen4+14 (85ac72a)

In the same commit, visibility-filtering/rules/fixtures.rs (+81) and golden_corpus.rs (+102) were expanded, so the regression safety net became thicker. It is a consistent pattern that keeps a balance between simplification and regression defense.

The next questions need a deep dive. In this daily we settle only up to the overview level.

  • Which wire format and label source the 206-line deletion of the safety_label_source codec removed
  • Which hydrator entries the 90-line deletion in hydration/mod.rs removed
  • Which config layers the 118-line deletion in config.rs unified

G. Brazil2026ElectionFilter maintenance + PR #88 (e4dcedd) VF in-network id dedup

The account list of the Brazil election filter changed in line with the routine update practice, and a PR from an external contributor was merged verified.

The Brazil filter file changed verified — home-mixer/filters/brazil_2026_election_filter.rs (+339/−4). The actual delta has the following three parts.

  • It added 166 hardcoded account IDs, so the set grew from 2,388 to 2,554 verified. Examples of added accounts are +@nanacachen, +@AecioNeves, +@lgmbrasilia, +@crismonteirosp, +@rodrigolimamdh, +@samiabomfim, and +@gabrielmagno_13. Accounts are written as an obfuscated user_id with the @username in a comment.
  • It introduced 3–4 entries marked "not the candidate's actual account".
  • It updated a small number of "no live account found" entries.

This is an observation that the routine update practice of Gen4+8 (0d3cdd8) continues. The file location is still home-mixer/filters/, and the AllowFollowedAuthors exception is also unchanged. The canon's observation is that this practice keeps appearing as a routine commit signal.

PR #88 (e4dcedd, 2026-09-03 20:28 UTC) was merged verified — xai-org/x-algorithm@e4dcedd. It is a PR from an external contributor (joshs1017dev) that added two lines to home-mixer/candidate_hydrators/vf_candidate_hydrator.rs. It is a single performance optimization that dedups in_network_ids before the VF lookup. The practice of merging external contributor PRs is itself an object of the canon's observation. It is evidence that the open-source stance of Gen4 actually accepts external PRs.

Gen4+16 (2026-09-04, 902a06f) — symmetric wiring of codivert impressions in the cold-start experiment + treatment viewers restricted to Phoenix MoE candidates + experiment_overrides map and SlateContext.exact_k/exact_gap added to the Phoenix serving contract + devel app_env added to VF dark-traffic mirroring + upcoming VF muted-keyword rule signal

The control and treatment arms of the cold-start experiment now leave the same bucket impressions, and cold-start candidates for treatment viewers were narrowed to Phoenix MoE candidates verified. Two fields were added to the Phoenix serving request: a per-request experiment param override and slate metrics verified (daily).

One commit changed three axes in an interlocking way verified — xai-org/x-algorithm@902a06f. The scale is 25 files, +472/−179.

  • AuthorColdStart::apply reordered its execution order. Now the control and treatment arms leave experiment bucket impressions symmetrically (the codivert pattern).
  • Fields were added to the Phoenix serving request contract. experiment_overrides: map<string, string> is a per-request experiment param override, and SlateContext.exact_k and exact_gap are two optional uint32 metric fields.
  • VF dark-traffic mirroring now recognizes the devel app_env (per-PR preview deployments) in addition to staging.

In addition, CoreFeature.text was explicitly preserved as dead code for the upcoming muted-keyword rule. In the VF stack, a large-scale cleanup from pub to pub(crate) took place.

A. The two arms of the cold-start experiment leave codivert impressions symmetrically verified — home-mixer/scorers/author_cold_start.rs, home-mixer/params/param.rs.

The new params are rust_home_mixer_phoenix_moe_codivert_viewer_is_control and rust_home_mixer_phoenix_moe_codivert_viewer_is_treatment (bool, default false). A symmetric YAML rule was created for the moe_codivert_viewer experiment. The treatment and control arms split symmetrically by enable_viewer_cold_start_boost=true/false. The core reordering is the following sequence.

  • First, ColdStartParams::read(query) bundles the param lookup into one step, and the viewer_arm bucket impression is always left.
  • Next, record_tracked_ids is called.
  • Then, if params.arm != Holdout, author_corpus(...) is called regardless of whether the feature is enabled, leaving the author bucket impression.
  • Only after that does it return early on the enabled gating.

Because it returns after leaving the impressions, the observation log shape of the two arms becomes symmetric. The previous version returned early before leaving impressions when !EnableViewerColdStart.

New regression tests lock in this symmetry.

  • both_arms_log_the_same_codivert_impressions verifies that control and treatment each leave exactly 3 moe_codivert_viewer impressions.
  • both_viewer_arms_log_the_same_author_impressions verifies that both arms leave exactly 2 moe_exp (author-targeted) impressions.

Impression counts are instrumented with the test utility SpyingBucketImpressor (exposed by the experiment SDK). It is a culture observation that X enforces A/B bucket observation symmetry through regression tests.

B. The cold-start treatment candidate filter was narrowed to apply only to Phoenix MoE candidates verified — same file:

ViewerArm::Treatment => corpus == AuthorCorpus::Treatment && is_phoenix_moe(c),

Previously it checked only corpus == AuthorCorpus::Treatment. This time the condition && is_phoenix_moe(c) was added, and this condition means served_type == ForYouPhoenixRetrievalMoe. Now the cold-start candidates of a treatment viewer are only those that are posts from treatment corpus authors and also come from the Phoenix MoE source. The test name also changed from treatment_viewer_keeps_treatment_moe_and_cold_starts_treatment_corpus to treatment_viewer_cold_starts_treatment_moe_only. Cold start and Phoenix MoE are now bundled in a single codivert experiment, isolating the two effects so that they are not confounded.

C. Engagement counts hydration was separated from cold-start gating verified — home-mixer/candidate_hydrators/engagement_counts_hydrator.rs. enable() drops its dependency on EnableViewerColdStart and is decided only by EnableEngagementCountsHydration || is_shadow_traffic. The !query.has_cached_posts gating was also removed, so it refreshes even when a cache exists. The tight coupling of the Gen4+1 initial wiring, in which cold-start Thompson sampling depends on view_count_on_home and fav_count, was dismantled.

D. The Phoenix serving request and response contract was expanded verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto.

  • PredictNextActionsRequest.experiment_overrides = 21 (map<string, string>) overrides experiment parameters per request. This field pairs with the Gen4+16 codivert wiring (A).
    • Once home-mixer settles the experiment assignment upstream, it passes that decision on to Phoenix as is, keeping the downstream experiment scope consistent with upstream.
  • SlateContext.exact_k = 16 and exact_gap = 17 (optional uint32) were added. The exact family joins the SID family (Gen4+3) and the recon family (Gen4+9 to +11). We infer, from the names, that they are slot-neighbor metrics based on exact matches inferred.
    • On the home-mixer side as well, corresponding fields were added to both the SlateContext struct in home-mixer/models/candidate.rs and the From<xai_recsys_proto::SlateContext> conversion impl.

E. VF dark-traffic mirroring widened to the devel app_env verified — visibility-filtering/dark_traffic_setup.rs. parse_staging_listener was renamed parse_mirror_listener and iterates over the two values STAGING_APP_ENV and DEVEL_APP_ENV. The devel TLS domain is visibility.xai-vf-service.devel.{dc}.s2s.twttr.net, which differs from staging. MirrorChannelFactory picks the devel factory if the listener's xds_dest contains .devel., and the staging factory otherwise.

  • As an example that passes, xai-vf-service.devel.visibility:grpc becomes xai-vf-service.devel. xai-vf-service-pr-123.devel.visibility:grpc becomes xai-vf-service-pr-123.devel.
    • This suggests a practice of preview deployments based on GitHub PR numbers inferred.
  • The reject cases are also strict. Similar names such as development, staging-devel, devel-staging, grpc-extra, and grpc.other are explicitly rejected.

F. There is a signal that a muted-keyword rule is planned for VF verified — visibility-filtering/models/tweet.rs:

#[cfg_attr(
    not(test),
    expect(dead_code, reason = "retained for the upcoming muted-keyword rule")
)]
pub text: String,

CoreFeature.text in the VF service is currently consumed by no rule, but it was explicitly preserved with a dead-code suppression attribute. The new test assemble_hydrates_text_from_core_data locks in, as a regression test, that text is filled on the hydration path. The Following-only FollowingViewerMutedKeywordFilter of Gen4+4 is slated to expand across the whole VF service this time. Whether it unifies the Following and For You pipelines or keeps two parallel layers is a checkpoint to watch in the next commit.

G. A large-scale cleanup narrowed pub to pub(crate) in VF verified — visibility-filtering/lib.rs. The clients, hydration, models, rules, safety_label_source, and twemcache modules were narrowed to crate-internal. In config.rs, params.rs, and server.rs, several pub functions and constants were changed to pub(crate) or removed as dead code. The DEFAULT_REQUEST_TIMEOUT and DEFAULT_CONNECT_TIMEOUT constants in twemcache/client.rs were also removed. The direction is the same as the 800-line net reduction of Gen4+15 (9b0dc31). After the rewrite stabilized, the flow is to narrow APIs that no longer need to be exposed externally.

Other minor changes are as follows.

  • The KMS unwrap of the checkpoint DEK retries transient failures verified — phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py.
    • _unwrap_with_backoff retries KMS unwrap failures (429, 502, 503, 504, and the "KMS transport" marker) with jittered exponential backoff, 4 times, up to a total cap of 2 seconds.
    • assert_all_enveloped passes only if the _DEK master file exists before finalize.
    • It is a stabilization follow-up to the Gen4+13 envelope-encrypted Orbax writer.
  • retrieval_dataset.py was refactored for DRY verified. The SID snapshot window paths were unified into the helper _sid_window(filename) -> (primary, backup), and the actual paths remain post_sid_v8_256x6_snapshots and _backup.
  • The timestamp of the StableHLO bundle manifest is refreshed verified — phoenix/xrex/train/recsys_bundle_export.py.
    • restamp_manifest(data) was introduced and rewrites the created_timestamp of MANIFEST.json to the export-time value.
    • It is wiring for fresh-detection of the .mlirbc bundle introduced in Gen4+13.
  • The backbone score is downcast to bfloat16 verified — phoenix/xrex/train/trainer_recsys.py.
    • forward_fn round-trips log_probs and cont_preds through .astype(bfloat16).astype(float32).
    • It aligns the numerics observed during training with serving precision (presumably bfloat16). It pairs with the Gen4+14 backbone scores request semantics.
  • log_util does structured logging verified — phoenix/xrex/utils/log_util.py.
    • It automatically appends the extras of LogRecord.__dict__ to the log line as key=value. The event=X extra is placed first.
  • The borrow in mutual_follow_stats_side_effect.rs was cleaned up verified. The PhoenixRetrievalMOEInferenceClusterId lookup moved outside the moe_enabled condition, which tidied the lifetime.

Some things are not yet public.

  • The initial active traffic share of the moe_codivert_viewer experiment
  • How Phoenix serving actually parses the experiment_overrides map and which layer's params it overrides
  • The exact definition of exact_k/exact_gap (the code that fills them in Phoenix serving is not in the open source)
  • When the muted-keyword rule in VF actually appears

Gen4+17 (2026-09-08, 49815da) — ranking scorer per-head weight perturbation + grox v8-search multimodal embedding stack introduced + ContentFeatures enters the Phoenix wire + PTOS adult leading-frames rescan path + FA4 attention kernel SM80·SM90 forward·backward fully open-sourced

The ranking side gained a mechanism that perturbs per-behavior weights on every request, but the default perturbation magnitude is 0.0, and the post information the ranker receives gained a content-features field verified (items A and C). The safety verdict gained a path that re-examines the first 10 seconds of videos of 30 seconds or longer that have already passed through PTOS, and a search-only embedding pipeline and the release of attention kernel backward code are in the same commit verified (items B, D, E). This commit is covered in the daily.

One commit moved on five axes at once verified — xai-org/x-algorithm@49815da. It changed 78 files, with +10,938/−982 lines.

  • The ranking scorer perturbs the weights of 26 engagement heads on every request by exp(±sigma). The newly added ScoringWeights::perturbed() in home-mixer/scorers/ranking_scorer.rs does this, and the sign is set deterministically by md5(salt:user_id:head).
  • A v8-search embedding stack dedicated to the search surface was added under grox/flows/mm_emb/. It adds 12 new files (+1,026 lines) and modifies 5 existing files (+171 lines).
  • The Phoenix serving proto gained a ContentFeatures message and the TweetInfo.contentFeatures and quotedContentFeatures fields. The values are built by the new file home-mixer/models/content_features.rs (185 new lines) using Twitter's weighted-text rules.
  • grox/flows/ptos/ gained an 8-stage DAG PlanSafetyPtosAdultContentLeadingFrames and an entry filter, which crop the first 10 seconds of a video and re-judge it. To enter, a video must be 30 seconds or longer, must already have passed PTOS without an NSFW verdict, and must also exceed the deluxe fav threshold.
  • phoenix/xrex/cutedsl/ranker_fa4/ gained 6 new files (7,080 lines in total): flash_fwd, flash_fwd_sm90, flash_bwd, flash_bwd_sm90, flash_bwd_preprocess, and ampere_helpers. X.AI released in full its modified SM80/SM90 forward and backward kernels, forked from the original Dao-AILab flash-attention, and the original backward pass is released for the first time here.

Beyond that, phoenix/xrex/utils/aot.py sanitizes CutlassCall device blobs into SHA256 digests to stabilize the AOT cache. copy_port_client.rs checks for MANIFEST.json and picks only checkpoints whose bundling is finished, and quote_hydrator.rs gained parallel MediaInfoCacheClient hydration.

A. The ranking scorer perturbs per-head weights on every request (per-head weight perturbation) verified — home-mixer/scorers/ranking_scorer.rs, home-mixer/params/param.rs.

There are two new params. WeightPerturbationSigma is an f64 with a default of 0.0. WeightPerturbationSalt is a String with a default of "". .perturbed(query) is chained after ScoringWeights::from_params(&query.params). On each request, if sigma > 0, each of the 26 heads is scaled by multiplying weight *= (sigma * perturbation_sign(salt, user_id, head)).exp(). perturbation_sign is a deterministic ±1 set by md5(format!("{salt}:{user_id}:{head}"))[0] & 1. negative_sum, positive_sum, and total_sum are recomputed from the perturbed values in recompute_sums().

The target heads number 26 in total.

  • 18 core positive: favorite·reply·retweet·photo_expand·video_open·click·open_link·profile_click·vqv·share·share_via_dm·share_via_copy_link·dwell·quote·quoted_click·quoted_vqv·follow_author·post_unexplored
  • 3 continuous: dwell_time·click_dwell_time·active_secs_5m_residual_norm
  • 5 negative: not_interested·block_author·mute_author·report·not_dwelled

The sync timestamp in the param.rs header changed to 2026-09-04T16:22:24Z → 2026-09-08T16:24:06Z.

For the same (salt, user_id), the 26 heads get independent signs, and changing the salt reshuffles the per-user assignment entirely. At σ=0.1 the weights are perturbed by about ±10.5%, and at σ=0.3 by about ±35%. This is multiplicative noise that is symmetric in log-space. The mechanism can be read as Bandit-style exploration (gradient-free) or as infrastructure for measuring A/B weight sensitivity on production traffic inferred. Because the default is 0.0, default production behavior is still deterministic.

B. grox gained v8-search, a multimodal embedding stack dedicated to the search surface verified — grox/flows/mm_emb/.

  • There are four Plans:
    • mm_emb_v8_search: real-time, grox-multimodal-embedding-requests-with-summary topic
    • mm_emb_v8_search_for_reply: for replies, content-understanding-...-min-traction-for-grox-multi-modal topic
    • mm_emb_v8_search_backfill and mm_emb_v8_search_backfill_batch: backfill of past posts, grox-multimodal-embedding-backfill topic
  • The embedder is embedder_v8_search.py (113 lines). It looks up the two endpoints RECSYS_V8_SEARCH_EMBED and RECSYS_V8_SEARCH_BACKFILL_EMBED through grox_config.get_embedding_model. It calls embed_openai_async(text, images) through XaiEmbeddingClientHttp (an OpenAI-compatible embed API), with a timeout of 60 seconds. The result is re-normalized with L2. The author's core information is fetched in parallel with StratoUserCore().fetch(user_id) and passed to the renderer.
  • The renderer is renderer_v8_search.py (461 lines). The media budget is 16 frames counting the original and quoted media together (MAX_MEDIA=16), and the number of frames the embedder actually requests is MAX_FRAMES=4. The rendered result consists of the elements below:
    • instruction prefix "Represent this X post (formerly Twitter) for recommendation: capture its topic, intent, key entities, and likely engagement."
    • author block: the follower count in two forms, a human-readable notation and a tier label
    • body: up to 4000 characters, with dangling media URLs removed
    • quoted post (1000 characters), ASR (2000 characters), card title (100 characters), article title (200 characters)
  • The fav threshold for backfill adjusts itself according to throughput. The implementation is adaptive_min_fav.py (63 lines).
    • The controller is AdaptiveMinFav(start=20000, target_qps=5.0, gain=0.3, deadband=0.1, window_s=30.0, floor=0, cap=20000, warmup_s=60.0).
    • This PI controller observes the actual pass QPS and adjusts the fav threshold every 30-second window. It raises the threshold when the pass QPS is above the target and lowers it when it is below.
    • It is a pure PI controller with no side conditions. error = (qps - target) / target, and if |error| ≤ deadband it makes no adjustment. Otherwise it updates with min_fav *= 1 + gain * clamp(error, -1, 1).
    • TaskBackfillFavGate throttles posts with an insufficient fav count, observing them as task.backfill_gate.dropped.count{reason=low_favs}.
    • TaskBackfillPreloadGate skips posts that are already embedded with V8_SEARCH_MODEL_VERSION = "v8_search_1". Only posts that pass are counted in the rate calculation through _min_fav.observe_pass().
  • The fanout is handled by task_backfill_fanout.py (85 lines). BackfillIdBatch.post_ids (a byte-packed <q list in the kafka value) first goes through BackfillConsumerDedup to check whether it is seen for the first time. Then fav counts are hydrated in parallel by BatchedFavHydrator, and Plans run in parallel under the cap of asyncio.Semaphore(FANOUT_CONCURRENCY=240). Exceeding task_timeout raises TimeoutError, and results are recorded as task.backfill_fanout.posts.count{result=executed|failed}.

This stack attaches a vector space dedicated to the search surface, separate from v5 and v8.2. It pairs with the Phoenix xrecsys_search config of Gen4+4. With it, both the search ranking model and its paired search document embedding pipeline are in place. However, the actual model behind the two RECSYS_V8_SEARCH_EMBED endpoints (dim, base architecture, training data) and the connection to the retrieval index are not public.

C. ContentFeatures entered the Phoenix wire format verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto, home-mixer/models/content_features.rs (185 new lines).

message TweetInfo {
  // ...
  ContentFeatures contentFeatures = 36;
  ContentFeatures quotedContentFeatures = 37;
}
message ContentFeatures {
  bool hasVideo = 1;
  uint32 maxVideoDurationMs = 2;
  bool hasPhoto = 3;
  uint32 mediaCount = 4;
  uint32 weightedTextLen = 5;
  uint32 newlineCount = 6;
  bool hasUrl = 7;
}

The builders build(candidate: &PostCandidate) -> ContentFeatures and build_quoted(...) are pure functions. TextMetrics::of(text, attachment_urls) follows Twitter's text length counting rules as they are.

  • A URL always counts as 23 characters (URL_WEIGHTED_LEN). This corresponds to the t.co shortener.
  • CJK characters, emoji bases, and punctuation count as 2 characters (outside SINGLE_WEIGHT_CODEPOINT_RANGES).
  • ASCII, basic Latin, and certain ranges count as 1 character.
  • ZWJ and skin-tone modifiers (0xFE0E·0xFE0F·0x1F3FB..0x1F3FF·0xE0020..0xE007F) count as 0 characters.
  • The first media attachment URL is subtracted from the external URL count (external_urls = urls.saturating_sub(attachment_urls)).

Five regression tests pin down these rules:

  • weighted("hello world")=11
  • weighted("日本語")=6
  • weighted("see https://t.co/abc123 now")=3+1+23+1+3
  • weighted("ok 👍🏽")=2+1+2: the skin-tone modifier is 0 characters.
  • weighted("日本\u{3000}語")=4+2+2: the ideographic space also counts as CJK.

media_attachment_url_is_not_an_external_link pins down the distinction between a media URL and an external link.

Why it matters The ranker receives structural signals of the text itself on the wire as first-class inputs. weighted_text_len is a signal for "how full it is / whether it is a pass-through with only a URL attached", newline_count for formatting, and has_url for external navigation. However, this commit has no code in phoenix/xrex/models/recsys_model.py that embeds these fields. They are carried on the wire, but whether training consumes them is something to observe in the next commit.

D. PTOS gained a leading-frames deep rescan path that re-judges whether the beginning of a video contains adult content verified — grox/flows/ptos/plan_safety_ptos_adult_content_leading_frames.py (56 lines), grox/flows/ptos/task_safety_ptos_adult_content_leading_frames_filter.py (81 lines), grox/flows/ptos/constants.py (+15).

PlanSafetyPtosAdultContentLeadingFrames is an 8-stage DAG in the order rate_limit → leading_frames_filter → media_hydration → category_detection → policy_detection → safemodel_sex_nudity → cross_validation → sink_write. The entry filter is a 6-stage gate.

  1. post.user must exist.
  2. post.get_fav_count() >= deluxe_fav_count_threshold must hold. This is the deluxe tier threshold introduced in Gen4+12, for cost control.
  3. post.media or post.quoted_post.media must contain a video of 30 seconds or longer (ADULT_CONTENT_LEADING_FRAMES_MIN_DURATION_SECONDS = 30.0).
  4. A prior PTOS verdict must exist (StratoSafetyPostAnnotationsResultDirectMh.fetch(post_id) is non-null). Only posts that have already passed through the PTOS pipeline once are eligible.
  5. The prior verdict must not be NSFW (prior.safetyBoolMetadata.isNsfw is false).
  6. The prior must have an AdultContent violated policy, or the fav count must be 512 or more (ADULT_CONTENT_LEADING_FRAMES_UNCONDITIONAL_FAV).

For videos that pass, video.crop_seconds = 10.0 (ADULT_CONTENT_LEADING_FRAMES_CROP_SECONDS) is set, so media hydration crops only the first 10 seconds for judgment.

The deluxe tier task types are DELUXE_TIER_TASK_TYPES = frozenset({SAFETY_PTOS_DELUXE, SAFETY_PTOS_SPECIAL_VIDEO, SAFETY_PTOS_ADULT_CONTENT_LEADING_FRAMES}). The new kafka topic is safety-ptos-adult-content-leading-frames.

Why it matters This path follows the special-video path of Gen4+12 and the 3-category cross-validator of Gen4+7, and is the third defense layer aimed at temporal evasion that disguises the first few seconds. The special-video path counters static-overlay evasion, and the cross-validator allows only demotion. The reason isNsfw is false even though the prior has an AdultContent violated policy is that the cross-validator allows only demotion. This path is a second judge that can overturn a demoted verdict again. The unconditional-entry threshold of 512 fav is the scale of a mid-popularity post in production.

E. The SM80·SM90 forward·backward of the Phoenix FA4 attention kernel were fully open-sourced verified — phoenix/xrex/cutedsl/ranker_fa4/.

The changes are 6 new files and 4 modified files.

File Status Lines Role
flash_fwd.py added 1,333 SM80/Ampere forward
flash_fwd_sm90.py added 1,465 SM90/Hopper forward
flash_bwd.py added 1,954 SM80/Ampere backward
flash_bwd_sm90.py added 1,864 SM90/Hopper backward
flash_bwd_preprocess.py added 329 pre-bwd dO·row_sum·softmax_LSE
ampere_helpers.py added 135 SM80 utilities (warp shuffle, register layout)
block_sparse_utils.py modified +412/−314 Rewrite of the block-sparse iterator
ranker_attention_fa4.py modified +273/−130 Refactor of the fwd entry point
ranker_attention_varlen_fa4.py modified +178/−211 Migration and cleanup of Gen4+15 varlen support
mask.py modified +63/−1 AttentionMask extension (for bwd)

The newly added code totals 7,080 lines verified — sum of the per-file added lines in 49815da. The file headers read # Derived from flash-attention (https://github.com/Dao-AILab/flash-attention); modified by X.AI Corp. and SPDX-License-Identifier: Apache-2.0 Copyright 2026 X.AI Corp.. What was open-sourced this time is X.AI's modified fork based on the original Dao-AILab/flash-attention. The phoenix/NOTICE file also gained +198 lines of copyright notices for the flash-attention subcomponents.

The main features of the kernels are as follows:

  • It is based on CuTeDSL (the cutlass Python DSL).
  • It supports asymmetric attention with head_dim != head_dim_v.
  • It supports pack_gqa (Grouped Query Attention).
  • It has the options is_causal, is_local, score_mod, mask_mod, and has_aux_tensors.
  • There are two schedulers, SingleTileScheduler and SingleTileVarlenScheduler (fixed and varlen).

The backward settings are m_block_size=64, n_block_size=128, num_stages_Q=2, num_stages_dO=2, and num_threads=256. The _bs_bwd_seq_at constexpr subtile maps the domain indices of the mask, full, and diag iterators of block-sparse backward. flash_bwd_preprocess.py precomputes dO·row_sum using the softmax_LSE (log-sum-exp) saved in forward.

Why it matters:

  • The original backward pass was open-sourced for the first time. Gen4+15 released the rewrite of forward varlen support, but backward is first here. Backward, which is essential for training, existed only in X's internal repository, and the open-source mirror had released only forward.
  • The wiring on the SM90 (H100) side is confirmed. The SAVE_H100_RECSYS = 32 remat policy in Gen4+15 was "the first code signal that H100 training is running". This commit explicitly contains two SM90 files for forward and backward, so the wiring on the H100 training side is complete. X maintains attention kernels optimized separately for two hardware generations, GB300 (SM100) and H100 (SM90).
  • X chose a strategy of reusing the flash-attention upstream. Rather than writing CuTeDSL from scratch, it forks and modifies the Dao-AILab original. This is a signal that the attention kernel is standardized infrastructure verified by the LLM community, and that X has not departed from that standard.

Not public are the benchmarks of the SM90/SM80 kernels (GB/s, TFLOPS, speedup over baseline) and where the sparse patterns of block_sparse_utils.py are actually used.

Other changes

  • Replaced the CutlassCall device blob with a SHA256 digest, so the JAX AOT cache has a stable cache key for the same CUDA kernel binary and avoids repeated recompilation verified — phoenix/xrex/utils/aot.py (+54/−1).
    • _sanitize_cutlass_calls replaces the module field in the backend_config of the StableHLO stablehlo.custom_call{target=CuteDSLRT_NvJaxCutlassCall} with a SHA256 digest (device_digest:<hex>). This field is an ELF/fatbin byte sequence.
    • cutlass_module_device_digest parses the ELF header and hashes only the device blob section.
    • AOT_CACHE_VERSION also changed "20260705" → "20260904".
  • Serving-side checkpoint discovery now picks only checkpoints whose bundling is finished verified — phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs (+207/−9).
    • full_prefixes_newest_first was split out as a helper that returns the prefixes present across the whole channel, newest first.
    • select_bundle_checkpoint picks the newest checkpoint for which export/MANIFEST.json is non-empty on every channel. A newest checkpoint that is still unbundled is skipped with a warn log.
    • download_named_files downloads a specified list of file names atomically.
    • The .mlirbc bundle system (StableHLO export) of Gen4+13 is now fully connected in the serving loader as well.
  • The serving sequence_len is now computed automatically verified — phoenix/xrex/train/recsys_bundle_export.py (+48).
    • _serving_sequence_len back-computes sequence_len - history_seq_len - candidate_seq_len * (1 + num_negatives) - num_global_negatives = prefix_tokens from the training config and checks that it matches num_user_prefix_tokens.
    • Then it overrides the attention kernel size with the serving sequence_len = prefix + history + candidate.
    • model_sequence_len was also added to the MANIFEST.
  • quote_hydrator.rs was extended verified (+142/−12).
    • MediaInfoCacheClient is injected as a new dependency.
    • get_quoted_content looks up the core data and media info of the quoted post in parallel within a 200ms timeout, and caches them in QuickCache<u64, QuotedContent>.
    • This extension is an upstream condition for wiring up ContentFeatures.quotedContentFeatures in Gen4+17.
  • The media info lookup in media_info_hydrator.rs was refactored, and max_video_duration_ms and the like needed for the content features computation are hydrated here verified (+70/−15).
  • The entire file home-mixer/bin/pipeline_components_json.rs was deleted (76 lines) verified. It was likely an offline debugging JSON dump utility inferred.
  • The PTOS reclassification trigger was extended verified.
    • In task_safety_ptos_policy.py (+4/−1), policy detection also handles re-judgment from the leading-frames path.
    • TaskRateLimitSafetyPtosAdultContentLeadingFrames was added to task_rate_limit.py.
  • The Pipeline core signatures were cleaned up in 8 candidate-pipeline files, with no semantic change verified (+103/−38).
  • The proto in VF safety_label_source/proto.rs was slightly expanded verified (+34/−38).
  • image.py (+42/−19), subtitles.py (+3/−1), and video_frames.py (+7/−7) in video_tools were slightly modified verified.
  • A new API was added to the VF client in visibility-filtering-client/vf_client.rs verified (+119/0).
  • The grox reply_spam code was cleaned up verified.
    • This was a small touch-up of the multi-step reply spam pipeline introduced in Gen4+15, and the removed lines are in task_write.py (−17) and task_multi_step_reply_spam.py (−9).

Five things are not public.

  • the actual production σ value of the weight perturbation and the salt assignment logic
  • the dim, base model, and retrieval index connection of the v8-search embedding
  • which ranking heads actually use ContentFeatures, and how
  • benchmark figures for the FA4 SM80/SM90 kernels
  • the actual reclassification rate of the leading-frames path

Gen4+18 (2026-09-09, 75d93d9) — VF verdict axis refactor (FilteredReason → Action) + new ad format header-image-prompt-ad + grox preview thumbnail for videos over 30 seconds + broadcast formally enrolled in the PTOS safety pipeline + all multimodal embeddings disabled in the ranking training config

Starting with this commit, visibility-filtering filters candidates by an explicit action value instead of the reason they were filtered out, and the home timeline gained a sixth ad format verified (items A and B). Live broadcasts entered the PTOS safety pipeline, and multimodal post embeddings were turned off in both ranking training configs verified (items D and E). This commit is covered in the daily.

This commit is about +801/−252 lines across 28 files, and five axes moved together verified — xai-org/x-algorithm@75d93d9.

A. The criterion of the VF verdict changed from FilteredReason to Action

TweetVisibility now has an action: Action field as a formal member, in addition to the existing reason: Option<FilteredReason> and safety_labels. The Action variants are Allow, Interstitial, Avoid, Downrank, Drop(DropReason), Tombstone, and NotEvaluated. StratoVfClient derives the action from the FilteredReason received from Strato through action_from_strato_reason. There are four derivation rules:

  • None → Allow
  • Some(SafetyResult) with NotEvaluated → Allow
  • Some(SafetyResult) with other → unchanged
  • Some(other reason) → Drop(DropReason{})

The gRPC path maps the result.action returned by the server as it is, and sets NotEvaluated if it is absent. PostCandidate has a visibility_action: Option<Action> field, and VFFilter::filter splits candidates with should_drop_action (see line 392 in the ranking and filter section above). should_drop_ancillary also decides based on the action of ancestor, quoted, and retweeted posts. FilteredReason::ViewerBlocksAuthor was added as a formal variant verified. It covers the case where the viewer blocks the author, and is the symmetric direction of the existing AuthorBlockViewer. However, no rule code returns this reason yet verified — visibility-filtering-client/vf_client.rs (+130/−62), visibility-filtering-client/models.rs (+7), home-mixer/filters/vf_filter.rs (+27/−69), home-mixer/candidate_hydrators/vf_candidate_hydrator.rs (+159/−23).

⚠ Canon claim updated. The previous canon said: "VFFilter is fail-closed. Every FilteredReason variant other than SafetyResult is an unconditional drop." The new verdict function has entirely different syntax, but the fail-closed character is retained. Non-SafetyResult reasons are already converted to Drop at the client layer, and NotEvaluated is also a drop. Only one thing changed. Downrank and Avoid became explicit keep targets. Previously this was ambiguous depending on the path.

B. The home timeline gained a sixth ad format, header-image-prompt-ad

It follows the existing five formats (promoted-tweet · preroll video · SSP/RTB Trend Takeover · rtb-image-ad · GoogleSDKNativeAd). At the very start, marshal_ad() checks whether AdIndexInfo.header_image_prompt_ad_info: Option<HeaderImagePromptAdInfo> is present, and if so branches to marshal_header_image_prompt_ad. This path marshals the ad as the URT TimelineEntryContent::Item(TimelineItem { content: TimelineItemContent::Message(MessagePrompt { content: MessageContent::HeaderImagePrompt(HeaderImagePrompt { .. }) }) }). The Entry ID format is header-image-prompt-ad-<impression_hex>.

The structure of HeaderImagePrompt consists of the fields below:

  • header_image: MessageImage
  • header_text, body_text
  • header_rich_text (RichText, no entities), body_rich_text
  • primary_button_action: MessageTextAction
  • secondary_button_action: Option<MessageTextAction>

Each button has its own ClientEventInfo (action = "primary_cta" or "secondary_cta") and on_click_callbacks: Option<Vec<Callback>>. The secondary button is attached only when secondary_button_text is non-empty. This format is the first case of an ad on the home timeline that uses the URT MessageContent type, and also the first ad format that supports a secondary CTA. Three tests pin down this contract verified — home-mixer/util/urt/ad_marshaller.rs (+258/−3).

  • header_image_prompt_ad_marshals_to_message_prompt_entry
  • header_image_prompt_ad_emits_secondary_button_when_set
  • header_image_prompt_ad_info_absent_stays_a_tweet_item

C. grox attaches a preview thumbnail to videos longer than 30 seconds

The Video pydantic model gained a preview_image: bytes | None field. The new constants are _VIDEO_PREVIEW_MIN_DURATION_MS = 30_000 and PREVIEW_IMAGE_LABEL = "[Video Preview Thumbnail]". hydrate_video_preview resizes the first frame of a video that passes the 4 gates with resize_tile(bs, tile_size) and stores it. The 4 gates are: convo_video exists, url exists, videoInfo exists, and duration > 30s. The tile size is deluxe_image_tile_size if the video is deluxe (= main_post ∧ is_high_fav), and image_tile_size otherwise. In the LLM input, interleave_storyboard puts the preview before the storyboard as the three elements [LABEL, image_bytes, "\n"] verified. The _image_part chat marshaling also attaches the label text and the image part before the storyboard in the same order verified — grox/core/data_loaders/media_loader.py (+100/−19), grox/core/lm/convo.py (+18).

D. Broadcast (live video) formally entered the PTOS safety pipeline

BroadcastLoader came into the grox media loader from broadcast_tools.broadcast. MediaLoader._get_broadcast_loader() lazily initializes the loader from the X_API_BEARER and BROADCAST_GUEST_TOKEN environment variables. _extract_broadcast_video(broadcast_metadata) extracts frames with BroadcastLoader.load_broadcast(broadcast_id, media_key, max_frames, tile_size, max_duration=crop_seconds). The extracted frames are normalized into Video(convo_video=ConvoVideo(frames, duration=average interval, total_duration=sampled_duration)).

The entry conditions of the PTOS leading-frames filter were restructured. Two of the 6 gates folded into the canon in Gen4+17 changed:

  • The deluxe fav gate (gate #2) was removed. Posts without a prior AdultContent verdict still need fav ≥ 512 (gate #6) to enter verified — task_safety_ptos_adult_content_leading_frames_filter.py:63-67.
  • The broadcast payload was formally added. The old code looked only at long_videos ≥ 30s and skipped with no_long_video if there were none. The new code also computes broadcasts = [p.broadcast_metadata for p in (post, post.quoted_post) if p and p.broadcast_metadata and p.broadcast_metadata.broadcast_id], and a post enters if either exists. At that point broadcast_metadata.crop_seconds = 10.0 is set, and eligible.count gets a media = "both" | "broadcast" | "video" tag.

After the change, there are 5 gates:

  1. post.user exists
  2. a video of 30 seconds or longer, or a broadcast_id, exists
  3. a prior PTOS verdict exists
  4. the prior verdict is not NSFW
  5. prior AdultContent violated policy or fav ≥ 512

Broadcast frames now formally enter the PTOS safemodel sex/nudity payload as well. It is gated by enable_local_broadcast_frame_extraction; when on, _iter_broadcast_videos(post) iterates over broadcast_metadata.video.convo_video.frames. The samples chosen by _sample_uniform(frames, _MAX_FRAMES_PER_VIDEO=8) are appended as ("broadcast_frame", frame) payloads. The constant _FRAME_KINDS = {"video_frame", "broadcast_frame"} makes both kinds count equally when computing has_video and n_video_frames verified — grox/flows/ptos/task_safety_ptos_adult_content_leading_frames_filter.py (+18/−8), grox/flows/ptos/task_safety_ptos_safemodel_sex_nudity.py (+21/−2).

E. Multimodal post embeddings are now turned off in all ranking training configs verified — phoenix/README.md (+3/−3).

The "Multimodal post embedding" row of the nano training config table changed from "v5 on xrecsys_seqpack; off on home_direct_packed" to "off (home_direct_packed and xrecsys_seqpack)". The accompanying note was also updated from "on the xrecsys_seqpack training config" to "and on every registered ranking config". "and on" is a grammatical elision, and read together with the table it means "off everywhere". In other words, multimodal post embeddings were dropped from the training inputs of both registered ranking training configs. The two configs are the flagship home_direct_packed and the seqpack xrecsys_seqpack. Read together with Gen4+17 (one day earlier, 2026-09-08) introducing the v8-search stack, this appears to be an intermediate state: v5 was removed from ranking, and v8-search is not yet connected to a ranking config inferred. We will watch whether v8-search enters a ranking config in the next commit.

F. Other changes

  • Fixed a NUMA CPU affinity bug verified — phoenix/xrex/utils/gpu.py (+32/−3).
    • Added the helpers _parse_cpulist and schedulable_cpus(*nodes) to phoenix/xrex/utils/gpu.py.
    • pin_process_to_local_numa runs numa.memory.set_membind_nodes(node) and then, with os.sched_setaffinity(0, schedulable_cpus(node)), schedules only the node's CPUs excluding isolated CPUs.
    • Previously it claimed the whole node with numa.schedule.run_on_nodes(node), so work could be scheduled onto isolated cores.
  • Deleted six log sites in candidate-pipeline that were gated by the quiet-spans feature verified — xai-org/x-algorithm/candidate-pipeline.
    • One #[cfg(feature = "quiet-spans")] tracing::info!(...) block each was removed from filter.rs, hydrator.rs, query_hydrator.rs, scorer.rs, selector.rs, and source.rs (−40 lines in total).
    • The error log string format also reverted from error!(component=…, error=%err, "source_failed") to error!("{} Failed: {}", self.name(), err). The structured fields are gone and the format regressed to a plain string.
  • The Phoenix inference code changed slightly. The changed lines are xrecsys.py (−1), model_runner.py (+2/−1), and recsys_model.py (+5). It may be code wiring up the consumption of Gen4+17's ContentFeatures, but the diagnosis of this commit alone cannot confirm it inferred.

Five things are not public.

  • Where the rule that actually returns Action::Downrank lives
  • How the rule that returns FilteredReason::ViewerBlocksAuthor is wired up
  • Which of the five ads blenders header-image-prompt-ad enters
  • When v8-search is connected to a ranking config
  • The actual scale of broadcast PTOS re-judgments

Gen4+19 (2026-09-10, fee1d0f) — VF hydration timeout unified (80/100 → 390 ms) + AuthorId fallback cache on TES failure + AuthorLabel 64-bit bitset + gRPC deadline observation + clock-cache primitive introduced (not wired) + new ProductSurface and UserAction constants + two-tower user tower padding segment ID + UTH per-country account takedown batch job

In this commit, the call timeout that VF applies at the Strato layer when calling TES and Gizmoduck grew from 100 ms and 80 ms, respectively, to a single 390 ms verified. Also, even if a TES lookup fails, author-based rules keep being evaluated as long as a cached author id remains, and author labels are reduced to the 11 used in verdicts verified. This commit was covered in the 2026-09-12 daily.

33 files, +1587/−391, with nine axes moving together verified — xai-org/x-algorithm@fee1d0f.

A. The VF hydration timeout was unified. The Strato per-call caller timeout rose from 100 ms for TES and 80 ms for Gizmoduck to a unified 390 ms. pub(crate) const HYDRATION_TIMEOUT: Duration = Duration::from_millis(390); was added to visibility-filtering/hydration/mod.rs. The CLIENT_TIMEOUT of the two hydrators, TES and Gizmoduck, moved to this shared constant. Previously, each of the two hydrators had used its own 150 ms local constant since the Gen4 release. The two Strato client config constants in server_deps.rs also follow this value. TES_STRATO_REQUEST_TIMEOUT_MS (previously 100) and GIZMODUCK_STRATO_REQUEST_TIMEOUT_MS (previously 80) now refer to crate::hydration::HYDRATION_TIMEOUT.as_millis() as u64 (=390). In other words, the Strato-layer per-call caller timeout for the two backends grew 3–4×. verified — visibility-filtering/hydration/mod.rs, visibility-filtering/hydration/gizmoduck_hydrator.rs, visibility-filtering/hydration/tes_hydrator.rs, visibility-filtering/server_deps.rs.

B. When a TES lookup fails, the author is looked up in the AuthorId fallback cache. This cache is on by default, and its default size is 1 M entries. TesHydrator gained an author_id_cache: Option<FallbackCache<TweetId, u64>> field. fetch_pure_core now returns PureCoreHydration { core: HashMap<TweetId, PureCoreData>, recovered_authors: HashMap<TweetId, u64> } instead of HashMap<TweetId, PureCoreData>. If the TES call result for a tweet is Hydrated::Found(core_data), it records (tweet_id, author_id) in the cache and also fills core. If it is NotFound or Failed, it looks up this tweet's earlier author_id in the cache. The priority with which resolve_candidate consumes this result is raw.request_author_id → core.get(&tweet_id).map(|c| c.author_id) → recovered_authors.get(&tweet_id). In other words, even if TES is down or does not respond, author-side rules (user label, account state) are still evaluated as long as this tweet's earlier author_id is in the cache. In that case only tweet-side rules (NSFW media, takedown reason, etc.) are skipped. The new environment variable VF_AUTHOR_ID_FALLBACK_ENABLED defaults to true and uses is_none_or, so "unset = enabled". The new environment variable VF_AUTHOR_ID_FALLBACK_CAPACITY defaults to 1_000_000. verified — visibility-filtering/hydration/tes_hydrator.rs (+234/−42), visibility-filtering/models/mod.rs (+49/−9), visibility-filtering/config.rs (+14).

C. Author labels were replaced by 11 explicit AuthorLabel variants and a 64-bit bitset. The old HashSet<LabelValue> is gone. UserLabelSet(HashSet<LabelValue>) in models/author.rs became AuthorLabelSet(u64). The new AuthorLabel enum names only these 11 variants: NsfwHighRecall, NsfwHighPrecision, NsfwNearPerfect, NsfwAvatarImage, NsfwBannerImage, SpamHighRecall, AbusiveHighRecall, Compromised, ReadOnly, ImpersonationHighPrecision, DoNotAmplify. These 11 correspond exactly 1:1 to the 11 OON_USER_LABEL_DROPS rules that Gen4+13 (7ba7768) organized as rule constants. AuthorFeatures was promoted to #[derive(Clone, Copy, ...)]. Its size is locked at exactly 16 bytes, and the assert size_of::<Option<AuthorFeatures>>() == 16 pins it. The Gizmoduck hydrator's author_features(user_result, counts) narrows each thrift LabelValue to an Option<AuthorLabel> through the author_label(LabelValue) mapping. Labels that do not map only increment a count. The new metric is vf_author_labels{result="mapped"|"unmapped"}, and a test asserting that the dashboard references this name was added with it. In other words, VF now uses only these 11 labels for verdicts, and the other thrift LabelValue values (e.g., NSFW_TEXT_HIGH_RECALL, NSFW_NEW_USER) are explicitly dropped at input. Previously everything was kept in the HashSet and rules only checked for existence on lookup, so the other values were not used in verdicts but were still held in memory. verified — visibility-filtering/models/author.rs (+49/−13), visibility-filtering/hydration/gizmoduck_hydrator.rs, visibility-filtering/rules/author_rules.rs (+30/−27), visibility-filtering/rules/context.rs.

D. VF observes whether it met the gRPC deadline (filter_tweets_deadline{outcome=within|overrun|absent}) and measures request time split into two phases, hydration and post_hydration. filter_tweets.rs::FilterTweetsEndpoint::filter_tweets parses the grpc-timeout header in the request metadata. The units H/M/S/m/u/n map to hours, minutes, seconds, milliseconds, microseconds, and nanoseconds, respectively. The digits must be 8 or fewer, and any other format becomes None. When the request succeeds, it calls RequestMetricsGuard::record_deadline(grpc_timeout). The behavior of record_deadline splits into three cases.

  • If elapsed <= deadline, it increments the counter with outcome=within and records the remainder in the filter_tweets_deadline_remaining_ms histogram.
  • If the deadline was exceeded, it increments with outcome=overrun and records the excess in filter_tweets_deadline_overrun_ms.
  • If there is no timeout header, it increments with outcome=absent.

The new metric filter_tweets_phase_ms{stage="hydration"|"post_hydration"} observes hydrated_at - started and hydrated_at.elapsed() in filter.rs::FilterTweets::run, respectively. That is, the VF service can now measure the following two things:

  1. How much slack its own latency has relative to the deadline the caller allowed, or how much it overran
  2. How request time is allocated between hydration and the rule engine stage that follows

This added observation and the timeout increase in A read as effectively the same project. verified — visibility-filtering/filter_tweets.rs (+42/−0), visibility-filtering/filter.rs (+6/−1), visibility-filtering/rules/metrics.rs.

E. clock_cache.rs, a 32-way set-associative × 64-shard CLOCK cache primitive, was introduced, but nothing is wired to it yet. The changes are visibility-filtering/clock_cache.rs +446 and the registration pub(crate) mod clock_cache; in visibility-filtering/lib.rs. The top of the file opens with #![cfg_attr(not(test), expect(dead_code, reason = "TODO: no consumer wired yet"))]. The cache has fixed capacity, with u64 keys and V: Copy values. The structure has three layers, in the order shard (64) × set (capacity ÷ WAYS, distributed across shards) × way (32). When insert(generation, key, value) overwrites an existing slot, it checks the stored generation and skips the write if the stored one is newer. If there is no existing slot, it evicts with the CLOCK algorithm. The hand rotates, decaying Referenced → Cold, and inserts into the slot when it finds Cold or Empty. begin_request() assigns each request a monotonic generation, which prevents a later request from reviving a stale cache value. It is a separate primitive from the existing FallbackCache (TinyLFU family, moka::sync::Cache). This cache is a new hot-path cache that handles write throughput and lock contention better, with per-shard mutexes and fixed-slot arrays. Since this commit has no consumer, what to watch is which hydrator's backend it gets wired to in the next commit (TES author-id? Gizmoduck AuthorFeatures? SafetyLabel expiring cache?). verified — visibility-filtering/clock_cache.rs, visibility-filtering/lib.rs.

F. The Phoenix proto gained two ProductSurface values (TREND_PAGE=16, EXPLORE_SPOTLIGHT=17) and UserActionAggregationType::IMMERSIVE_LONG_DWELL=29. The changed files are phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto and phoenix/python/common/xai-proto/proto/recsys.proto. The earlier PLACEHOLDER_16 and PLACEHOLDER_17 were named PRODUCT_SURFACE_TREND_PAGE = 16 and PRODUCT_SURFACE_EXPLORE_SPOTLIGHT = 17, respectively. Proto slots opened for the Trend surface and the Explore Spotlight surface to be formally brought into Phoenix ranking. The two surfaces were added after the 15 surface constants organized in code in Gen4+3, and there is no actual home-mixer wiring yet. In UserActionAggregationType, PLACEHOLDER_29 was also named IMMERSIVE_LONG_DWELL = 29. Separately from the existing IMMERSIVE = 26, it is now possible to split immersive interactions with long dwell time into their own aggregation type. Which sequence pipeline in the nano/flagship training configs consumes this new aggregation is not in this commit. verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto.

G. The two-tower user tower uses PADDING_SEGMENT_ID at padding positions and HISTORY_SEGMENT_ID at real history positions. It now reflects the mask instead of the previous all-zero. user_tower_segment_ids in phoenix/xrex/models/recsys_two_tower_model.py was extended to take an optional padding_mask: jax.Array | None argument. The previous logic had two branches.

  • If use_history_segment_ids=True, it fills the array entirely with HISTORY_SEGMENT_ID.
  • If False, it uses jnp.zeros.

The new logic, when padding_mask is given, mixes the two values by mask position with jnp.where(padding_mask, HISTORY_SEGMENT_ID, PADDING_SEGMENT_ID). The actual call site of the _pool function now passes user_padding_mask, so the production training path takes this new branch. In other words, the user tower's attention, RoPE, and attention bias are computed with padding positions distinguished from history. It is a correctness fix of the same nature as Gen4+15's variable-length history support in the FA4 attention kernel. verified — phoenix/xrex/models/recsys_two_tower_model.py (+15/−4).

H. Home-mixer's ads injection log now explicitly includes an ads_blender field. home-mixer/side_effects/ads_injection_logging_side_effect.rs looks up the AdsBlenderType parameter (feature-switch) and fills the ads_blender string in the log payload. For RequestType::Following, it does not look up the parameter and uses the hardcoded "following". The AdsBlenderType param is looked up only for the other surfaces. Gen4+10 (24c6094) introduced five ads blenders (safe_gap, partition_organic, partition_organic_low_risk, time_gap, multi_risk). This time, in follow-up, a separate blender label "following" for the Following surface was formally brought into the logging axis. Whether this is a sixth blender, or whether the Following surface takes a separate pipeline outside the five blenders so that the label is attached only in logging, cannot be confirmed from this diff alone inferred. The latter fits the code flow better (skipping the param lookup reads as a signal that the pipeline is different) inferred. verified — home-mixer/side_effects/ads_injection_logging_side_effect.rs (+5/−1).

I. Under-The-Hood gained a daily per-country account takedown batch job, UthPctdAccountTakedownEventsJob, and the UTH label aggregates gained a countryCodes field. under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala was added with +143 lines. The job joins two datasets. AuditServicePctdActionsScalaDataset is the User-type PCTD action log, and UsersourceScalaDataset holds the takedown reason from the user snapshot. The join result (userId, day, reason, country) is written as daily-partitioned Parquet (daily_account_takedowns/YYYY/MM/DD). The reason priority is LEGAL_REQUEST < BYSTANDER_REPORT < DMCA < UNSPECIFIED. If multiple reasons are found for the same (userId, country), the one with the lower priority value is kept. Cross-DC snapshot access (AllowCrossDC) is allowed. The default reducers is 50, the default usersourceSnapshotMaxAgeDays is 10, and the test user filter is supported through TestUserFilter.isTestUserId. A countryCodes: optional list<string> field (#3) was added to the two structs UthPostLabelAggregate and UthAccountLabelAggregate in uth_serving.thrift. In other words, the UTH transparency tool has started preparing to serve country-level takedown records. It reads as a plan to show users which label was active in which country and when. It pairs with the 16 NSFW-gated countries disclosed in Gen4+12 (2026-09-01, 6384ca7). We will watch whether a per-country filter appears at the UTH URL (x.com/i/under_the_hood). verified — under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala, under-the-hood/thrift/uth_serving.thrift.

J. The side changes are as follows.

  • ExpiringCache::with_clock calls cache.reserve(capacity) on initialization. The safety-label expiring cache now reserves the capacity it will use in advance and avoids reallocation on the first insert. verified — visibility-filtering/safety_label_source/expiring_cache.rs.
  • In hydration/metrics.rs (+21/−154), timed_keyed_rpc, KeyedResultCounts::from_results, and outer_timeout were removed. After the batch unification in Gen4+15 (9b0dc31, 2026-09-04), this keyed-result path was dead code. A new record_author_labels(mapped, unmapped) helper was added. verified — visibility-filtering/hydration/metrics.rs.
  • The frame cap of the Grox motion-reveal image builder was raised from 32 to 48. ECC affine alignment, Farneback optical flow refinement, a weighted median, and a LAB skin-color plausibility gate were also added. Grox's motion-reveal thumbnail generation (video → single-frame highlight) was refined to take more frames and pass them through alignment, denoising, and color verification stage by stage. verified — grox/libs/video_tools/image.py (+288/−25).
    • _MOTION_REVEAL_MAX_INPUT_FRAMES changed from 32 to 48.
    • New helpers:
      • _ecc_align: cv2.findTransformECC
      • _is_static: judges static frames with phaseCorrelate
      • _dense_align_residual: aligns the residual with Farneback optical flow
      • _weighted_median: weighted median of the overlays shifted after alignment
      • _native_chroma: median chroma of bright pixels in the LAB color space
      • _skin_plausible: a gate that checks whether the overlay's median chroma falls near the skin-color axis
  • home-mixer/params/param.rs had only a mirror sync tick. Apart from the timestamp of the feature-switch defaults mirror being updated from 2026-09-09T16:23:17Z to 2026-09-10T16:21:03Z, there is no substantive change.

Undisclosed: the following are not public.

  • The actual consumer of clock_cache
  • Which home-mixer candidate and ranking paths the TREND_PAGE and EXPLORE_SPOTLIGHT surfaces connect to
  • The event definition that detects and aggregates IMMERSIVE_LONG_DWELL
  • In what UI UTH per-country takedowns are shown on the user screen
  • How much the VF HYDRATION_TIMEOUT of 390 ms increased P99 in the actual latency distribution

Gen4+20 (2026-09-12, 6bb4594) — VMRanker DPP pins the seed anchor + Phoenix ScoredCandidate log dual-published as Thrift and Proto + VF client discovery migrated to the xDS LDS listener + Phoenix proto LLM-judge search relevance action + Phoenix wire returnLogitsList + CompactAuthorFeatures 8-byte packed (not wired) + VF worldwide takedown rules refined + UTH report monthly cap parameter + Phoenix training Kafka mTLS mode

In this commit, when a request carries a reference post (seed) and its embedding exists, VMRanker's DPP puts the seed into the kernel to reduce the gain of candidates close to it, but does not include it in the result verified. In safety verdicts, bystander reports and legal copyright requests tagged only with a worldwide code are no longer filtered out automatically verified. This commit was covered in the 2026-09-13 daily.

24 files, +1397/−338, with eight axes moving together verified — xai-org/x-algorithm@6bb4594.

A. VMRanker DPP uses seed_tweet_id as a pinned anchor. This seed does not consume the top_k budget. A new field seed_tweet_id was added to RankRequest. dpp_model::rank looks up the embedding store (ctx.store.client) with this id. If the embedding exists, it builds DppInput { id, score: 0.0, embedding, norm, embedding_missing: false } and passes it to the new seed: Option<&DppInput> argument of dpp::rescore. rescore builds a kernel of size total = m + 1 from the top-K candidates and one seed. The first slot of the greedy selection is forcibly pinned to the seed position (pinned_first: Option<usize>). After that, it filters the seed index out of the selection result and returns the rest. That is, the seed sits in the kernel and has its cosine similarity to other candidates computed, which reduces the determinantal gain of candidates close to it. However, it does not appear in the final slate and does not consume the top_k budget. The new metric vm_ranker_dpp_seed_context_total{result="pinned"|"embedding_missing"} counts seed embedding hits and misses separately. The new tests seed_suppresses_near_duplicate and seed_does_not_consume_top_k_budget lock in these two contracts. verified — vm-ranker/dpp.rs (+117/−40), vm-ranker/scoring/dpp_model.rs (+28/−1), vm-ranker/metrics.rs (+8).

Before this commit, VMRanker DPP optimized only within-set diversity (cosine repulsion among top-K candidates), and the home timeline's blender placed that result as is. The seed pin this commit introduced is contextual diversity that "favors candidates dissimilar to the tweet being viewed" inferred. Related tweets on a permalink page, a "more like this" flow for a specific tweet, and alternative slates for thread content read as natural consumers inferred. Which home-mixer pipeline supplies seed_tweet_id is not in this commit.

B. The Phoenix ScoredCandidate log is dual-published in the Thrift PredictionScore BTreeSet format and the Proto LoggedScoredCandidate HashMap format. The Proto side goes to a new Kafka topic. home-mixer/side_effects/phoenix_experiments_side_effect.rs now builds two payloads per candidate and writes them to the two topics concurrently with tokio::join!.

  • The existing Thrift binary ScoredCandidate goes to PHOENIX_SCORES_TOPIC (cluster Aiml).
  • The new proto LoggedScoredCandidate goes to LOGGED_SCORED_CANDIDATES_TOPIC (cluster Phoenix).

prediction_scores in the Proto payload is a map<string, double>. So, unlike the sorted collection of Thrift's BTreeSet<Box<PredictionScore>>, it allows O(1) lookup, and the wire size is also much more compact without a sorted index. The mapping keys keep the phoenix.<cluster_name>.<action_name> naming convention as is. The keys are attached to the 28 engagement heads and the pdwell alias. The constructor, new_mock, and prod assembly code of PhoenixCandidatePipeline were extended to take logged_scored_candidates_kafka_client as the sixth Kafka publisher. The Thrift stream is kept and the Proto stream was attached in parallel. When downstream (the ranking log consumers) switches to Proto is outside this commit. verified — home-mixer/side_effects/phoenix_experiments_side_effect.rs (+207/−54), home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs (+17/−1).

C. VF moved to finding the TES and Gizmoduck clients through the xDS LDS listener, and unified the Strato constants. Previously, visibility-filtering/server_deps.rs set the gRPC endpoint directly with a datacenter template string (tweet-entity-service.tweet-entity-service.prod.{datacenter}.s2s.twttr.net). It also used each service crate's TESClientConfig and GizmoduckClientConfig. These two are per-service config structs holding the aperture size, penalized peak EWMA / least-request P2C, the readiness probe port, and so on. This commit deleted all of them and replaced them with a common helper, build_xds_strato(XdsStratoParams, deterministic_aperture). The new helper assembles the channel with xai_x_rpc::grpc_client::ChannelBuilder as follows.

ChannelBuilder::new(params.name)
    .tls(TlsMode::mtls_from_env()?.with_domain_override(params.tls_domain))
    .request_timeout(STRATO_REQUEST_TIMEOUT)          // = HYDRATION_TIMEOUT (390 ms)
    .connect_timeout(STRATO_CONNECT_TIMEOUT)          // 400 ms
    .xds(StartFrom::Lds(params.xds_listener))         // TES: "tweet-entity-service.prod.tweet-entity-service:fed-grpc"
                                                       // Gizmoduck: "gizmoduck.prod.gizmoduck:fed-grpc"
    .eager_resolution(Duration::from_secs(5))
    .aperture(STRATO_APERTURE_SIZE)                    // 12
    .readiness_probe(READINESS_PROBE_PORT, "/ready")
    .buffer_max_wait(DEFAULT_BUFFER_MAX_WAIT)
    .total_timeout(DEFAULT_TOTAL_TIMEOUT)
    .build_load_balanced(params.lb_policy)             // TES: penalized peak-EWMA / Gizmoduck: least-request

Only the TES path attaches a server-side deadline hint with enable_serve_within: true (OpContext.serve_within, round_trip_allowance_microseconds = 10_000). Gizmoduck turns this option off. retry_config: Some(RetryConfig::for_idempotent()) is also set only on the TES path.

This migration changes three things.

  • The service discovery layer moved from a hostname template to going through the xDS LDS (Envoy-family listener name).
  • The separate client-config structs that lived inside the service crates, such as TESClientConfig and GizmoduckClientConfig, are gone. The client assembly logic was absorbed into VF server_deps.rs.
  • The Strato client of the L2 cache warmer now also takes the deterministic_aperture flag.

It can also be read from the viewpoint that the HYDRATION_TIMEOUT unification in Gen4+19 rippled out to here. verified — visibility-filtering/server_deps.rs (+119/−48).

D. The Phoenix proto gained two LLM-judge search relevance actions and the returnLogitsList response format. The following definitions were added to both phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto and its Python mirror.

enum ActionName {
  ...
  CLIENT_TWEET_SEARCH_QUERY_REFORMULATED = 104;
  CLIENT_TWEET_RELEVANT_TO_SEARCH = 105;
  CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106;
  LLM_JUDGE_RELEVANT_TO_SEARCH = 107;
  LLM_JUDGE_NOT_RELEVANT_TO_SEARCH = 108;
  ...
}

message PredictNextActionsRequest {
  ...
  bool returnLogitsList = 22;
}

message NextActionDistribution {
  ...
  repeated float requestedActionLogits = 12;
}

The two actions extend the xrecsys_search search ranking config from Gen4+4 (aad7179, 2026-08-19). Until this commit, the only search relevance signal was the client signal (a user selecting or skipping a result). This commit adds the LLM judge's relevance verdict as a separate action axis. The natural reading is that the xrecsys_search config uses these two actions as training targets to supervise search relevance prediction inferred. A pipeline in which grox or a dedicated LLM judge feeds relevant/not labels for (query, tweet) pairs must exist somewhere inferred.

returnLogitsList is a response payload format. A new field return_logits_list: bool was added to PredictRequestItem. When this field is on, python.rs::PredictRequestBatch responds as follows instead of using return_log_map (a sparse HashMap<u32, f32>):

dist_entry.requested_action_logits = item
    .requested_action_indices
    .iter()
    .map(|&idx| dist.get(idx as usize).copied().unwrap_or(f32::NEG_INFINITY))
    .collect();

That is, the response is a positional list that keeps the order of requested_action_indices. If a requested action index falls outside the model vocab, it is filled with -inf. This compresses what the existing return_log_map sent over the wire as a sparse HashMap into a positional dense array. The key overhead disappears and the order is guaranteed. The two formats are mutually exclusive by design, and if both are on, return_logits_list takes precedence. verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto (+6), phoenix/python/common/xai-proto/proto/recsys.proto (+6), phoenix/crates/serving/xai-recsys-engine/src/python.rs (+19/−6), phoenix/crates/serving/xai-recsys-engine/src/util.rs (+2).

E. VF gained CompactAuthorFeatures, an 8-byte representation packed as a NonZeroU64, but it is not yet wired in. The following code was added to visibility-filtering/models/author.rs:

pub(crate) struct CompactAuthorFeatures(NonZeroU64);

const COMPACT_PRESENT_BIT: NonZeroU64 = match NonZeroU64::new(1 << 63) { ... };
const COMPACT_LABEL_SHIFT: u32 = 8;
const COMPACT_LABEL_MASK: u64 = (1 << (63 - COMPACT_LABEL_SHIFT)) - 1;

impl AuthorFeatures {
    pub(crate) fn to_compact(self) -> CompactAuthorFeatures { ... }
    pub(crate) fn from_compact(compact: CompactAuthorFeatures) -> Self { ... }
}

The layout has three parts:

  • Bit 63: present marker
  • Bits 0–6: 7 account-state flags (is_suspended, is_deactivated, is_protected, is_nsfw_user, is_nsfw_admin, is_erased, is_offboarded)
  • Bits 8–62: 55-bit label bitset

It asserts that the size of Option<CompactAuthorFeatures> is exactly 8 bytes (using the NonZeroU64 niche optimization).

Why it matters: In Gen4+19 (fee1d0f, 2026-09-10), AuthorFeatures was already shrunk to a 16-byte Copy struct. This commit's CompactAuthorFeatures is an 8-byte representation that cuts it in half again. A test that exhaustively verifies the round trip for every label and flag combination was added with it (1<<7 × 1<<11 = 262,144 combinations). Since there is no consumer yet (#[allow(dead_code)]), it appears likely to be adopted in the next commit as a value type for the hydration pipeline or a cache (the clock_cache introduced in Gen4+19 is the most likely candidate) inferred. verified — visibility-filtering/models/author.rs (+94/−2).

F. VF takedown rules now handle worldwide codes separately by reason. visibility-filtering/rules/context.rs extracted the worldwide check of TakedownPredicates::filter_by_country_or_worldwide into a new helper is_worldwide_code(cc) = "xx" OR "xy" (case-insensitive). It also added two exceptions:

  • legal_takedown_country: if TakedownReason::LegalRequest { country_code } carries "xy" (WORLDWIDE_COPYRIGHT_COUNTRY_CODE), it now returns None. That is, LegalRequest{"xy"} no longer triggers the legal takedown rule. The DMCA reason still returns "xy" as the synthetic worldwide code.
  • local_laws_takedown_country: if TakedownReason::BystanderReport { country_code } carries a worldwide code ("xx" or "xy"), it returns None. That is, a worldwide-scale BystanderReport{"xx"} report is no longer dropped by the local laws rule.

Three new test cases (legal_takedown_copyright_code_allows_without_viewer_country, unspecified_takedown_worldwide_drops_without_viewer_country, local_laws_takedown_worldwide_allows_any_viewer) lock in this refinement. This is the first substantive policy relaxation since Gen4+12 (6384ca7, 2026-09-01), which first introduced country-scope handling. Bystander reports and legal-copyright requests that carry only a world-wide scope are no longer dropped automatically. UnspecifiedReason is still dropped when it is worldwide. verified — visibility-filtering/rules/context.rs (+14/−4), visibility-filtering/rules/golden_corpus.rs (+33/−3), visibility-filtering/rules/tweet_rules.rs (+22/−11).

G. The Under-The-Hood report gained a maximum-month cap FS parameter and an employee role. under-the-hood/strato/lib/params.strato exposes a new int parameter under_the_hood_report_max_month_bucket (default 0 = unused). In reportMonthBucket(now, maxMonthBucket) of underTheHoodReport.User.strato, if the cap is greater than 0, the computed calendar month is clamped to that cap. "employee" was added to the userRoles Set next to "birdwatch_team". That is, operators can now lock the displayed month of the UTH report at or below a given value, and the employee role is included among those who can view the report. The month cap appears to be a safeguard that pins the report to the last month with complete data when preparing for a large incident or rollout. verified — under-the-hood/strato/lib/params.strato (+5), under-the-hood/strato/columns/underTheHoodReport.User.strato (+15/−7).

H. Kafka authentication for the Phoenix training pipeline gained an mTLS mode, chosen automatically alongside SASL. phoenix/xrex/data/streaming/kafkaconsumer.py and kafkaloader.py introduced a new KafkaAuth dataclass (mode="mtls"|"sasl"). auto_detect_auth decides the authentication in this order:

  • If the KAFKA_AUTH env var is "sasl", SASL is forced.
  • If ca-bundle.crt, client.fullchain and client.key are all present in KAFKA_MTLS_CERTS_DIR (default /root/client-certs), mTLS is used.
  • If only the client certificate is present and the CA is missing, mTLS is used with the platform CA bundle (/etc/ssl/internal-ca/ca-bundle.crt) or the system truststore as a substitute.
  • If SASL_PLAIN_PASSWORD is present, it falls back to SASL.

The recsys action stream Kafka consumed by Phoenix training can now be reached over mTLS as well as SASL/SCRAM. This code alone does not show which one is adopted in actual operation. verified — phoenix/xrex/data/streaming/kafkaloader.py (+159/−9), phoenix/xrex/data/streaming/kafkaconsumer.py (+96/−40), phoenix/xrex/data/rust_kafka_recsys.py (+23/−5).

I. Minor changes:

  • The hardcoded account list of Brazil2026ElectionFilter was tidied up. This is routine maintenance reflecting politicians' accounts being activated and deactivated; there is no policy change. verified — home-mixer/filters/brazil_2026_election_filter.rs (+334/−42).
    • 163 account IDs were added and 10 were removed, so the set grew from 2,554 to 2,707 verified — same file. Examples of newly added accounts: @VladimirAlves, @merisio, @LorenLuniere, @capitaoassumcao, @annavalente, @ademarmago, @henriquecatan, @jabscres, @yarahaquim, @ElySilveira, @MarceloFaro30, @freire_roberto, @felipececilio, @claudiojacoski
    • One @rafaelcampelo → @RobertoOshiro swap
    • Many newly registered "no live account found" comments
    • Many cleanups of "not the candidate's actual account" and "no live account" comments
  • The two VF client files visibility-filtering-client/discovery.rs (+11/−8) and vf_client.rs (+1/−1) were lightly refactored to match the xDS migration in C above. There is no policy change.
  • home-mixer/params/param.rs had only a mirror sync tick. The feature-switch defaults mirror timestamp changed from 2026-09-10T16:21:03Z → 2026-09-11T16:22:45Z, and there is no substantive change.
  • The Grox motion-reveal image builder was tidied up slightly. grox/libs/video_tools/image.py (+60/−55) was refactored as a follow-up cleanup of the ECC·Farneback pipeline introduced in Gen4+19. There is no policy change.

Undisclosed: the following are not public.

  • Which home-mixer pipeline supplies seed_tweet_id
  • The downstream consumers of LOGGED_SCORED_CANDIDATES_TOPIC
  • The actual registration definition of the xDS LDS listener
  • The actual consumer of CompactAuthorFeatures

LLM_JUDGE_RELEVANT_TO_SEARCH and LLM_JUDGE_NOT_RELEVANT_TO_SEARCH were vacated five days later, in Gen4+23 (42266f3, 2026-09-17). CLIENT_TWEET_LIVE_STREAM_REPLY=107 and RESERVED_108 took their place. Questions about where these two actions connect are now moot.

Gen4+21 (2026-09-15, 2d4a03c) — Three-stage response diversity (pre-heuristic, top10, final) observation side effect + Phoenix ads continuous head ADS_WEB_CT_PURCHASE_VALUE + PurchaseValueBaseline request field + 12 TweetTypeMetrics slate-axis bits (239-244, 315-320) + viewer positive-engagement parser + H100 attn kernel unified with GB300 on the same CUTe DSL + Kafka OTel export 30s → 120s + coordinated_spam follower 10k → 30k

Observation now measures the response slate's diversity side by side at three points: before the heuristic layers, top 10, and final. Slate diversity and author context are also recorded as per-candidate bits verified. On the ads side, a new head regresses on the purchase value after a web click, and on the safety side the follower threshold of the coordinated spam filter rose from 10k to 30k verified (daily).

17 files, +592/−9, with five axes moving together verified — xai-org/x-algorithm@2d4a03c.

A. A side effect now measures the response slate's diversity side by side at three stages.

The new ResponseDiversityStatsSideEffect observes the three stages pre_heuristic, top10 and final in parallel. The default sampling rate is 5%, and it runs at 100% in experiment buckets.

The implementation file is home-mixer/side_effects/response_diversity_stats_side_effect.rs (186 lines, new). This side effect was wired into the Phoenix candidate pipeline as the sixth observation side effect.

There are two activation conditions.

  • The default is DEFAULT_SAMPLING_RATE = 0.05.
  • If the new feature-switch param EnableResponseDiversityStatsExperimentBucket captures an experiment bucket, it runs at 100% in that bucket. Each metric is then emitted with the scope tags ddg=<experiment id> and bucket=<bucket id>.

The three stages cover different ranges.

  • pre_heuristic: merges selected_candidates and non_selected_candidates and takes the top K by weighted_score in descending order. This score is the raw head-weight sum, the value before the DPP, author-decay and OON multipliers are applied. This stage is the counterfactual of "what if the ranker's raw top-K had been served without the heuristic layer."
  • top10: the top 10 of the final slate, sorted by score (post-heuristic).
  • final: the entire slate.

Each stage emits histogram metrics, all with names starting with ResponseDiversity.*:

  • Size: Size
  • Author: UniqueAuthors·UniqueAuthorRatio·MaxAuthorShare, AuthorHhi·AuthorEntropyNorm
  • Source: UniqueSources·SourceEntropyNorm (axis=served_type, retrieval source)
  • Other: InNetworkShare, SidCoverage
  • 3 SID L1 axes: UniqueSidL1·MaxSidL1Share·SidL1EntropyNorm
  • 2 SID L2 axes: UniqueSidL2·SidL2EntropyNorm

It computes HHI (Herfindahl-Hirschman Index, a market concentration measure applied to the slate) together with Shannon entropy normalized as entropy / ln(size). This makes diversity comparable regardless of differences in slate cardinality. verified — home-mixer/side_effects/response_diversity_stats_side_effect.rs, home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs (+2), home-mixer/params/param.rs (+7/−1).

Why it matters: How much the heuristic layer changes the slate relative to the ranker's raw scores has not been measured until now. The pre_heuristic counterfactual is the first connection that makes this difference measurable.

B. A shared primitive for computing multiset statistics was added.

The new Composition computes multiset statistics (unique, max, HHI, normalized entropy) in O(n). The implementation is in home-mixer/util/composition.rs (63 lines, new). A single Composition::from_keys<K: Hash + Eq>(iter) extracts statistics for any axis (author, served_type, SID L1/L2). It is reused in two places: A's ResponseDiversityStatsSideEffect and the computation of the new bits in C's TweetTypeMetricsHydrator. verified — home-mixer/util/composition.rs, home-mixer/util/mod.rs (+2).

C. 12 bits representing slate-wide and context information were added to the per-candidate bitset.

Slate-axis bits 239-244 and 315-320 were newly defined in the TweetTypeMetrics bitset. Two functions were added to home-mixer/candidate_hydrators/tweet_type_metrics_hydrator.rs (+100/−4):

  • author_diversity_bits(authors: &Composition) -> HashSet<usize> builds the slate-wide bits, which apply identically to every candidate.
  • slate_position_bits(query, candidates) -> Vec<HashSet<usize>> applies differently to each candidate.

In hydrate, the two bit sets are merged by union with each candidate's base bitset, then compressed and stored as the tweet_type_metrics payload. The new predicates are defined in home-mixer/util/tweet_type_metrics.rs (+27).

Slate-wide:

  • UNIQUE_AUTHOR_RATIO_LTE_50_PCT = 239: the slate's unique_ratio ≤ 0.5
  • UNIQUE_AUTHOR_LTE_5/10/15 = 240/241/242
  • SINGLE_AUTHOR_GTE_25_PCT/50_PCT = 243/244

Slate context (traversed in descending score order):

  • AUTHOR_REPEAT_IN_SLATE = 315, AUTHOR_REPEAT_GTE_3_IN_SLATE = 316: turn on from the second and the third-or-later appearance of the same author_id. They do not turn on for the first appearance. What they mark is "which appearance of the same author this tweet is."
  • AUTHOR_NOT_ENGAGED_BY_VIEWER = 317: means the viewer does not follow the candidate's author and that author is also not in the viewer's positive-engagement set.
  • HAS_SEMANTIC_IDS = 318
  • SID_L1_REPEAT_IN_SLATE = 319, SID_L2_REPEAT_IN_SLATE = 320: turn on for the second and later candidates whose SID L1 (L2) prefix is already in the slate.

The new helper is PostCandidate::semantic_id_prefix(levels: usize) -> Option<&[i32]> (home-mixer/models/candidate.rs +8). This helper safely slices the top levels codes from the SID array.

Why it matters: TweetTypeMetrics is an axis for ranking training and serving logs and for offline analysis. So slate diversity, author context and SID context are now logged as per-candidate features and can be referenced in training and modeling. However, this commit has no training config change that actually consumes these bits, so only the channel is open. Actual consumption is something to watch for in follow-up commits. If these bits are promoted to ranker features, it would signal that X's ranker is moving from pointwise to slate-aware scoring. verified — home-mixer/candidate_hydrators/tweet_type_metrics_hydrator.rs, home-mixer/util/tweet_type_metrics.rs, home-mixer/models/candidate.rs.

D. A function was added that extracts the set of authors a viewer has engaged with positively from the viewer's history.

The new function viewer_history::positively_engaged_author_ids extracts the set of positive-engagement authors from an Arrow IPC stream. The file is home-mixer/util/viewer_history.rs (61 lines, new). The function opens the request's ScoredPostsQuery.columnar_scoring_sequence (an Arrow columnar batch of the user's recent interaction history). From it, it collects and returns the authorId of rows where authorId > 0 and at least one of the 18 positive actions below is on in the actionNameMultiHot FixedSizeList.

ServerTweetFav, ServerTweetReply, ServerTweetQuote, ServerTweetRetweet,
ClientTweetClick, ClientQuotedTweetClick, ClientTweetOpenLink,
ClientTweetClickProfile, ClientTweetPhotoExpand, ClientQuotedTweetPhotoExpand,
ClientTweetVideoOpen, ClientTweetVideoQualityView, ClientQuotedTweetVideoQualityView,
ClientTweetShare, ClientTweetClickSendViaDirectMessage, ClientTweetShareViaCopyLink,
ClientTweetBookmark, ClientTweetFollowAuthor

Negative signals such as report, mute, block and not_dwelled, and impression-type signals such as dwell and quality view, are intentionally excluded (video quality view is explicitly positive). This set is a new constant set that appears nowhere else in X's public code. It fixes in code, for the first time, the definition of "an author with whom a relationship was formed through active engagement."

The AUTHOR_NOT_ENGAGED_BY_VIEWER bit in C is the one consumer of this function, but the concept of "an author with an established relationship with the viewer" could be reused in other pipelines (cold start, cold author fatigue decay and so on), so it appears to have been split out as a separate utility inferred. verified — home-mixer/util/viewer_history.rs.

E. The Phoenix proto gained a head that predicts the actual purchase value after an ads web click, and a request field.

The new continuous head is ADS_WEB_CT_PURCHASE_VALUE = 5, and the new request field is PurchaseValueBaseline. This head is the fifth continuous head of the ContinuousActionName enum. The existing four are DWELL_TIME=1, CLICK_DWELL_TIME=2, ACTIVE_SECS_5M_RESIDUAL_NORM=3 and BRIDGE_PROBABILITY=4. The new head's regression target is the purchase value that occurs after a web click-through conversion (CT = Click-Through).

In the same commit, a new field 23 was added to PredictNextActionsRequest:

repeated PurchaseValueBaseline purchase_value_baselines = 23;

message PurchaseValueBaseline {
  int64 impression_id = 1;
  int64 advertiser_account_id = 2;
  double mean_value_usd_28d = 3;
}

Each baseline is a tuple of (impression_id, advertiser_account_id, the advertiser's average purchase value in USD over the last 28 days). The Rust helper phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs (114 lines, new) also came in, and it contains the following three items:

  • is_valid_baseline: the criterion is a positive id and a positive finite USD value.
  • find_baseline(request, impression_id, advertiser_account_id)
  • 5 wire tag stability unit tests: they check whether field 23 = 0xBA 0x01 is independent of returnLogitsList field 22 = 0xB0 0x01 0x01 and whether it is backward compatible with old requests.

Why it matters: If ads ranking has so far predicted only the probability of ad clicks and conversions (including the 20 conversion actions of Gen4+6), the new head takes the value itself as its regression target. The request also carries a per-advertiser 28-day baseline, preparing a connection that would enable residual training. This commit does not state whether it is a direct training target or a baseline offset regression. It appears to be the first serving connection for a value-optimizing auction (the same direction as Meta VBB and Google tCPA/tROAS) inferred. A baseline arriving with every request means a separate offline pipeline updates the advertiser account's 28-day average daily and feeds it into serving requests in parallel, so it is likely that only the baselines of advertisers picked by pre-pruning at request time are attached inferred. verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs, phoenix/python/common/xai-proto/proto/recsys.proto.

F. The H100 training attn kernel in the two-tower config was unified with the GB300 kernel.

_H100_OVERRIDES.attn_impl in phoenix/xrex/configs/xrecsys_two_tower.py changed from pallas_ranker_varlen_attn to cutedsl_ranker_varlen_attn. cutedsl_ranker_varlen_attn has been in _GB300_OVERRIDES since the Gen4 release (47c1bcd) (xrecsys.py:308). In the 2026-09-08 commit (Gen4+17, 49815da), SM80 and SM90 forward and backward kernel files were newly released in phoenix/xrex/cutedsl/ranker_fa4/ (49815da). By NVIDIA compute capability, SM80 is A100 (Ampere), SM90 is H100 (Hopper), and GB300 is the Blackwell generation. We read this change as a follow-up that makes the H100 config use the same CUTe DSL implementation as the GB300 config once the SM90 kernel was released inferred. The training code paths of the two GPU generations, GB300 and H100, now match down to the attn-layer semantics. Whether this commit lifted the NotImplementedError constraint on the seqpack and FA4 combination observed in Gen4+11 (bc8e5f0) could not be confirmed, and it is a follow-up item to watch. verified — phoenix/xrex/configs/xrecsys_two_tower.py (+1/−1).

G. The Kafka OTel metric export interval for Phoenix training increased from 30s to 120s.

The default export_interval_ms of _init_otel_metrics and PhoenixKafkaDataset.metrics_export_interval_ms both changed from 30_000 to 120_000. OTel metrics are now exported 4× less often. The observation tail resolution of training jobs drops, while the collector load decreases. This change appears to be paired with the Kafka mTLS connection in H of Gen4+20 (6bb4594, 2026-09-12). mTLS increases authentication overhead, and the relaxed export reduces metric load. This is circumstantial evidence that metric host cost became a burden in measurement inferred. verified — phoenix/xrex/data/streaming/kafkaloader.py (+2/−2).

H. Grox's coordinated spam filter raised its follower threshold from 10k to 30k.

The changed value is the FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION constant of TaskCoordinatedSpamFilter in grox/flows/reply_spam/task_filter.py. The Grox reply-spam pipeline has two separate follower thresholds.

  • TaskSpamFilter·TaskReplyRankingFilter: raised seven times, 15k → 30k (Gen4+1) → 40k (Gen4+2) → 60k (Gen4+3) → 80k (Gen4+6) → 100k (Gen4+7) → 120k (Gen4+8) → 150k (Gen4+17). Each increase extends the spam verdict to threads of larger accounts and narrows reply ranking scoring correspondingly verified — task_filter.py:77-92, 214-225.
  • TaskCoordinatedSpamFilter: from 1k to 5k (Gen4+15) and 10k (Gen4+17), and now 30k. When the thread root's follower count is below the threshold, the thread is skipped as low_blast_radius, so raising the threshold removes conversations rooted at small accounts from the verdict targets verified — task_filter.py:157-162.

verified — grox/flows/reply_spam/task_filter.py (+1/−1).

I. Elsewhere, the param mirror timestamp was updated.

  • The feature-switch defaults mirror timestamp in home-mixer/params/param.rs changed from 2026-09-11T16:22:45Z → 2026-09-14T16:22:15Z (mirror sync tick). The substantive change is the introduction of EnableResponseDiversityStatsExperimentBucket in A above.

Undisclosed: the items below did not surface in this commit.

  • Which training config and loss term ADS_WEB_CT_PURCHASE_VALUE is registered in (baseline offset vs raw dollar)
  • Where the daily-updating pipeline for PurchaseValueBaseline sits
  • When the 12 new TweetTypeMetrics bits (239-244, 315-320) become active in a training config
  • The measured results of experiments in which ResponseDiversityStatsSideEffect is active, and the pre_heuristic vs final gap
  • Whether the 18-action set of positively_engaged_author_ids exactly matches the positive engagement head definition in phoenix/runners.py
  • Whether the seqpack and FA4 combination constraint has been resolved

Gen4+22 (2026-09-16, fad2f71) — Phoenix ads purchase-value loss wired in + new binary continuous loss type (first use = click-dwell BCE, on by default) + XAUC clicked-only metric + silent remap of the user_gender categorical index + H100 attn override flipped to CUTe DSL + VMRanker DSCP + EnableAdsBrandSafetyVerdictV2 rollback + grox reply-spam model split by thread follower count

A setting that trains the post-click dwell-time head as a binary classification of "stayed 10 seconds or more" is now on by default, and in serving this head stays a probability verified. The ads purchase-value head now connects through to the training loss, but its weight default is 0, so it is off in the default configuration, and the mirror value of the ads brand-safety V2 verdict switch went back to false verified (daily).

26 files, +552/−233; five axes moved together verified — xai-org/x-algorithm@fad2f71.

A. The ads purchase-value head now connects through to the training loss, but it is off in the default configuration.

The ADS_WEB_CT_PURCHASE_VALUE Huber ratio loss is now formally wired in (weight default 0, opt-in). In Gen4+21 only the wire (proto, request field, Rust helper) of the ads purchase-value continuous head was opened; this commit connects it through to the training loss. Two functions were introduced in phoenix/xrex/models/loss_recsys.py (+91).

  • purchase_value_valid_mask builds the valid mask as an AND of eight terms.
    • The expression is label_valid & padding & !negative_sample & delayed_mask & has_click & has_purchase & keeper_mask & (isfinite && positive) on ratio·baseline.
    • For a sample to enter the loss, its label must be observed, it must not be padding, and it must not be a negative-sampling sample.
    • The delayed-reward window must also be closed (when delayed_mask is True, the label has converged).
    • The sample must have actually been clicked and actually reached a purchase, and it must be a candidate that is trained on (trained_candidate_mask).
    • Updated in Gen4+24 (2026-09-18): the delayed_mask term narrowed from sample_source > 0 to sample_source == EARLY_RELABEL_STREAM_ID. This loss is not trained on the late-split stream. The stat names were also renamed from purchase-value-* to purchase-value_delayed_clicked-*.
  • purchase_value_loss_compute computes a Huber loss (default delta=1.0). It takes three inputs.
    • raw_ratio: GT ratio
    • pred_ratio: the model prediction
    • baseline_mean_usd: the advertiser's 28-day average
    • It emits seven stats: purchase-value-loss, -valid-count, -weight-sum, -ratio-mae, -target-ratio, -pred-ratio, -baseline-mean-usd.
    • The loss argument is the raw ratio, not raw USD. The training target is the ratio relative to the advertiser's 28-day average. baseline_mean_usd is an offset that arrives with the request and is used only for stat computation.

The loss loop in recsys_model.py (+236/−26) gained a block that runs when purchase_value_loss_weight > 0.

  • It pulls trained_candidate_mask, value_label_valid, and value_baseline_mean_usd from candidate_seq and builds the mask.
  • It takes candidate_continuous_actions[:, :, PURCHASE_VALUE_ACTION_INDEX] (= slot 5) as the raw ratio and candidate_continuous_preds[:, :, 5] as the prediction, and computes the loss.
  • It weights this loss by purchase_value_loss_weight and adds it to continuous_action_loss_total.

The default is purchase_value_loss_weight = 0.0, so it is still off in the default config. Turning it on requires passing mparams["purchase_value_loss_weight"] explicitly.

A new checker, validate_purchase_value, enforces the activation conditions.

  • num_continuous_actions > 5: slot 5 must exist.
  • output_vocab_size > ADS_PURCHASE_CONVERSION: action head 200 must exist.
  • purchase_value_huber_delta > 0 && finite
  • No generic continuous loss may be attached to the same slot. Purchase-value slot 5 is dedicated.

When the purchase-value head is active, the serving activation function is fixed to softplus (positive reals).

Four new fields entered on the wire side (phoenix/xrex/data/recsys/recsys_batch.py +53, parquet_recsys.py +4, feature_config.py +2).

  • PostSeq gained value_label_valid: bool and value_baseline_mean_usd: float32 slots.
  • The Parquet reader _read_value_labels reads the valueLabelValidSeq and valueBaselineMeanUsdSeq columns in pa.list_(dtype, seq_len) format and unpacks them into (batch, seq) arrays.
  • Both columns were registered in the feature list.

With this, the advertiser's 28-day baseline is also injected in parallel per batch at training time. The serving request field purchase_value_baselines was already open in Gen4+21.

Why it matters: Ads ranking has formally entered a value-optimizing auction direction, down to the training pipeline stage. Its training target closely resembles Meta VBB (Value-Based Bidding) and Google tCPA/tROAS. Whether it is actually active will be decided when a mparams["purchase_value_loss_weight"] value appears in a later commit. That the training target is a ratio to the advertiser baseline carries advertiser-scale normalization into the training target itself. This normalization unifies the loss scale of a "10,000 won advertiser" and a "100,000 won advertiser". verified — phoenix/xrex/models/loss_recsys.py, phoenix/xrex/models/recsys_model.py, phoenix/xrex/data/recsys/recsys_batch.py, phoenix/xrex/data/recsys/constants.py.

B. A new continuous loss type was added, and the post-click dwell-time head uses it by default.

The new type binary combines sigmoid, BCE, and a threshold. Its first use is click_dwell_time, with threshold=10s, and training is on by default. ContinuousActionLossConfig.loss_type now accepts binary in addition to the existing {mse, mae, huber, tweedie}. The new field is binary_threshold: float = 10.0. The activation function is forced to sigmoid by an assert. output_cap is meaningless because the output is a probability in [0,1], and an assert forbids it.

The new function binary_threshold_loss_compute binarizes the original continuous value gt_raw to {0,1} against the threshold (gt_binary = (gt_raw > threshold)). It then computes a sigmoid binary cross-entropy. The mask uses the same valid_mask & !negative_sample_mask gating as the existing continuous loss.

The signature of decode_continuous changed to jax.Array → (jax.Array, dict[int, jax.Array]). The second return value, head_logits_by_index, is the plumbing of this commit. Because binary loss needs the raw logit before the sigmoid (computing the sigmoid and BCE together from the logit is numerically stable), it also returns a raw logit tensor for each binary-typed head index. The loss loop consumes these logits through head_logits_by_index[loss_config.action_index]. The return value of the serving forward path (a non-training path) also becomes a 3-tuple, but serving does not use head_logits and discards it.

In the default _home_direct_packed_base of xrecsys.py, two defaults changed.

  1. click_dwell_loss_weight: 1.0: the mparams default was set to 1.0. Before this commit, the public training code (phoenix/xrex) had no click-dwell loss configuration at all verified — 2d4a03c grep. The default training target is now a binary classification of "whether the user dwelled 10 seconds or more within the 60-second window after a click".
    • A new ContinuousActionLossConfig was appended: action_index=CLICK_DWELL_TIME (= 2), metric_name="click-dwell-binary".
    • The remaining settings are loss_weight=mparams.get("click_dwell_loss_weight", 0.0) and loss_type="binary".
    • The binarization and normalization settings are binary_threshold=10.0 and norm_config=NormConfig(norm_scale=60.0).
  2. enable_click_dwell_time: True (feature-prep): the click_dwell_time history feature in the history encoder is now on by default. In recsys_feature_prep.py (+12), when enable_click_dwell_time=True, it normalizes history_seq.continuous_actions[..., 2] (slot 2). The expression is log1p(clip(x, 0, 60)) / log1p(60). It multiplies this value by the hist_click_dwell_time_vec embedding and adds the result to the history vector. The default is click_dwell_time_norm_scale=60.0.

Why it matters: First, the click_dwell_time head now has a training target as a binary classification for the first time. The target is "the probability of dwelling 10 seconds or more". Netflix N-tower and YouTube dwell families keep "whether the user dwelled (binary)" and "dwell time(regression)" as separate heads, whereas X, in contrast, unified them into one head trained as a binary classification. Second, the history encoder now references the actual post-click dwell time in the user history (previously there was only enable_dwell_time). Third, the symmetry between serving and training is shaken. _rescale_sigmoid_heads_for_inference multiplies sigmoid heads by norm_scale to restore the original units. However, the binary loss is explicitly excluded from this rescaling (if loss_config.loss_type == "binary": continue). In serving, this head stays a probability in [0,1]. The ranking scorer multiplies ContClickDwellTimeWeight (param.rs) by this probability. This weight was 0.0 at the time of this commit and became 0.4 in the 2026-09-29 commit (Gen4+31) verified (weight table). verified — phoenix/xrex/models/loss_recsys.py, phoenix/xrex/configs/xrecsys.py (+13/−1), phoenix/xrex/models/recsys_feature_prep.py (+12).

C. An XAUC metric that measures ordering quality among clicked candidates was added.

XAUC is a clicked-only pairwise concordance metric, and the new function is metric_xauc(pred, gt, valid_mask, max_samples=2048). It flat-reshapes the values and reduces them to at most 2048 with stride sampling (stride = max(N // 2048, 1)). It then computes pairwise concordance with a 2048×2048 outer-difference matrix.

  • A valid pair is pair_valid = mask_i & mask_j & (dy != 0).
  • Concordant means sign(dy) == sign(dp); tied means dp == 0 and is weighted 0.5.
  • The metric value is (concordant + 0.5·tied) / pair_valid_count. If pair_valid_count == 0, it returns 0.
  • xauc_mask = _cont_loss_mask & (xauc_gt > 0) uses positive-only samples. That is, it measures ordering quality only among candidates that received a click.
  • The stat name is {metric_name}-xauc-clicked.

For the binary loss type, the xauc gt keeps the original units as clip(gt_raw, 0, norm_scale). It is reconstructed from the pre-binarization value rather than the post-binarization one, to preserve the per-sample rank signal. For binary heads, it additionally emits RCE (relative cross entropy) and ratio-pos.

Why it matters: It measures the ranking quality of continuous heads such as dwell and purchase-value by ordering concordance rather than by the magnitude of the predicted values. In ads value optimization, "the ability to get the order of large revenue right" is more directly tied to auction performance than "the accuracy of the absolute revenue prediction". Because the sampling is strided rather than random, it has a bias that depends on the sequence-packing order. Even so, the budget limit of not computing more than 2048² pairs per training step takes priority. verified — phoenix/xrex/models/recsys_model.py.

D. In serving, sigmoid-head predictions are restored to original units, except for binary heads.

_rescale_sigmoid_heads_for_inference handles this. In the serving forward path, among continuous_action_losses, it multiplies the predictions of heads with activation == "sigmoid" && loss_type != "binary" by norm_scale, restoring the scale before training-time normalization. That is, if a continuous label that was log/scale-normalized in training was regressed with a sigmoid, serving stretches it back to original units. Binary heads are the exception and stay a probability in [0,1]. verified — phoenix/xrex/models/recsys_model.py.

E. The index mapping of the gender categorical embedding changed without notice.

The user_gender index changed from the hardcoded Male=2, Female=1, other=0 to using the proto enum integer as is. The file is phoenix/crates/common/xai-recsys/src/util.rs (+17/−5).

// before
categorical_features[USER_GENDER] = match pb::Gender::try_from(ctx.user_gender) {
    Ok(pb::Gender::Male) => 2,
    Ok(pb::Gender::Female) => 1,
    _ => 0,
};
// after
categorical_features[USER_GENDER] = ctx.user_gender as i16;

A new regression test, user_gender_is_raw_proto_enum, pins that the three values (Male, Female, Unknown) must match g as i16 exactly.

Why it matters: If the proto Gender enum is UNKNOWN=0, MALE=1, FEMALE=2 (typical proto practice), the embedding indices of Male and Female are swapped. The previously trained embedding table is reinterpreted with misaligned indices, so until retraining, the gender signal in serving has its sign flipped. Also, non-binary values were previously lumped unconditionally into 0, but they now take the index the proto enum defines. The proto definition needs to be rechecked at the open-source mirror point. For a silent embedding index change like this, the open question is whether retraining ran alongside it. Gen4+22 itself is not a code change that forces a training reset to account for this migration, so the actual deployment point is what to watch inferred. verified — phoenix/crates/common/xai-recsys/src/util.rs.

F. The H100 training attn kernel override in the ranking config was also finally switched to the CUTe DSL kernel.

The value of _H100_OVERRIDES.attn_impl in phoenix/xrex/configs/xrecsys.py changed from pallas_ranker_varlen_attn to cutedsl_ranker_varlen_attn. In Gen4+21 (2d4a03c), the H100 override in xrecsys_two_tower.py had already been unified to the CUTe DSL. This time the override in xrecsys.py (the ranking config lineage) was also cleaned up to cutedsl_ranker_varlen_attn. In Gen4+15 (9b0dc31), the SM80 forward and backward for H100 were open-sourced, and since then the H100 kernel override has matched GB300 in both config lineages. verified — phoenix/xrex/configs/xrecsys.py (+13/−1).

G. DSCP=InferenceCritical was set on the VMRanker gRPC client.

One line, .dscp(Some(Dscp::InferenceCritical)), was added to the LoadBalancedChannel::builder chain in home-mixer/clients/vm_ranker_client.rs (+2/−1). DSCP (Differentiated Services Code Point) is a QoS field in the IP header, used on network paths for traffic classification and prioritization. InferenceCritical first appears in this commit (other clients not confirmed).

Why it matters: This setting tags VMRanker calls with the highest-priority tier of the data-center network, to suppress latency inflation even under congestion. It pairs with the recent cleanup in Gen4+8 that reduced the VMRanker path to a thin layer passing rank server results straight through, and it is evidence that a judgment that VMRanker is "critical to home ranking tail latency" has been deployed inferred. verified — home-mixer/clients/vm_ranker_client.rs.

H. The ads brand-safety V2 verdict gate was rolled back in the mirror.

In the feature-switch mirror of home-mixer/params/param.rs, EnableAdsBrandSafetyVerdictV2 changed true → false. Until then, V2 had been on a path to being folded into the production verdict. compute_verdict_v2 was introduced in Gen4+2 (2026-08-17), and BrandSafetyVerdict::HighRisk was introduced in Gen4+7 (2026-08-24). This transition suggests that the expansion caused problems in measurement or is under review. Because ads_injection_logging_side_effect.rs in Gen4+7 (2026-08-24) had already exposed the EnableAdsBrandSafetyVerdictV2 experiment bucket in logging, it is plausible that per-bucket measured metrics led to the regression to V1 inferred. The mirror sync tick was also updated: 2026-09-14T16:22:15Z → 2026-09-15T16:25:02Z. verified — home-mixer/params/param.rs (+2/−2).

I. The Grox reply-spam classifier now uses two models depending on the thread follower count, and the task threshold was also raised.

Three changes came together.

  • Constants were added in grox/flows/reply_spam/constants.py (+3).
    • GEMMA_REPLY_SPAM = "oai-gemma4-26b-reply-spam": presumably a dedicated fine-tuned variant.
    • GEMMA_REPLY_SPAM_MIN_FOLLOWERS = 150_000: model routing threshold
    • GROK_GEMMA_FOLLOWER_SPLIT = 180_000: task eligibility threshold
  • In grox/flows/reply_spam/classifier_simple_reply_scorer.py (+22/−4), SimpleReplyScorer has two samplers, oai_gemma4 and oai_gemma4_reply_spam.
    • _sampler(post) looks at the max follower count of the thread root, post.ancestors[0], and the thread tail, post.ancestors[-1]. If this value exceeds 150k, it selects the dedicated oai_gemma4_reply_spam; otherwise it selects the default oai_gemma4.
    • That is, the bigger the thread's reach, the more it is routed to the dedicated fine-tuned model.
  • In grox/flows/reply_spam/task_filter.py (+3/−2), both task thresholds changed 150000 → GROK_GEMMA_FOLLOWER_SPLIT (180000).
    • The targets are TaskSpamFilter.FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION and TaskReplyRankingFilter.FOLLOWER_COUNT_THRESHOLD_FOR_REPLY_RANKING.
    • The previous value was 150k from the 2026-09-08 commit (Gen4+17). Compared with 100k in Gen4+7, it is an 80% increase.

It matters that the two thresholds differ.

  • Task eligibility is 180k. Only threads where both the reply target and the thread root have 180k followers or fewer enter the spam verdict pipeline verified — task_filter.py:78-85.
  • The model routing threshold is 150k. The dedicated model is used above 150k.
  • So as of this commit, the dedicated model is used only for threads in the 30k band between the two thresholds (above 150k and up to 180k) verified — classifier_simple_reply_scorer.py:35-45.

Why it matters: The direction is to use a dedicated fine-tuned model on the judgment that reply-spam patterns in large-account threads have a different statistical distribution from ordinary spam inferred. Read together with the coordinated_spam threshold change from 10k to 30k in Gen4+21, the whole spam pipeline is being subdivided toward "handling high-follower and low-follower bands with different models and criteria" inferred. verified — grox/flows/reply_spam/constants.py, grox/flows/reply_spam/classifier_simple_reply_scorer.py, grox/flows/reply_spam/task_filter.py.

J. Grox adds the quoted author's handle and a self-quote marker to the quote-post wording it passes to the LLM.

A by @handle notation and a self-quote marker were added in grox/core/lm/post.py (+10/−1). The thread renderer that feeds the LLM input now improves the opening sentence of a quote post as follows.

  • If the quoted user has a handle, it writes "This Post quotes Post <id> by @<handle>; the quoted content below is embedded within this Post".
  • If the quoter has the same handle as the quoted user, it adds a marker such as "...by @<handle> (this Post's author, quoting their OWN post)".

Why it matters: At the prompt layer, it gives grox judges such as reply spam and PtOS a hint to distinguish semantically between a "quote that is actually a self-quote (self-QT)" and a "quote of someone else". Because self-QT is one of the patterns observed in spam traffic (self-boosting to expand reach), this marker is expected to raise verdict accuracy. verified — grox/core/lm/post.py (+10/−1).

K. The AES verdict outcome now carries the score-run identifier for audit tracing.

This change passes score_id into the AES decision_outcome, and it lands in abuse-enforcement-service/service-lib/src/lib.rs (+20/−1). decision_outcome() emits ScoreResult.score_id (the score-run identifier) as is in the outcome JSON payload. Two tests pin this behavior. A score_id field assertion was added to decision_outcome_json_mirror_carries_funnel_fields, and the legacy empty string also passes.

Why it matters: It logs the verdicts AES executed in a state that can be joined with the original score run. It gives a join key to back-reference a policy-violation verdict to a specific score batch and to use when handling verdict reproduction, audit, and rollback. verified — abuse-enforcement-service/service-lib/src/lib.rs.

L. String conversion of VF enums was unified with a macro, and behavior did not change.

13 VF enums were unified with the strum::IntoStaticStr macro (7 files, +53/−184) verified — comparison of visibility-filtering/ between 2d4a03c and fad2f71. The per-file line changes are below.

  • visibility-filtering/safety_label_source/metrics.rs (-76 net)
  • safety_label_source/types.rs (-25 net)
  • rules/registry.rs (-9 net)
  • hydration/metrics.rs (-10 net)
  • twemcache/metrics.rs (-11 net)
  • reference_compare.rs(+0/−0), rules/metrics.rs(+0/−0)

The hand-written as_str() implementations were removed from these enums.

  • Target enums: RequestOutcome, SourceOutcome, CacheTier, CacheResult, ManhattanResult, BatchStage, ConnEvent, HydratorOutcome, SafetyLevel, LabelSource, FallbackReason, FailureKind, WarmKeyResult

#[derive(strum::IntoStaticStr)] and #[strum(serialize_all = "snake_case")] take their place. Call sites changed from .as_str() to .into() or <&str>::from(...). It is a pure refactor, and the observability metric label strings are unchanged (the serialization rule is the same snake_case). verified — visibility-filtering/safety_label_source/metrics.rs.

Undisclosed: the items below did not surface in this commit.

  • When a config appears in which purchase_value_loss_weight is actually set to 0 or above in mparams
  • Whether the serving ranking's ContClickDwellTimeWeight value was adjusted to match the default switch to enable_click_dwell_time=True and click_dwell_loss_weight=1.0 (the current param mirror is unchanged)
  • Whether the user_gender index mapping change was deployed paired with retraining of the production embedding table
  • The reason for the EnableAdsBrandSafetyVerdictV2 rollback
  • The measured latency improvement of VMRanker DSCP=InferenceCritical

Gen4+23 (2026-09-17, 42266f3) — Introduced FavHoldoutFilter; click-dwell scoring redefined from a low-fav penalty to a dwell × click product; cold-start Treatment arm drops the MoE gate and max age 24h→48h; Phoenix proto vacates LLM_JUDGE_* after 4 days and adds CLIENT_TWEET_LIVE_STREAM_REPLY; ads delayed conversion Tier 2/3 (20 kinds) added; VF's own twemcache (2,900 lines) removed and migrated to the unified xai_cache; VF evaluate_tweets RPC and Thrift Action response decoder introduced; UTH daily takedown label reproduction infrastructure

With this commit, home-mixer gained a holdout filter that withholds a post from up to 15% of viewers depending on the post's fav (like) count bucket, and in the slot of the ranking's low-fav penalty it gained an option that multiplies the dwell prediction by the click prediction. Both the new filter and the product option are off by default verified (daily).

The change is +2288/−3627 across 64 files, and the large number of deletions comes from removing the whole VF twemcache subtree (a little over 2,900 lines) verified — xai-org/x-algorithm@42266f3.

A. A new filter, FavHoldoutFilter, was added to home-mixer. The new file is home-mixer/filters/fav_holdout_filter.rs (166 lines, new). This filter is wired in home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs after InventoryHoldoutFilter. The holdout probability is determined by a 23-tier stepped table of fav count.

const STEPS: &'static [(i64, u32)] = &[
    (10000, 15), (5000, 15), (3000, 15), (2000, 14), (1500, 13), (1000, 13),
    (750, 12), (500, 11), (300, 10), (200, 9), (150, 8), (100, 7),
    (75, 7), (50, 6), (30, 6), (20, 5), (15, 5), (10, 5),
    (7, 4), (5, 4), (3, 3), (2, 3), (1, 2),
];

If fav_count <= 1, the rate is 0% and the post is shown to every viewer. Above that it rises in steps to a maximum of 15% at 10K+ fav. The holdout decision reuses InventoryHoldoutFilter::is_held_out(post_id, viewer_id, percent). This function computes a 100-modular bucket of (post_id, viewer_id) using splitmix64 double mixing and holds the post out if the value is < percent. In other words, the same post is deterministically held out for the same viewer, and even for the same post only a different subset of viewers is removed. It is off by default with Param EnableFavHoldout=false.

B. The holdout-rate params of InventoryHoldoutFilter were confirmed. An item that had long remained undisclosed was resolved. home-mixer/filters/inventory_holdout_filter.rs references three params: InventoryHoldoutOriginalsPercent, InventoryHoldoutRepliesPercent and InventoryHoldoutRetweetsPercent. The post-kind mapping is retweeted_tweet_id → Retweet, in_reply_to_tweet_id → Reply, and anything else → Original, and a quoted post is treated as Original. The gate opens when EnableInventoryHoldout=true and at least one of the three values is >0. A retweet is judged on the original tweet_id, so it shares the holdout decision with the original.

C. The EnableClickDwellLowFavRatePenalty family was deleted from the ranking score and EnableCdwellOnImpr (dwell × click product) was introduced. The changed files are home-mixer/scorers/ranking_scorer.rs (+10/−32) and home-mixer/params/param.rs. Five fields previously held by ScoringWeights and the five corresponding params were deleted. These fields had existed since the Gen4 release (47c1bcd) verified — param.rs:387-416. The deleted fields are enable_click_dwell_low_fav_rate_penalty and baseline/alpha/floor/cap, and a single enable_cdwell_on_impr: bool took their place.

fn click_dwell_term(&self, scores: &PhoenixScores) -> Option<f64> {
    if !self.enable_cdwell_on_impr { return scores.click_dwell_time; }
    match (scores.click_dwell_time, scores.click_score) {
        (Some(cd), Some(click)) => Some(cd * click),
        (cd, None) => cd,
        (None, _) => None,
    }
}

The previous function low_fav_penalized_click_dwell computed a heuristic penalty that lowered the dwell prediction when the fav rate was low. It was renamed click_dwell_term, and its computation was simplified to click_dwell_time × click_score. The gate observation label also changed gate.click_dwell_low_fav_rate_penalty → gate.cdwell_on_impr. It is off by default with Param EnableCdwellOnImpr=false.

D. Two gates were relaxed together in the Treatment arm of the author cold-start scorer. The changed files are home-mixer/scorers/author_cold_start.rs (+7/−50) and home-mixer/params/param.rs.

  • Previously, ViewerArm::Treatment eligibility was corpus == Treatment && is_phoenix_moe(c). In Gen4+23 the is_phoenix_moe(c) AND condition was dropped, so it now checks only corpus == Treatment.
  • The separate function cold_start_freshness_eligible(arm, c, max_age), which applied an age cap only to the Treatment arm, was deleted. Now apply_cold_start applies the filter duration_since_creation_opt(c.tweet_id).is_some_and(|age| age <= params.max_post_age) to all arms.
  • The default of ColdStartMaxPostAgeSecs was also increased from 86400 → 172800 (24 hours → 48 hours).
  • The regression tests were also cleaned up, with renames.
    • treatment_viewer_cold_starts_treatment_moe_only → _treatment_authors_only
    • treatment_skips_post_older_than_max_post_age → skips_post_older_than_max_post_age
    • control_ignores_max_post_age was deleted.

E. Two LLM_JUDGE_* entries in the Phoenix proto ActionName were vacated after 5 days. The changed files are phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto and its Python mirror. LLM_JUDGE_RELEVANT_TO_SEARCH=107 and LLM_JUDGE_NOT_RELEVANT_TO_SEARCH=108, introduced in Gen4+20 (6bb4594, 2026-09-12), were deleted. CLIENT_TWEET_LIVE_STREAM_REPLY=107 and RESERVED_108 took their place.

Why it matters The direction of porting LLM judge labels as separate actions onto the search relevance axis was readjusted. We infer that it did not carry through to production inferred.

F. Ads delayed conversion attribution was expanded to 3 tiers. In the same proto, reserved 214 to 252 was narrowed to reserved 234 to 252, and 10 kinds each for tier 2 and tier 3 went into 214-233. Details are in the Gen4+23 entry of the ads conversion action section. The lag definitions and loss weights of tier 2 and tier 3 are not stated explicitly in this commit.

G. The VF sub-repo's own twemcache client was removed entirely. Eleven files under visibility-filtering/twemcache/, a little over 2,900 lines in total, were deleted. The top of safety_label_source/twemcache.rs changed from use crate::twemcache::{Key, TwemcacheClient, TwemcacheError, Value} to use xai_cache::{CacheClient, KVCacheError, Key, Value}. The FallbackReason mapping was unified as KVCacheError::Timeout(_) → Timeout, Backpressure → Backpressure, _ → Other. This reads as moving the twemcache protocol that VF maintained itself (including accrual, discovery, host_pool and ring) to the company-wide KV cache abstraction.

H. The VF service gained an evaluate_tweets RPC and a Thrift Action payload decoder. The new file is visibility-filtering-client/evaluated.rs (122 lines, new), and async fns were added to the traits in vf_client.rs and tweet_safety_label.rs. The client test stub returns Err(Status::unimplemented("unused")), and the VF server's evaluate_tweets also returns Err(Status::unimplemented("EvaluateTweets is not served yet")) verified — visibility-filtering/server.rs:50-56, vf_client.rs:958-970. So only the interface is open, and the actual implementation is pending. evaluated.rs defines the following:

pub enum EvaluationResult { Evaluated(Box<Action>), NotEvaluated, Failed }
pub const MAX_ACTION_BYTES: usize = 64 * 1024;
pub fn decode_action(bytes: &[u8]) -> Result<Action>

The decoding rules are as follows:

  • 64KB payload cap (all four caps of TConfiguration are 64KiB)
  • Compact Thrift protocol
  • No trailing bytes
  • Action::NotEvaluated is rejected

Why it matters The VF response interface has begun to expand toward returning a complete Thrift Action object to the client, not only the sigil enum (FilteredReason) inferred. Read together with the Gen4+20 xDS LDS listener migration, VF is moving from a thin filter to a richer policy verdict service.

I. A daily takedown label reproduction job stack for Under-the-Hood was added. This stack recomputes the audit labels of the Under-the-Hood tool every day and applies them to a Strato column. There are four new Scala files in under-the-hood/scalding/:

The files changed alongside are underTheHoodLabels.strato +164, UthPctdAccountTakedownEventsJob.scala +392/−63 and UthUserMonthMhPublisherJob.scala +140/−34. Because the canon covers audit tool serving rather than ranking, this line is only mentioned and not analyzed in detail.

J. A filter that redacts takedown reasons was added. This filter redacts unenforced takedown reasons so that they are not exposed on the read path. The related files are takedowns/gizmoduck/RedactUnenforcedTakedownsFilter.scala (81 lines) and ReadFilters.scala (39 lines). There are also tweet-entity-service/TakedownReasonsTransform.scala (73 lines) and TakedownReasonsColumn.scala (25 lines).

Undisclosed The following are not yet public:

  • The point at which the mirror values of EnableFavHoldout and EnableCdwellOnImpr change, and the bucket definition of the actual deployment experiment
  • Which training config's target head CLIENT_TWEET_LIVE_STREAM_REPLY (107) is added to (home vs. search)
  • The lag-window definitions and training loss weights of delayed conversion tier 2/3
  • When the server-side implementation of the VF evaluate_tweets RPC lands
  • Whether relaxing the cold-start Treatment arm gates was triggered by measured A/B results or is preparation for the next experiment setup

Gen4+24 (2026-09-18, c279172) — Ads delayed feedback training unified under sample-source 3-stream masking; purchase-value loss narrowed to the early-relabel slice; Phoenix wire proto adds EngagementCounts (user×ads window counts, ad×window counts) and SearchLexicalMatch (matched-word fraction, exact phrase) signals; grox introduces a spam-clone detection flow that compares a reference video bundle against candidate videos with a VLM; per-topic cluster/zone override for abuse-enforcement Kafka topics; Phoenix bundle export separates request-vs-template contracts

In ads ranking training, three delayed-feedback settings were merged into one switch that is off by default, and turning it on determines, per head, which of three sample streams (fresh, early-relabel, late-split) is trained on. The purchase-value regression loss was narrowed to train only on early-relabel samples, and grox gained a flow that compares a reference video bundle with candidate videos using a VLM to identify spam clones verified (daily).

30 files changed, +1491/−154 lines verified — xai-org/x-algorithm@c279172.

A. Three config options were merged into ads_head_masking, and sample-source is split into three streams: FRESH, EARLY_RELABEL and LATE_SPLIT. The three config options condition_conversion_on_click, train_view_through_heads and split_head_training_by_source were all deleted and replaced by a single ads_head_masking: bool = False. All three option references were also removed from RecsysAggregatedModelConfig, build_metric_masks and trainer_recsys. When ads_head_masking=True, the new function ads_head_masking_factor(targets, source_id, num_actions) assembles the loss and metric masks by the following rules:

  • Engagement heads (everything except the CT, VT and twin indices) train only on FRESH_STREAM_ID(=0) samples.
  • CT heads (CLICK_CONDITIONED_ACTION_INDICES) train only on EARLY_RELABEL_STREAM_ID(=1) samples that have a click.
  • VT heads (VIEW_THROUGH_ACTION_INDICES) train only on EARLY_RELABEL_STREAM_ID samples that have no click.
  • Twin heads (new ADS_LATE_WINDOW_TWIN_HEAD_INDICES, 10 delayed actions) train only on LATE_SPLIT_STREAM_ID(=2) samples that have a click and no fresh counterpart.

The twin head list is the 10 base *_DELAYED kinds (204-213) that Gen4+12 introduced on 2026-09-01. Tier 2's 214-223 and tier 3's 224-233, newly created by Gen4+23 on 2026-09-17, have not yet been added to the twin list.

The ADS_LATE_WINDOW_TWIN_SUPPRESSOR dictionary, added at the same time, specifies the fresh-stream counterpart index for each twin head. For example, ADS_PURCHASE_CONVERSION_DELAYED → ADS_PURCHASE_CONVERSION and ADS_WEB_CONVERSION_DELAYED → ADS_ATTRIBUTED_CLICK_CONVERSION; note that the suppressor of web-conversion delayed is attributed-click-conversion. ads_head_masking_factor looks up this suppressor value with no_early_counterpart = 1 - targets[:, :, suppressor_index] and multiplies it into the late-split twin mask. If the fresh-stream counterpart has already been observed for the same (user, ad) sample, the late-split twin head is excluded from training to avoid duplicate observation.

The metric slices were also redefined. build_metric_masks lost the condition_conversion_on_click and split_head_training_by_source parameters and takes only ads_head_masking. A new slice late_split_no_early (= mask × late × click × no_early) was added, and the existing fresh, delayed_clicked and delayed_non_clicked remain. verified — phoenix/xrex/models/ads_head_masking.py (67 lines, new), phoenix/xrex/data/recsys/ads_late_window.py (29 lines, new), phoenix/xrex/models/recsys_model.py (+50/−55), phoenix/xrex/train/trainer_recsys.py (+19/−2).

Why it matters A training code path that splits delayed-feedback labeling into three streams (fresh, early, late) was introduced verified. However, ads_head_masking is False by default and no public config in this commit turns it on, so the point at which it was applied to production training is unknown verified — c279172 phoenix/ grep. Of the three booleans, condition_conversion_on_click has existed since the Gen4 release, and train_view_through_heads and split_head_training_by_source were added by Gen4+6 (recsys_model.py in 47c1bcd and 28e414f). In effect, this commit tidied up the fact that the effective combinations of the three booleans are a mapping of a single categorical sample-source; the earlier practice of leaving the 8 view-through kinds out of training with the default train_view_through_heads=False is gone. With ads_head_masking=True, view-through heads are automatically trained on the "no click" slice of the early-relabel stream.

B. The purchase-value loss was narrowed to train only on the EARLY_RELABEL slice. purchase_value_valid_mask was narrowed from sample_source > 0 to sample_source == EARLY_RELABEL_STREAM_ID. The purchase-value regression is not trained on the late-split stream. The loss stat name also changed from purchase-value-* to purchase-value_delayed_clicked-*. The slice scope (clicked samples among delayed samples) was put into the stat name.

The new function purchase_value_smoothed_stats emits EMA-smoothed loss, ratio-mae, target-ratio, baseline-mean-usd, usd-mae and calib. The default smoothing windows are (1 << 28, 1 << 30), which are 2^28 ≈ 268 million samples and 2^30 ≈ 1.07 billion samples, respectively. trainer_recsys._purchase_value_ema_keys keeps a shape=(10,) tensor as EMA state for these two windows. If the shape does not match when the checkpoint is reloaded, it is reinitialized to zeros.

Two new stats are also emitted verified — phoenix/xrex/models/loss_recsys.py (+75/−10), phoenix/xrex/train/trainer_recsys.py (+19/−2).

  • purchase-value_delayed_clicked-usd-mae is the MAE in dollars.
  • purchase-value_delayed_clicked-calib is the sum of predicted ratios ÷ the sum of actual ratios, a calibration against the advertiser baseline.

Because the default purchase_value_loss_weight=0.0 is kept, actual activation is still opt-in verified (same source).

Why it matters The ads revenue regression loss of Gen4+22 was narrowed from the practice of training on everything "inside the delayed observation window" to training on only the "short-delay (early) slice". The late-split slice trains only the twin classification head and does no regression. X ads ranking settled into a two-branch stack. For short-lag observations it trains purchase value as a regression, and for long-lag observations it trains only whether a conversion happened, as a classification.

C. Two new channels, EngagementCounts and SearchLexicalMatch, were added to the Phoenix wire proto. Both channels were applied identically to the two proto mirrors (phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto and phoenix/python/common/xai-proto/proto/recsys.proto).

The EngagementCounts message carries an (action × time-window) count matrix in the request payload.

message EngagementCountRow { int32 action = 1; repeated uint32 counts = 2; }
message EngagementCounts {
  bool present = 1;
  repeated uint32 window_secs = 2;   // list of time window lengths
  repeated EngagementCountRow rows = 3;  // per-window counts for each action
  int64 updated_at_ms = 4;
  int64 window_end_ms = 5;
}

This message is connected to two slots:

  • PredictNextActionsRequest.user_ads_engagement_counts = 24 is the user's ads engagement history.
  • AdInfo.engagement_counts = 23 is the engagement history of the ad itself.

Server-side training and encoding are not in this commit.

SearchLexicalMatch was attached as TweetInfo.searchLexicalMatch = 38 and carries the following fields:

  • float matchedWordFraction is the fraction of query words that appear in the tweet.
  • bool exactPhrase indicates whether the query string appears as an exact match.

This commit does not state explicitly where xrecsys_search consumes this signal.

Why it matters EngagementCounts is, after ConvAssetIds (ad creative asset ID history) in Gen4+9, the second channel through which recent engagement statistics of a user–ad pair are passed to the ranker as explicit features. SearchLexicalMatch is the first case of a lexical match signal being added as a wire field to the X search ranking line. Instead of the EarlyBird BM25 family, it was simplified to two axes (matched fraction, exact phrase). verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto.

D. A new flow, safety_ptos_media_injected_adult_infrared_video_spam_detection, was added to the grox safety pipeline. Four new files (146+188+55+44 = 433 lines) were added, and small additions were made to the constants, prompt, rate-limit and generator files. This flow is a streaming task sourced from the Kafka topic safety-ptos-media-injected-adult-infrared-video-spam-detection.

The eligibility filter passes only posts that satisfy all of the following conditions:

  • There is a user.
  • The post or quoted post has a video.
  • It is a high-engagement post whose fav count is at or above media_hydration.deluxe_fav_count_threshold.
  • It has not already been flagged as NSFW.

For posts that pass, the reference video bundle in the blobstore is loaded with MediaReferenceBundleLoader.current("media_injected_adult_infrared_video_spam"). The bundle specification is as follows:

  • Up to 10 videos × up to 10 frames each × up to 2 MiB per frame
  • Refreshed periodically with refresh_interval_s=900 (15 minutes)
  • Identity managed with a 12-character SHA-256 digest

As the VLM, GROK_4_1_FAST_HEDGEHOG_CRITICAL (vision sampler) is called with a 60-second timeout. The prompt interleaves several reference video storyboards and then renders the candidate post with PostRenderer. It then requires a verdict in the format <json>{"violation": bool, "reason": ...}</json>.

If MediaInjectedAdultInfraredVideoSpamVerdict parses successfully and violation=True, the violated policy is added to SafetyPtosState. TaskWriteSafetyPostAnnotationsResultSink sends the result to the safety pipeline sink. verified — grox/flows/ptos/plan_safety_ptos_media_injected_adult_infrared_video_spam_detection.py (44 lines), grox/flows/ptos/task_safety_ptos_media_injected_adult_infrared_video_spam_detection.py (188 lines), grox/core/data_loaders/media_reference_bundle.py (146 lines).

Why it matters For near-duplicate video detection, embedding matching is standard, but instead of embeddings X puts reference storyboard frames directly into the VLM prompt and leaves the visual matching verdict to the VLM. The reference bundle is refreshed from the blobstore every 15 minutes. So when the operations team adds a new clone candidate video to the bundle, it is reflected in verdicts without a separate redeployment. infrared appears to refer to a specific capture mode (for example, adult video clones shot with night vision), but the prompt template that would hold the exact category definition is not public inferred.

E. The request contract and the template contract were separated in Phoenix recsys_bundle_export. A new constant REQUEST_BATCH_KEYS (a set of about 30 batch keys, in the user_*, history_seq.* and candidate_seq.* families) was defined verified — phoenix/xrex/train/recsys_bundle_export.py (+104/−7). _input_spec classifies each batch key into three kinds verified (same file).

  • packing_layout.* is packing_layout.
  • If it is included in REQUEST_BATCH_KEYS, it is source="request" and is filled from the request payload.
  • The rest are source="template". The uniform constant from example_data is baked into the bundle as the fill value.

A non-uniform template raises an error. That is, the request contract and the constant contract of the serving bundle are explicitly separated. verified (same file)

F. Per-topic cluster/zone overrides were added to the Kafka routing of abuse-enforcement-service. Consumer and producer configs can now override the cluster/zone per topic instead of using the service-wide mTLS cluster/zone. The new metric abuse_enforcement_kafka_consumer_start_total{topic,cluster,result} labels the consumer start result at boot time. A filter was also added so that consumer_error_total counts only the set of watched topics, and errors on topics that are not watched do not trigger the watchdog. verified — abuse-enforcement-service/service-lib/src/lib.rs (+390/−56), abuse-enforcement-service/service-lib/src/growthbook.rs (+35/−6), abuse-enforcement-service/service-lib/src/metrics.rs.

G. There was a small cleanup in grox.

  • In _collect_urls, user URLs were pushed behind card URLs. The domain dedup changed from set to dict[str, None], which preserves insertion order. So the order in which domains are exposed in the downstream verdict is stable.
  • In the web_search tool of eapi_sampler, allowed_domains is truncated to the top 5 (MAX_SEARCH_DOMAINS = 5).
  • The name of the reply spam log label changed from "lower than 1000 follower bucket" to "Gemma bucket". This unifies the notation that refers to the Gemma score=0 verdict after the follower two-way routing of Gen4+22.

H. The Brazil2026 election filter was updated for maintenance. Twelve new accounts were hardcoded, and 4 "no live account found" comments were updated. The filter semantics did not change. The practice of updating from TSE dadosabertos continues. verified — home-mixer/filters/brazil_2026_election_filter.rs (+143/−4).

I. The thrift source of Phoenix Cargo moved from a git tag to the crates.io registry. The version is the same at 0.24.0, but a checksum (2b29bdae81cc2dbc498a720e2734e08f26e388c791fca6e95464d41001913714) lock was added, which strengthens supply chain reproducibility. verified — phoenix/Cargo.toml, phoenix/Cargo.lock.

Undisclosed The following are not yet public:

  • When ads_head_masking=True is switched on in mparams (which training config activates it first)
  • When tier 2/3 delayed actions are added to twin masking
  • Where EngagementCounts is consumed (which embedding layer it passes through)
  • Which head or score of xrecsys_search SearchLexicalMatch attaches to
  • The actual deployment time of the media_injected_adult_infrared_video_spam_detection flow and the exact definition of the infrared category
  • Whether the purchase-value EMA smoothing windows (2^28, 2^30) are exposed on an actual dashboard

Gen4+29 (2026-09-25, bf7db1b) — VF hydration rewritten as a data-driven Plan/Sources/Execute three-layer design + Hydrator enum 7 → 17 variants + about_this_account/tfe_top_country Strato column added to the hydration budget + 1,493-line single file rules/golden_corpus split into 15 category files + models/region.rs (97 lines) introduced with a 9-region catalog

Hydration in visibility-filtering (the stage that fetches the raw data needed for a verdict from backend services in batches and fills it in) was rewritten from one function per hydrator to execution by a data table and a scheduler. Lookups that use the same input against the same backend are merged automatically into one RPC, and the viewer country is looked up directly from About This Account data and used for the conversation-control country allowed-list verdict verified (daily).

87 files changed, +7,780/−5,759 lines verified — xai-org/x-algorithm@bf7db1b.

A. Hydration was rewritten from a function layer into a data table and a scheduler. Until yesterday (Gen4+28), each hydrator variant had its own function in a separate .rs file, such as conversation_control_hydrator.rs, exclusive_content_hydrator.rs, and socialgraph_hydrator.rs. All of that structure was deleted, and execution moved to the following three files.

  • visibility-filtering/hydration/plan.rs is a new 471-line file. It registers the NodeSpec { source, part, key, label } data of each Hydrator variant through the Hydrator::spec() const fn.
    • Source is the backend, with eight kinds: TesPureCore, TesComposite, TesConversationControl, SafetyLabels, GizmoduckViewer, GizmoduckAuthor, Flock, and ViewerCountry.
    • Part is what is read inside that backend, and has Column, Fields(&[QueryFields]), and Edge(Graph, EdgeDirection).
    • KeyOrigin is the origin of the input key, and has RequestTweets, Viewer, PureCoreAuthor, PureCoreReplyRoot, ExclusiveConversationAuthor, ConversationRoot(&[ConversationControlArm]), and ViewerForCoAllowedList.
    • HydrationPlan::new(level, hydrators) groups the requested hydrator set into (source, input) pairs and merges hydrators with the same backend and the same input dependency into one RPC.
    • Group::fields() builds the union of the field sets, and Group::edges() merges socialgraph edge queries.
    • Three compile-time asserts (inputs_precede_nodes, author_keys_come_from_pure_core, Hydrator::VARIANTS.len() <= u32::BITS) lock the scheduling-order and bitset-width invariants.
  • visibility-filtering/hydration/execute.rs is a new 1,280-line file. HydrationPlan::hydrate(sources, request) schedules the dependency graph with FuturesUnordered. The algorithm proceeds in the following order.
    • Initialize the ready queue with the groups whose input.is_none().
    • Wrap the RPC of each ready group in a Call<'a> future and push it to running.
    • Store the result of a completed group in Store<'a>. The result is a Reply enum (PureCores, Tweets, Controls, Labels, Viewer, Authors, Edges, ViewerCountry).
    • Promote to ready the child groups that had this group as a dependency.
    • Repeat this process.
  • visibility-filtering/hydration/sources.rs is a new 445-line file. The Sources trait defines eight methods, one for each of the eight backends.
    • The methods are pure_cores(tweet_ids), tweets(tweet_ids), conversation_controls(tweet_ids), safety_labels(tweet_ids), viewer(viewer_id, fields), users(user_ids, fields), select_edges(viewer_id, queries), and viewer_country(viewer_id).
    • The ProdSources struct holds TES, composite TES, gizmoduck, socialgraph, about_this_account, safety_labels, PureCoreFallbackCache, and AuthorFallbackCache.

The Hydrator enum grew from 7 variants (Gen4+28) to 17 variants. 13 new names were added and 3, ExclusiveContent, Author, and Relationship, were removed, so the net increase is 10 verified — hydration/mod.rs in 44d37eb and bf7db1b. In the list below, the variants shown in bold are the ones newly added this time. The Hydrators bitset stays a single u32, and an assert checks 17 ≤ 32.

  • PureCore·Tweet·ConversationControl·TweetSafetyLabels·ViewerProfile·AuthorSafety·AuthorLabels·Follows·Blocks·Mutes·MuteRetweets·BlockedByAuthor·BlockedByReplyRoot·SuperFollowsExclusive·RootFollowsViewer·SuperFollowsRoot·ViewerCountry

B. The plan is fixed per SafetyLevel and pinned by golden tests. The plan that RuleEngine::plan(level) builds for each SafetyLevel is locked by golden strings verified plan.rs:422-451. The number of calls per SafetyLevel is as follows verified (same file).

  • filter_all 1 call
  • timeline_home 7 calls
  • timeline_home_recommendations 7 calls
  • timeline_home_hydration 10 calls
  • immersive_expanded_recommendations 7 calls

For example, in the 10-call graph of timeline_home_hydration, each call runs once the following conditions are met verified (same file).

  1. The five calls tes/get_tweet_core_datas, tes/get_tweets_for_visibility, conversation_control/get_conversation_controls, safety_labels/get, and gizmoduck/get_viewer_data start immediately, needing only the request tweet ids and the viewer id.
  2. gizmoduck/get_users runs after pure_core completes. This call looks up the union of the author_safety and labels fields.
  3. blocked_by/batch_check_blocked_by runs after pure_core completes.
  4. exclusive_content/batch_check_super_follows runs after tweet completes.
  5. The call that merges conversation_control/batch_check_followed_by and batch_check_super_follows, and conversation_control/tfe_top_country, run after conversation_control completes.

C. An About This Account client (166 lines) that reads the Strato column about_this_account/tfe_top_country.User was added. AboutThisAccountClient::tfe_top_country(user_id) in visibility-filtering/clients/about_this_account_client.rs returns the weighted top country of a user. This result is fed through the ViewerCountry hydrator into the conversation-control country allowed-list verdict. "About This Account" is the transparency UI at the bottom of an account profile in the X app (account creation date, top country, name change history). VF now consumes that data source directly.

D. rules/golden_corpus was split into 15 files. The 1,493-line single file visibility-filtering/rules/golden_corpus.rs was deleted and split again by policy category into 15 files under visibility-filtering/rules/golden_corpus/. The verdict matrix can now be pinned separately for each category.

  • The line counts per file are age_gating.rs 267, author_state.rs 119, baseline.rs 56, builders.rs 63, conversation_control.rs 257, exclusive_content.rs 61, interstitial.rs 334, mod.rs 173, oon_media.rs 218, oon_tweet_label.rs 133, oon_user_label.rs 234, relationship.rs 122, takedown.rs 224, tweet_label.rs 195, and tweet_state.rs 90.

E. models/region.rs, which groups ISO country codes into 10 regions, was added. visibility-filtering/models/region.rs is 97 lines, with 10 regions and 250 country codes without duplicates verified. If the 16-country NSFW gating of Gen4+12 (2026-09-01) was country-level, region-level rules can now be expressed as well. No rule that actually uses this catalog appears explicitly in this commit.

  • AFR (58 countries), AUS (33 countries), CAS (6 countries: AF·KG·KZ·TJ·TM·UZ), EAS (8 countries: CN·HK·JP·KP·KR·MN·MO·TW), EUR (54 countries), NAM (40 countries), SAM (14 countries), SAS (7 countries: BD·BT·IN·LK·MV·NP·PK), SEA (11 countries: BN·ID·KH·LA·MM·MY·PH·SG·TH·TL·VN), WES (19 countries: AE·AM·AZ·BH·CY·GE·IL·IQ·IR·JO·KW·LB·OM·PS·QA·SA·SY·TR·YE)

F. The rules catalog shrank considerably. The per-file changes are tweet_rules.rs −575 lines (692 → 117), author_rules.rs −160 lines (182 → 22), registry.rs +226/−58, and rule_spec.rs +141/−161. The two-way Verdict tree introduced in Gen4+26 (2026-09-22) (Withheld{Drop|Tombstone}|Shown{media?,engagement?}) and the data-driven RuleClause structure are kept, and the rules themselves appear to be moving further into data. Details will be tracked in a follow-up batch.

G. There were also incidental changes.

  • In home-mixer/filters/brazil_2026_election_filter.rs (+22/−2), the list of accounts under Brazilian electoral court orders, introduced on 2026-08-14, was updated slightly. The basis is TSE Electoral Resolution No. 23.610 Art. 28 §1º-A.
  • 34 lines were newly added at the same time to both mirrors of the Phoenix proto recsys.proto, and phoenix/xrex/models/transformer.py changed by +42/−5 and recsys_model.py by +5/−1. This batch did not track what the new fields are.
  • grox gained new files frame_sampler.py (124 lines) and embedding_ranked_key_frames.py (193 lines). Alongside them, key_frames.py +14/−53, media_loader.py +62/−34, and convo.py +112/−61 changed. The 32×32 grayscale scene-cut pipeline opened yesterday was extended to embedding-based ranking.
  • under-the-hood/jetfuel/ gained 8 new observability UI frontend files, about 600 lines. They have no relation to the ranking or retrieval mechanisms themselves.

Why it matters VF hydration was rewritten with a 24-hour half-life. conversation_control_hydrator.rs (310 lines), introduced yesterday, was deleted today. The pattern introduced yesterday, in which a predicate declares its required hydrators with const fn hydrators() and RuleEngine::hydrators_for(safety_level) combines them, was rewritten as a NodeSpec data table and the execute.rs scheduler. There are three substantive changes.

  1. The interface changed from functions to data. In yesterday's structure, execution was still a separate function or file per hydrator variant, but today execution is data as well. Adding a rule no longer requires writing a new hydrator function; it is enough to add one line to the NodeSpec table.
  2. RPC group merging is automated. Hydrators with the same backend and the same input are merged into one RPC automatically. The gizmoduck SAFETY and LABELS field lookups and the socialgraph 4-edge lookup of Follows, Blocks, Mutes, and MuteRetweets are examples. In X's structure, where the hydration budget concentrates on fan-out services such as gizmoduck and socialgraph, this directly affects P99.
  3. ViewerCountry formally entered the hydration budget. Previously, country information was carried in the request context, but now hydration fills it by looking up the about_this_account/tfe_top_country.User Strato column directly. The backend of the About This Account transparency UI has been attached to the VF verdict path.

Gen4+30 (2026-09-26, 4c5cfe8) — overturn-hold gate introduced in abuse-enforcement (5,270 lines) + VF hydration split into execute/store + safety_label_hydrator.rs and tes_hydrator.rs deleted entirely + wingman follows client introduced + rules registry locked as static Policy + Phoenix ads purchase-value target catalog (event × level, 6 combinations) + Phoenix Cold retrieval channel added to the cold-start arm-gate

An overturn-hold gate was added to the abuse-enforcement service verified — daily. It is the last precheck that, while a hold is active, prevents automated verdicts from re-applying a suspension or label that a human reviewer overturned on appeal. On the feed side, candidates from the Cold channel of Phoenix retrieval started to be shown only in the Treatment arm of the cold-start experiment verified (same daily).

This commit changed 80 files, with +9,130/−2,652 lines verified — xai-org/x-algorithm@4c5cfe8.

A. abuse-enforcement gained the overturn-hold gate as its last precheck.

abuse-enforcement-service/service-lib/src/overturn_hold.rs was added as a new 5,270-line file. Its purpose is to prevent automated verdicts from re-applying a suspension or label that a human reviewer overturned while handling an appeal, for as long as that hold is active. The comment at the top of the file states this purpose directly: "the last precheck before a suspend (or a gated label) is dispatched. It exists so we do not re-apply an enforcement that a human reviewer already overturned on appeal".

The gate consists of a mode, a kind, a remote ledger lookup, and a circuit breaker.

  • There are three gate modes (GateMode::Off | Shadow | Enforce). Shadow only runs the verdict and does not dispatch the action, and Enforce actually blocks.
  • There are two gate kinds: GateKind::Suspend, which prevents re-applying a suspension, and GateKind::Label, which prevents re-applying a label. Suspensions are judged separately as two kinds: temporary (GatedAction::Suspend) and permanent (GatedAction::PermSuspend).
  • Active holds are received from a remote ledger. An HTTP client calls /v1/holds/lookup.
    • The timeouts are CONNECT_TIMEOUT of 200ms and DEFAULT_TIMEOUT_MS of 200ms, and the tuning range of the latter is 10–5000ms.
    • The positive cache is 60 seconds (up to 3,600 seconds), and the response body cap is 64 KiB.
    • CACHE_MAX_WEIGHT of the moka cache is 100,000, and CONFIG_PUBLISH_INTERVAL, a separate constant, is 15 seconds.
  • The circuit breaker opens when failures reach BREAKER_FAILURES=5, and after opening it waits for DEFAULT_BREAKER_OPEN=10s. If a reachable error (for example, HTTP 5xx) occurs five times in a row, it backs off by REACHABLE_ERROR_BACKOFF=5s.

The gate is wired into the lib.rs of AES (abuse-enforcement-service) at two points verified — abuse-enforcement-service/service-lib/src/lib.rs:166-509.

  • At startup, the gate is built with HoldGate::from_url(cfg.overturn_hold_ledger_url, ..., cfg.overturn_hold_env), and an Arc<HoldGate> is injected into EnforcementCtx.
  • On the execution path, gated_actions(&specs, &cfg) picks and returns the gate-target actions among the dispatch candidates. Also, strip_held_labels(&mut specs, &gate.strip_labels) removes the names of already overturned labels from the spec.

The verdict result has two statuses.

  • STATUS_HOLD_OVERTURNED = "hold_overturned" is the case where the hold was active and the action was actually blocked.
  • STATUS_HOLD_LOOKUP_FAILED = "hold_lookup_failed" is the case where the ledger lookup failed.

The same outcome dict carries five diagnostic keys: overturn_hold_id, overturn_hold_mode, overturn_hold_head, overturn_hold_expires_at, and overturn_hold_labels_stripped. The dedup retention layer also handles the two statuses separately verified — lib.rs:2469-2501. New entries dedup_retention_for(STATUS_HOLD_OVERTURNED) and dedup_retention_for(STATUS_HOLD_LOOKUP_FAILED) were added verified (same file).

B. State management for VF hydration was separated from the execution file into its own file.

State was split out of execute.rs, which had handled both state and execution, into store.rs, and only execution remains in execute.rs. visibility-filtering/hydration/store.rs is new at 339 lines, and the line count change of execute.rs is −141 lines (+394/−535). store.rs defines the Store struct that holds in-flight results during hydration execution.

It has eight Option<...> fields.

  • pure_cores: tweet skeleton
  • tweets: TweetForVisibility
  • controls: ConversationControl arm
  • labels: SafetyLabelMap
  • viewer: ViewerProfile
  • authors: DecodedAuthor
  • edges: Vec<(Hydrators, RawHydrationBatch<Hydrators>)>
  • viewer_country: Arc<str>

Store::keys(origin: KeyOrigin) -> Vec<u64> extracts and resolves the input keys needed by child hydrators for each origin axis. The origin axes include RequestTweets, Viewer, ViewerForCoAllowedList, PureCoreAuthor, PureCoreReplyRoot, and ExclusiveConversationAuthor. The Store::callable: Hydrators field fixes, as a bitset, the hydrators that can run in this request.

In the same commit, two leftovers from the previous day's commit (Gen4+29) were deleted.

  • visibility-filtering/hydration/safety_label_hydrator.rs is −167 lines, and the file was deleted.
  • visibility-filtering/hydration/tes_hydrator.rs is −79 lines, and the file was deleted.

The two files had become unnecessary when the functionality moved to the data-driven scheduler the previous day, but they stayed for a 24-hour grace period and were removed entirely this time.

C. A follows client was added that moves direct socialgraph calls to the wingman path.

visibility-filtering/clients/wingman_client.rs was added as a new 54-line file verified.

const FOLLOWS_GRAPH: u32 = 1;
const FOLLOWS_FLEET: Fleet<'static> = Fleet {
    wily_path: "/s/wingman/wingman-api-follows",
    authority_template: "wingman.wingman-api-follows.prod.{datacenter}.s2s.twttr.net",
    ...
};

The interface is only WingmanClient::batch_exists_intersect(viewer_id, root_author_ids) -> Option<Vec<Exists>>, and it uses IntersectMode::FwdXRev. FwdXRev is a mode that checks the forward (viewer → author) and reverse (author → viewer) directions at the same time. With this mode, "do the reply root author and the viewer follow each other?" is decided in a single RPC. Related code shrank in socialgraph_client.rs (+43/−27). The FOLLOWS_GRAPH=1 constant shows that wingman holds multiple graphs, but graph 2 and later are not confirmed in this release.

D. The rules registry was rewritten at +206/−203, and a metrics module dedicated to rules was added.

visibility-filtering/rules/registry.rs was effectively rewritten completely at +206/−203. The core is the Policy<'a> struct, which holds the rule set for each SafetyLevel. However, this struct and the five statics below already existed in the same form in Gen4+29 (bf7db1b) verified — registry.rs:23-29, 125-170.

pub(super) struct Policy<'a> { base: &'a [RuleClause], additional: &'a [RuleClause] }
static FILTER_ALL_POLICY: Policy = Policy::new(&[tweet_rules::FILTER_ALL]);
static TIMELINE_HOME_POLICY: Policy = Policy::new(&TIMELINE_HOME_SHARED_RULES);
static TIMELINE_HOME_RECOMMENDATIONS_POLICY: Policy = Policy::with_additional(...);
static TIMELINE_HOME_HYDRATION_POLICY: Policy = Policy::new(&[...]);
static IMMERSIVE_EXPANDED_RECOMMENDATIONS_POLICY: Policy = Policy::new(&[...]);

RuleEngine::select(level) -> &'static Policy references one of the five statics for each requested SafetyLevel. Policy::with_additional(base, additional) merges two slices to build a derived policy. RuleEngine::wired_rule_names(level) returns the list of rule names wired into a policy verified — registry.rs:346-460. The golden test pins, by assertion, the list that wired_rule_names returns for each of the five registered SafetyLevels verified (same file).

visibility-filtering/rules/metrics.rs was also added as a new 58-line file. Until the previous day, the hydration metrics.rs also handled metrics related to rule verdicts, and as verdicts and hydration were separated, the metrics were separated too.

  • In rule_spec.rs (+149/−74), RuleClause and ActionSpec were refactored.
  • filter.rs shrank by −94 lines (+26/−120), and context.rs by −53 lines (+9/−62). As the Verdict tree settles, the filter layer steps back from coordinating rule execution.

E. Phoenix's ads purchase-value target was made explicit as a catalog.

phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs changed by +230/−4. The event × level combinations of the ads purchase-value continuous head were made explicit as a data structure.

pub const ACTION_INDEX: usize = ContinuousActionName::AdsWebCtPurchaseValue as usize;
pub const MAX_EVENT_USD: f64 = 100_000.0;
pub fn is_countable_usd(usd: f64) -> bool {
    usd.is_finite() && usd > 0.0 && usd <= MAX_EVENT_USD
}
pub enum ValueEvent { Purchase, ContentView, AddToCart }
pub enum ValueLevel { ClickThrough, ViewThrough }
pub struct ValueTarget { event: ValueEvent, level: ValueLevel }
impl ValueTarget {
    pub const ALL: [ValueTarget; 6] = [ ... ];
    pub const PUBLISHED: [ValueTarget; 4] = [ ... ];
    pub const REQUIRED: [ValueTarget; 2] = [Self::PURCHASE_CT, Self::PURCHASE_VT];
}

The catalog is as follows.

  • ValueEvent has three kinds verified.
    • Purchase: conversion_type 2, clickhouse purchase
    • ContentView: 19, content_view
    • AddToCart: 12, add_to_cart
  • ValueLevel has two kinds verified.
    • ClickThrough: occurs after an ad click
    • ViewThrough: occurs with an impression only, without a click
  • The 2 REQUIRED targets are purchase_ct and purchase_vt, and the model must predict them verified.
  • The 4 PUBLISHED targets are purchase_ct, purchase_vt, content_view_ct, and add_to_cart_ct, and they are the advertiser webhook targets verified.
  • According to is_countable_usd, an event is excluded from training and evaluation if it exceeds $100,000 USD or is NaN, negative, or infinite verified.
  • ValueTarget::mean(&PurchaseValueBaseline) reads the per-target mean from the baseline (the consumption contract of the PurchaseValueBaseline request field introduced in Gen4+21 was settled this time) verified.

The purchase-value prediction layer was released piecemeal in the following three commits, and in this commit its target axis is fixed as data.

  • Gen4+21 (2026-09-15, ADS_WEB_CT_PURCHASE_VALUE introduced)
  • Gen4+22 (2026-09-16, purchase-value loss formally wired in)
  • Gen4+24 (2026-09-18, purchase-value loss narrowed to the early-relabel slice)

F. The Phoenix Cold retrieval channel entered the cold-start arm-gate.

In home-mixer/scorers/author_cold_start.rs (+45/−6), the helper function is_phoenix_moe was renamed is_arm_gated_retrieval, and the target served_type was widened verified.

fn is_arm_gated_retrieval(c: &PostCandidate) -> bool {
    matches!(
        c.served_type,
        Some(
            pb::ServedType::ForYouPhoenixRetrievalMoe | pb::ServedType::ForYouPhoenixRetrievalCold
        )
    )
}

In Gen4+28 (2026-09-24, 44d37eb), a HOME_COLD=13 channel was added to Phoenix retrieval next to HOME_HOT=14 (13 is the value home-mixer had used as the cold slice tag since Gen4+25), and the 1fav_1day.parquet source was split with a fav<8 & views<500 mask. From this commit, candidates from this Cold channel are shown only to the Treatment arm among the three arms of the cold-start experiment, Holdout, Control, and Treatment. This is the same arm-gating as MoE. The new test cold_retrieval_is_gated_like_moe verifies that the Cold candidate score stays 0 in Holdout and Control and 5.0 in Treatment.

G. The remaining changes are incidental.

  • The Phoenix async embedding CUDA layer shrank: async_emb_api.cc +123/−191, async_emb_comm.cc +86/−63. The gradient exchange API surface is being reorganized to be data-centric.
  • Optimizer and trainer boilerplate shrank in Phoenix trainer_recsys.py +48/−200 and rowwise_adagrad.py +51/−120. phoenix/xrex/utils/recsys_async_emb.py was renamed (rename+54).
  • VF hydration files also changed together.
    • visibility-filtering/hydration/tes_composite.rs +29/−32, hydration/plan.rs +48/−12 (to accommodate the wingman addition)
    • hydration/mod.rs +22/−29, hydration/batch.rs +40/−64, hydration/metrics.rs +140/−180 (a side effect of separating the rule metrics)
  • visibility-filtering/evaluate_tweets.rs +57/−51, filter.rs +26/−120, and server.rs +35/−21 are changes that tidy up dispatch to match the hydration and rules overhaul.
  • abuse-enforcement-service/service-lib/src/lib.rs +612/−4 and service-lib/src/metrics.rs +79/−3 are side effects of the overturn-hold integration.

Why it matters The real headline of this commit is the new overturn-hold gate in abuse-enforcement. The 24-hour half-life pattern of the VF overhaul also continues. safety_label_hydrator and tes_hydrator, which were functions until the previous day, were deleted this time, and the state logic of execute.rs, introduced the previous day, was separated into store.rs.

  1. The priority between automated verdicts and human verdicts is fixed as data. Whenever a suspension spec is created on the automated dispatch path, the last gate that looks up the hold ledger to check "has this action ever been reversed by a human?" and gates on it is now explicit in code.
  2. The reversal of a suspension is treated as persistent state. A hold is a time window with an expiry time (expires_at). During that window, automated actions against a specific target (account or label) are blocked. A human reviewer's approval of an appeal becomes a policy entry meaning "no re-application until time T", not a "one-time reversal".
  3. The follows lookup moved to the wingman path. As direct socialgraph calls move to the wingman path, the fan-out layout of hydration RPCs is reorganized again. The fact that FOLLOWS_GRAPH=1 is fixed as a constant suggests that wingman holds multiple graphs (2, 3, ...), but the rest is not confirmed in this release.
  4. The Phoenix ads purchase-value target is fixed as data. Of the 6 combinations of ValueEvent 3 × ValueLevel 2, 2 REQUIRED and 4 PUBLISHED targets were made explicit. The $100k per-event cap is also fixed in the training and evaluation contract.
  5. Cold retrieval candidates entered the cold-start experiment. The path by which the new Cold channel of Phoenix retrieval flows only to the Treatment arm of the cold-start experiment is fixed as data (a matches! pattern).

Gen4+31 (2026-09-29, a707cc2) — overturn-hold server abuse-ledger-service released (12 files, 2,254 lines) + Tweetypie added to the VF reference comparison targets + home-mixer production cron ClickWeight 0.4 → 0.3, ContClickDwellTimeWeight 0.0 → 0.4, NotInterestedWeight −43.2 → −47.52 + VF retweet source merge extracted into a separate retweet.rs module + six VF client switches locked as data in params/client_switches.rs + age_verification and legacy_interstitial golden corpus split + Phoenix ads PUBLISHED = ALL (4 → 6)

The server for the remote ledger that the overturn-hold gate in the previous commit (Gen4+30) called was released as a read-only lookup service verified — daily. On the ranking side, the click weight in the home-mixer production cron mirror dropped from 0.4 to 0.3, and the post-click dwell time weight rose from 0.0 to 0.4 verified(same daily).

This commit changed 89 files, with +9,552/−2,325 lines verified — xai-org/x-algorithm@a707cc2.

A. abuse-ledger-service, the server for overturn-hold, was released.

The client overturn_hold.rs introduced in Gen4+30 (2026-09-26) called POST /v1/holds/lookup on a remote ledger. This commit released the full server implementation.

  • The Cargo crate name is xai-abuse-ledger-service.
  • There are 11 files (api.rs, boot.rs, codes.rs, config.rs, lib.rs, main.rs, metrics.rs, pg.rs, repo.rs, sql.rs, tls.rs), totaling +2,282 lines verified — per-file stats of a707cc2.
  • It is a read-only lookup service that combines axum, deadpool-postgres, and rustls.

The lookup API takes a user id and a label list via POST and returns a list of holds verified — abuse-ledger-service/src/api.rs.

  • The path is LOOKUP_PATH = "/v1/holds/lookup", the method is POST, and the header is x-request-deadline-ms verified(same file).
  • The request is {user_ids: Vec<i64>, labels: Vec<String>} and the response is {holds: Vec<Hold>, db_ms: u64} verified(same file).
  • The limits are MAX_USER_IDS = 100 and MAX_LABELS = 32, and empty-string labels are rejected verified(same file).
  • budget(deadline, statement_timeout) uses the smaller of the header value and the statement timeout as the actual DB budget verified(same file).
    • Without the header, it uses statement_timeout + QUERY_DEADLINE_SLACK(100ms).
  • The side endpoints are /healthz, /readyz, and /metrics verified(same file).

The lookup SQL reads only holds that have not been cleared and have not yet expired verified — abuse-ledger-service/src/sql.rs.

SELECT user_id, hold_id, head, expires_at, case_group_id, reason, action_kind, label
FROM ledger.enforcement_holds
WHERE user_id = ANY($1::bigint[]) AND cleared_at IS NULL AND expires_at > now()
AND (action_kind = 'suspend' OR (action_kind = 'label' AND label = ANY($2::text[])))
  • The table is ledger.enforcement_holds verified(same file).
  • The active condition is cleared_at IS NULL AND expires_at > now() verified(same file).
  • action_kind has two values, 'suspend' | 'label'; Suspend is returned regardless of the label, and Label is returned only when it belongs to the requested label set verified(same file).
  • At startup it validates the column projection schema with CONTRACT_SQL (SELECT ... LIMIT 0), and the health check uses PING_SQL (SELECT 1) verified(same file).
  • The tests assert that none of the three SQL statements contains INSERT/UPDATE/DELETE/ALTER/DROP/CREATE verified(same file). The server cannot write verified(same file).

The Hold record carries the target user id, action kind, label, expiry time, reason, and so on verified — abuse-ledger-service/src/repo.rs.

pub struct Hold {
    pub user_id: i64,
    pub hold_id: i64,
    pub action_kind: String,   // "suspend" | "label"
    pub label: Option<String>,
    pub head: Option<String>,           // issuing tool identifier [inferred]
    pub expires_at: DateTime<Utc>,
    pub case_group_id: Option<i32>,     // group key that ties an appeal case to multiple holds
    pub reason: String,
}

The pg constants are as follows verified — abuse-ledger-service/src/pg.rs.

  • The three pool timeouts are POOL_WAIT_TIMEOUT = 1s, POOL_CREATE_TIMEOUT = 1.5s, and POOL_RECYCLE_TIMEOUT = 500ms verified(same file).
  • QUERY_DEADLINE_SLACK = 100ms is also a constant in this file verified(same file).
  • PgError has 7 variants verified(same file).
    • The variants are BadDsn / Connect{Refused|Auth|Tls|Timeout|Other} / Saturated / Transport / Query{sqlstate,message} / Decode / Timeout.
  • SQLSTATE 57014 (query_canceled) maps to ErrorCode::StatementTimeout verified(same file). 28P01/28000 (auth) map to ErrorCode::AuthFailed verified(same file).
  • At boot, boot::contract_check_until_ok keeps AtomicBool<ready> false until the schema check succeeds, and meanwhile /readyz does not return 200 verified(same file).

TLS is rustls-based mTLS implemented in a separate 348-line file. It parses sslmode=require|verify-ca|verify-full and takes the CA/CRT/KEY paths from separate environment variables. The ledger store is protected by mTLS, and this service sits in the role of a read-only proxy for that store.

B. Tweetypie was added to the VF reference comparison targets.

The comparison targets widened to two values, vf_service | tweetypie. A Reference::{None, VfService, Tweetypie} enum was added in visibility-filtering/reference/mod.rs. The environment variable VF_REFERENCE replaces VF_DUAL_CALL_HARNESS_ENABLED from Gen4+7, and startup fails if the old variable remains. With any value, startup is refused under APP_ENV=prod (staging only).

reference/tweetypie.rs was newly added at 714 lines verified — visibility-filtering/reference/tweetypie.rs. The call target is the Strato column tweetypie/getTweetFields.Tweet, and the request carries GetTweetFieldsOptions { visibility_policy: VISIBILITY_POLICY_USER_VISIBLE, ... }. Tweetypie then returns the response with the tweet-level visibility decision (Action::Allow | Drop(reason)) already attached, and this is diffed against the Rust VF's own verdict. The six constants are listed below.

  • TWEETYPIE_MAX_BATCH_SIZE = 250: batches 250 tweets at a time.
  • MAX_IN_FLIGHT = 32: the semaphore fan-out cap.
  • TWEETYPIE_TIMEOUT = 1500ms
  • SAFETY_LEVEL = SafetyLevel::TimelineHomeHydration: fixes the verdict level requested from Tweetypie.
  • RECORD_CAP_PER_MINUTE = 20: the sample log cap.
  • WEB_CLIENT_APPLICATION_ID = 3_033_300

There are 7 result classes (Allow / BareDrop / Drop / Suppressed / NotFound / Failed / Missing). There are also 7 failure reasons (NoValue / StratoError / RateLimited / DecodeError / RpcError / OverCapacity / TweetypieFailed). Each is a label of the vf_tweetypie_reference_compare metric.

C. The VF retweet source merge was extracted into a separate module.

visibility-filtering/retweet.rs was newly added at 318 lines, and evaluate_tweets.rs shrank (+101/−102). The verdict merge of the in-batch and fetched sets, which Gen4+28 introduced inline, was split out into the evaluate_merging_sources function. The structure is the same. The new metric evaluate_tweets_retweet_sources { outcome=in_batch | fetched } shows how often a separate fetch is needed.

D. Six VF client switches were locked as a data table.

params/client_switches.rs was newly added at 225 lines and locks six switches as a data table verified — visibility-filtering/params/client_switches.rs.

enum ClientSwitch {
    AgeVerification(VerifyBlurSupport),   // a different rule key for each of the 4 client states
    ModernBlur,
    StaleTweetLimits,
    GoreBlurIgnoresSettings,
    FosnrRules,
    FosnrFallbackDrops,
}
  • The 4 axes of VerifyBlurSupport each map to a different age_verification_*_tombstone_rule_enabled key verified(same file).
    • The 4 axes are Unsupported | IosNeedsUpdate | AndroidNeedsUpdate | Supported.
  • Three switches are on by default via default_on: AgeVerification(Unsupported), StaleTweetLimits, and FosnrRules verified(same file).
  • When a request arrives, a RecipientBuilder is built from TwitterContextViewer and the viewer_id, and xai_feature_switches::FeatureSwitches matches against it verified(same file).
    • TwitterContextViewer contains client_application_id, user_agent, and country_code.

E. The golden corpus split continued.

Four files were newly split out as a follow-up to the 15-file split that began in Gen4+29. golden_corpus/age_verification.rs is 705 lines, legacy_interstitial.rs is 130 lines, and local_regulations.rs and takedown.rs were also split out separately. age_verification.rs pins, as a role×tweet matrix, the verdicts that the AgeVerification(*) switches in section D produce for each client state.

F. Three weights in the home-mixer production cron mirror changed.

The sync comment at the top of home-mixer/params/param.rs was updated from 2026-09-25T16:24:28Z to 2026-09-28T16:00:35Z, and three values changed together verified.

  • ClickWeight: 0.4 → 0.3 (⚠ −25%)
  • ContClickDwellTimeWeight: 0.0 → 0.4 (⚠ fully disabled → enabled)
  • NotInterestedWeight: −43.2 → −47.52 (exactly ×1.1)

Since the binary continuous loss type was brought into the training loss as click-dwell BCE in Gen4+22 (2026-09-16), this appears to be the sequence in which the trained head passed offline and holdout checks and was promoted into the serving weighted sum inferred. The weight on the click itself drops by 1/4, and "dwell after click" gains new weight.

Also, a new PhoenixRetrievalExcludeSeenPosts bool parameter was added (default false, not yet active), and the UseEngagementCounterViewCountForImpressionBoost parameter was removed.

G. The author_policy_zeroed field was removed entirely.

The author_policy_zeroed: bool field was deleted from both the home-mixer candidate (PostCandidate) and the VMRanker request shape verified — home-mixer/scorers/value_model.rs, scorers/vm_ranker.rs, scorers/vm_ranker_request.rs, models/candidate.rs. This field was a marker for candidates whose score was suppressed to 0 by author policy inferred, and policy-driven suppression is now decided directly in the VF verdict.

H. The Phoenix ads target widened to PUBLISHED = ALL (4 → 6).

The PUBLISHED array in phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs was fixed in Gen4+30 as [PURCHASE_CT, PURCHASE_VT, CONTENT_VIEW_CT, ADD_TO_CART_CT]. This commit widened it to PUBLISHED = ALL, so CONTENT_VIEW_VT and ADD_TO_CART_VT are also carried in the serving response. The PurchaseValueBaseline encoding size assert changed from ≤64 bytes to = 73 bytes.

I. The remaining changes are incidental.

  • visibility-filtering/rules/tweet_rules.rs +904/−543, rules/registry.rs +543/−353, and rules/rule_spec.rs +465/−33 are a large-scale refactor accompanying the introduction of client_switches. In this commit, Policy changed from a static slice to a structure built at run time as Vec<(&str, RuleClause)> verified — rules/registry.rs:28-55. The earlier Policy<'a> struct had existed since the 2026-09-22 commit (Gen4+26) verified — registry.rs:20-23 in 3aa0fa3. The number of static Policy entries per SafetyLevel grew from 3 in the 2026-09-02 commit (Gen4+14) to 5 in Gen4+29 (bf7db1b) verified — registry.rs:79-102 in 85ac72a.
  • visibility-filtering/hydration/mod.rs was modified by +8/−2 verified. In Gen4+30 as well, this file was not deleted but modified by +22/−29 (4c5cfe8).
  • home-mixer/sources/ads_source.rs and sources/phoenix_source.rs were adjusted slightly as a side effect of the candidate field removal.
  • The Blink score job template was modified in agatha/scalding/blink/BlinkScoreJobTemplate.scala (+87/−28).
  • The inference path was adjusted slightly with phoenix/xrex/inference/model_runner.py +134/−38 and models/recsys_model.py +83.

Why it matters Three facts about safety actions, VF verification, and ranking weights overlap in this commit.

  1. The trust layer of the automated suspension policy was physically separated into two services. abuse-enforcement decides dispatch, and abuse-ledger-service makes human reviewers' decisions (overturn) available for read-only lookup. abuse-enforcement queries the ledger only over HTTP (POST /v1/holds/lookup), and it is abuse-ledger-service that connects to ledger.enforcement_holds in Postgres verified — overturn_hold.rs:25, abuse-ledger-service/src/sql.rs:1-11. The latter's SQL consists of only three statements, PROBE_SQL for lookup, CONTRACT_SQL for column contract checks, and PING_SQL for connection checks, and an assert pins that none of the three contains a write verb; in principle, the dispatch path has no write access to the human-verdict store.
  2. The VF reference comparison target widened beyond its own references to the storage layer (Tweetypie). The Rust VF ↔ Scala VF diff is biased toward implementation drift. Tweetypie holds visibility decisions already attached to the tweet itself, so diffing against it catches drift between the "storage-axis decision" and the "serving-axis decision". Fixing the Safety Level to TimelineHomeHydration and refusing to start in prod makes explicit that this harness is a correctness verification tool, not a safety net.
  3. Ranking has begun to trust "clicks" less. ClickWeight 0.4 → 0.3 and ContClickDwellTimeWeight 0.0 → 0.4 flipped together in the same cron sync. In the serving weighted sum, the weight multiplied by the click-dwell head prediction went from 0.0 to 0.4 verified — xai-value-model/scoring.rs:108. We read this as the order in which the head brought into the training loss in Gen4+22 passed verification and came to be used in serving inferred. The direction that "stayed after clicking" fits the production objective better than "clicked" is pinned in code; the exact ×1.1 on Not Interested also strengthens the explicit negative signal.

Gen4+32 (2026-09-30, 77d431a) — VF hydration Store rewritten as a Fetcher<V> trait array + retweet source expansion simplified to hydrate-once + in-flight + treatment.rs projection isolation + Edge unified into an 8-variant enum + VF client response promoted from Thrift Action to TweetFieldsResultState + Phoenix xrecsys_two_tower.py promoted to the registry as a 6-preset catalog (SID 6-level + seqpack + H100/GB300 hardware presets + Home/Immersive two heads shared) + ads_head_masking moved from the JAX training graph to a numpy data loader, splitting the web/app conversion axes (new proto ADS_ATTRIBUTED_MACT_MMP_{CLICK,VIEW}_INSTALL = 194,195) + home-mixer cold-start 6-parameter production tuning (PhoenixColdStartMaxResults 0 → 200, ColdStartMaxPostAgeSecs 48h → 2h, ColdStartFollowerCap 1000 → 50k, ColdStartImpressionThreshold 1000 → 200, LowImpressionsMaxPositionRatio 0.85 → 0.97, EnableColdStartThompsonSampling false → true) + home-mixer/util/composition.rs 63 lines deleted entirely (migrated to a candidate-pipeline shared util) + Brazil 2026 election filter 2,786 → 2,795 handles

The hydration stage that fills in the data VF needs for verdicts was rebuilt into a structure that remembers request state per key, and the original post of a retweet is now filled in within a single call verified — daily. In the same commit, the Phoenix retrieval two-tower configuration was organized into a preset catalog, and the home-mixer cold-start parameters changed substantially all at once in the cron mirror verified(same daily).

The commit covers 69 files, +3,306/−1,489 verified — xai-org/x-algorithm@77d431a. It came one day after Gen4+31 (a707cc2). The four-day run from Gen4+29 through +30 and +31 to +32 completes the VF hydration rewrite.

A. The VF hydration Store was rewritten as a Fetcher<V> trait array. The new file visibility-filtering/hydration/fetcher.rs (NEW +113) defines the Fetcher<V> and AnyFetcher traits.

  • Inside is a single HashMap<u64, State<V>>, which holds an enum State<V> { Pending, Landed(Hydrated<V>) } state per key.
  • claim(keys) -> Vec<u64> marks only keys not yet present as Pending and returns only the keys that will actually be sent over RPC. This filters duplicates and also joins with earlier calls (dedup, join with the previous call).
  • land(&[u64], HydrationBatch<u64, V>) fills responses into the claimed keys and fills keys without a response with Hydrated::NotFound.
  • has_incomplete/is_incomplete/has_claimed/is_claimed determine whether a re-call is needed.

The 8 Option<TweetHydrationBatch<...>> fields that visibility-filtering/hydration/store.rs had in the previous day's commit, Gen4+31 (a707cc2), were all replaced by Fetcher<V> this time verified — store.rs:54-84.

  • Each of the 8 Edge variants (Follows / Blocks / Mutes / MuteRetweets / BlockedBy / SuperFollows / FollowedBy / SecondDegree) has one Fetcher<bool>.
  • They are held in the array edges: [Fetcher<bool>; Edge::VARIANTS.len()].

B. retweet.rs was simplified from calling hydrate twice to calling it once and expanding in-flight. evaluate_merging_sources as of Gen4+31 was rewritten verified — retweet.rs (+562/−110).

  • Until the previous day, the original batch was filled first with filter_tweets.hydrate(request). Then retweet source candidates were extracted via source_ids, and a second, separate filter_tweets.hydrate(...) call was sent for sources not in the request batch to build the fetched hydration. Finally, the outcomes of the two results were merged.
  • From this commit, filter_tweets.hydrate_with_retweet_sources(request) is called only once. Internally, the moment pure_cores lands, this call runs store.join_retweet_sources(), attaches to request_tweets those source_tweet_ids of pure_cores that are not yet in the request, and returns Landing::SourcesJoined verified — store.rs:308-330.
  • When the scheduler in execute.rs receives Landing::SourcesJoined, it puts the inputless() groups back into ready, claims new keys only for fetched sources, and sends the expanded batch verified — execute.rs:112-118.
    • inputless() groups are the initial groups with no pure_core dependency.
    • Because the Fetcher's claim filters duplicates, keys already in the request batch are not requested again.

The new metric RetweetSources::{NoSource, InBatch, Fetched} is attached as a label to record_phase verified — filter.rs:62-79. The test each_request_kind_labels_its_phase_samples pins the three cases ([2] → NoSource, [1,2,3,5] → InBatch, [1,2,3,4] → Fetched) verified — retweet.rs:721-738. On the second call (is_first=false), the record_expanded_batch metric is also recorded verified — execute.rs:76-79.

C. The interface of store.rs was reorganized, and the result of assemble changed to Hydration/HydratedTweet.

  • A new CallRequest<'p> struct bundles the details of actually calling one group verified — store.rs:37-46.
    • The details it holds are group, is_first, keys, key_count, queries, viewer_id, batch_size, and counts.
  • offer(&mut self, group) -> Option<CallRequest> claims the group's keys, builds a CallRequest, and hands it to the scheduler.
  • A new Landing::{Answered, SourcesJoined} enum was also added.
  • After receiving the pure_cores reply, land runs join_retweet_sources() if the is_expanding_retweet_sources flag is on.
  • assemble returns the new Hydration type instead of the previous day's HydrationOutput. Hydration has 3 fields: viewer, tweets: HashMap<TweetId, HydratedTweet>, and has_fetched_sources: bool.
  • HydratedTweet has candidate, has_failed_node, source_tweet_id, and safety_labels verified. The old HydrationOutput is gone, and Hydration and HydratedTweet take its place.

D. Part::Edge(Graph, EdgeDirection) in plan.rs was unified into Part::Edge(Edge), which takes an 8-variant enum. verified — plan.rs:8-20.

  • Edge::flock() returns the flock RPC parameters as Option<(Graph, EdgeDirection)>. Only SecondDegree is None, and this edge is routed in a unified way to Wingman.
  • Hydrator::RootFollowsViewerSecondDegree, which was Part::Column until the previous day, was promoted to Part::Edge(Edge::SecondDegree) this time and is routed to the same fetcher array as the other edges.
  • Group gained new fields position: usize and readers: Vec<usize> verified. Which groups can be retried after a group lands is precomputed as a graph, so the offer→land→ready schedule finishes in O(readers) time per group verified — plan.rs:172-207.

E. The new file visibility-filtering/treatment.rs (NEW +119) isolates in one place the projection of rules' verdicts into thrift, proto, and metric representations. treatment.rs projects the pure Verdict (Withheld / Shown{media, engagement}) that rules emit into three kinds of downstream representations verified.

  • thrift_action(&Verdict, SafetyLevel) -> Action projects to the thrift action::Action.
    • The projection result is one of Allow / Drop / Tombstone / ComposedMediaVisibilityResults / Interstitial / LimitedEngagements / TweetInterstitial.
  • thrift_result_state(&Verdict, SafetyLevel) -> TweetFieldsResultState projects to the tweet-service response state (Found / Filtered). Only four, FilteredReason::{TweetIsBounced, AuthorBlockViewer, AuthorIsProtected, AuthorIsSuspended}, are handled separately as legacy arms, and the rest are wrapped in SafetyResult.
  • proto_action(Verdict) -> (vf_pb::Action, Option<vf_pb::FilteredReason>) projects to the 3-way (Allow / Drop / Interstitial) form for the VF proto.
  • metric_label(&Verdict) -> &'static str returns a 6-way metric label.
    • The labels are allow / drop / tombstone / interstitial / limited_engagement / tweet_interstitial.
  • decided_rows(&Verdict) is an iterator that expands a verdict into a sequence of (rule, phase) tuples.

The constant AGE_VERIFICATION_OPTIONS = [SELFIE, PERSONA] is also pinned in this file verified — treatment.rs:120-123. blurred_media puts these two options in the response only when the blur prompt is AGE_VERIFICATION_PROMPT. The test dashboard_generator_pins_the_rpc_and_verdict_mix_labels actually reads scripts/dashboard.py and asserts that FT_VERDICT_ACTIONS has 6 labels. This test ties the metric labels and the dashboard together in golden fashion.

F. The response contract of the VF client was promoted from Thrift Action to TweetFieldsResultState. verified — EvaluationResult::decode in evaluated.rs:22-33, decode_response in vf_client.rs:35-41.

  • The proto's Outcome::ActionThriftCompact was renamed Outcome::ResultStateThriftCompact.
  • The constant MAX_ACTION_BYTES was also renamed MAX_RESULT_STATE_BYTES, and its value is still 64 * 1024.
  • VF now sends the (Found | Filtered) result state itself, so the caller can build the tweet-service response directly.

The check of tweet.id order in the response was removed verified — vf_client.rs:35-46. Until the previous day, order and quote context were verified with a tweet.as_ref() == Some(expected) assert. This time that line is gone and only the response length is checked. It is a wire format optimization.

A metadata: &tonic::metadata::MetadataMap parameter was also added to the request header, passing caller metadata (twittercontext etc.) on each chunk RPC verified — vf_client.rs:8-25. A new helper EvaluationResult::into_result_state(self, fetched: TweetFieldsResultState) -> Option<TweetFieldsResultState> was also added verified. This helper prefers fetched if fetched is NotFound/Failed, and replaces it with the VF result if VF is Evaluated verified — evaluated.rs:35-47.

G. Phoenix xrecsys_two_tower.py was promoted to the registry as a 6-preset catalog. verified — phoenix/xrex/configs/xrecsys_two_tower.py. The dictionary MODEL_CFGS holds 6 presets verified — xrecsys_two_tower.py:418-547.

  • xrecsys_two_tower / xrecsys_two_tower_immersive_only / xrecsys_two_tower_immersive_only_gb300
  • xrecsys_two_tower_combined / xrecsys_two_tower_combined_gb300 / xrecsys_two_tower_nano

The shared scale is defined in _xrecsys_two_tower_combined_base.

  • The sequence and model sizes are history_seq_len=1023, candidate_seq_len=64, num_layers=8, emb_size=1024, and emb_table_width=1024.
  • The attention heads are query_heads=16 and kv_heads=4 (GQA 4:1). The negative settings are num_global_negatives_per_example=64 and num_negatives_per_example=0 (global sampled softmax only, without in-batch).
  • The training scale and vocabulary sizes are total_samples=1e11 (100B), item_hash_vocab_size=100_000_000, and author_vocab_size=30_000_000 verified — xrecsys_two_tower.py:466-475.
  • Only SID is used, not user and post embeddings verified — xrecsys_two_tower.py:311-318.
    • The flags are use_user_embedding=False, use_post_embedding=False, and use_post_sid=True.
    • The SID settings are sid_num_levels=6, sid_codebook_size=256, sid_embed_dim=1024, sid_hash_level=True, and sid_cross_attn=True.
  • The sequence packing settings are use_seqpack=True, right_anchored_rope=True, and effective_sequence_len=513. The length distribution is BetaLengthDistribution(min=0, max=1023, mean=511, α=β=1.0, block_size=128).

The combined preset has two heads, home and immersive verified — xrecsys_two_tower.py:293-302.

  • The settings are num_candidate_heads=2 and head_names=["home", "immersive"].
  • head_dataset_mapping assigns HOME/HOME_COLD/HOME_HOT → head 0 and IMMERSIVE4Day/IMMERSIVE2Day/IMMERSIVENSFW → head 1.
  • The home head's positive_actions has only one entry, [SERVER_TWEET_FAV].
  • immersive_positive_actions has eight kinds verified — xrecsys_two_tower.py:283-292.
    • They are FAV / REPLY / QUOTE / RETWEET / VIDEO_QUALITY_VIEW / FOLLOW_AUTHOR / BOOKMARK / SHARE.

There are two hardware presets, one for H100 and one for GB300 verified — xrecsys_two_tower.py:409-413.

  • _H100_OVERRIDES = {bs_per_device: 480, ep: 128, attn_impl: "cutedsl_ranker_varlen_attn"}
  • _GB300_OVERRIDES = {bs_per_device: 960, ep: 64, ...}
  • On GB300, the per-device batch is doubled and EP is halved.

The training configuration values are as follows:

  • The optimizer is Adam(b1=0.95, b2=0.98, weight_decay=1e-3) verified — xrecsys_two_tower.py:826-831, 271.
  • The learning rate is learning_rate=2e-3 verified — same file.
  • The embedding learning rate is emb_learning_rate=0.1 (embedding rowwise adagrad) verified — same file.
  • The u2u and i2i losses are off by default (apply_u2u_and_i2i_loss=False) verified — same file.
  • empty_history_user_dropout_rate=0.1 is set verified — same file.

Candidate max_posts is 28_672_000 for combined and 40_960_000 for immersive. enable_linear_proj is False for combined/immersive and True for the single-tower nano. A mutual-exclusion assert is placed between apply_u2u_and_i2i_loss and enable_candidate_tower_linear_proj verified — xrecsys_two_tower.py:611-618.

H. The ads conversion head was split along web/app axes, and ads_head_masking moved from JAX to numpy. phoenix/xrex/data/recsys/ads_head_masking.py (NEW +87) replaces phoenix/xrex/models/ads_head_masking.py (−67) verified. The deleted models/ads_head_masking.py patch used import jax; import jax.numpy as jnp, while the new data/ads_head_masking.py uses only numpy as np.

A new function precomputes the numpy bool mask in the data pipe verified — data/recsys/ads_head_masking.py:44-88.

  • The signature is build_trained_candidate_mask(actions, sample_source, trained_actions_bits, *, ads_head_masking: bool) -> npt.NDArray[np.bool_].
  • The argument source_id became sample_source, and raw_targets became the trained_actions_bits: uint8 bitmask.
  • The new constant ADS_FAMILY_SLICE_HEADS: dict[str, int] holds a representative head index for each of the four slices.
    • The slices are delayed_website_clicked / delayed_website_non_clicked / delayed_app_clicked / delayed_app_non_clicked.
  • With the addition of the MMP (mobile attribution partner) app-conversion axis, masking expanded to a web × app × early/late × click/view grid.

In recsys_model.py, the two existing masks were split into web and app verified — recsys_model.py:1493-1520.

  • The existing masks were masks["clicked"] / masks["non_negative_clicked"].
  • The new masks are website_clicked / non_negative_website_clicked / app_clicked / non_negative_app_clicked.

The new verification validate_ads_head_masking forbids ads_head_masking and use_seqpack (in the packed layout, a per-candidate/per-head trained_candidate_mask is not defined) verified — recsys_model.py:610-614. It iterates over ADS_FAMILY_SLICE_HEADS.items() and exposes the four slice names as masks dict keys, which widens the training loss heads to four-way.

recsys_batch.py also changed accordingly verified — recsys_batch.py:471-479. The schema column conversionKeepMask: bool became conversionKeepBits: uint8. The fallback for null changed from True to CONVERSION_KEEP_WEB | CONVERSION_KEEP_APP = 0b11. It also now accepts a new sample_source column.

constants.py gained a new catalog verified — phoenix/xrex/data/recsys/constants.py.

  • MMP_CLICK_ACTION_INDEX = ADS_MMP_CLICK was defined.
  • MMP_CLICK_CONDITIONED_ACTION_INDICES (seven app click-through actions) is new.
  • MMP_CLICK_VIEW_THROUGH_ACTION_INDICES (seven app view-through actions) is also new. It was split out of VIEW_THROUGH_ACTION_INDICES, where they were mixed in until the previous day.
  • The bit constants CONVERSION_KEEP_WEB = 1 << 0 and CONVERSION_KEEP_APP = 1 << 1 were added.
  • The magic number line_item_objective == 5, used until the previous day, was promoted to LineItemObjective(enum.IntEnum): WEBSITE_CLICKS = 5 verified — recsys_model.py:1495-1499.
  • The new labels IsCustomConversion / IsContentViewConversion map to the ADS_CUSTOM_CONVERSION / ADS_CONTENT_VIEW_CONVERSION actions, respectively.

recsys.proto gained two new actions in both mirror files verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto.

  • ADS_ATTRIBUTED_MACT_MMP_CLICK_INSTALL = 194
  • ADS_ATTRIBUTED_MACT_MMP_VIEW_INSTALL = 195

I. Six home-mixer cold-start parameters changed to production values in the cron mirror. verified — home-mixer/params/param.rs.

Parameter Previous value New value Multiplier
PhoenixColdStartMaxResults 0 200 0 → 200 (Phoenix cold-start retrieval output enabled, from 0 to 200)
ColdStartImpressionThreshold 1000 200 ×0.2 (narrows the cold-start targets to posts with fewer than 200 home impressions)
ColdStartFollowerCap 1000 50,000 ×50 (author follower cap effectively lifted)
ColdStartMaxPostAgeSecs 172,800(48h) 7,200(2h) 1/24 (post freshness made extreme)
LowImpressionsMaxPositionRatio 0.85 0.97 +0.12 (position boost cap extends to the later part of the feed)
EnableColdStartThompsonSampling false true newly ON

The last sync in the comment header was updated from 2026-09-28T16:00:35Z to 2026-09-29T17:02:52Z verified. This time, six knobs of the cold-start pipeline were adjusted substantially as one set.

  • The Phoenix cold retrieval channel was turned on (0 → 200 posts).
  • The home impression threshold for candidate posts was lowered (1000 → 200). Only posts with fewer than 200 impressions remain, so the targets are narrowed verified — author_cold_start.rs:293-294.
  • The author follower cap was effectively lifted (1000 → 50k).
  • The post age was shortened drastically, from two days to two hours.
  • Exploration was also turned on through Thompson sampling.

This change appears to be the actual production tuning of the HOME_COLD=13 channel, which was introduced in Gen4+28 and incorporated into the cold-start arm-gate in Gen4+30 inferred.

J. home-mixer/util/composition.rs (−63) was deleted entirely. The whole file was removed verified. Until the previous day, this file was a diversity stats helper. It contained the Composition {size, unique, max_count, hhi, entropy_norm} struct and the from_keys / from_counts / unique_ratio / max_share APIs. The usage site in response_diversity_stats_side_effect.rs only changed its import path to use xai_candidate_pipeline::component_library::utils::Composition;. pub mod composition; was also removed from home-mixer/util/mod.rs. The shared candidate-pipeline utilities already have a Composition of the same name, so this is a dedup that deletes its own copy and uses the shared one. It is unrelated to the rules/registry Policy (Gen4+30).

K. Nine handles were added to the Brazil 2026 election filter. The list size and the assert were updated together (2,786 → 2,795, assert_eq!(...len(), 2786) → 2795) verified — brazil_2026_election_filter.rs:5849-5852. Going by the comments, the added handles are @linoaa, @LPVellozo, @erikakokay, @douglascamargoo, @Anadelso_p, @zeosmaruniao, @siandrade11777, @tiagopiaui22, and @gloriabsoares. The logic did not change.

Why it matters Four facts overlap in this commit.

  1. VF hydration gained the ability to expand again midway. Across the four commits from Gen4+29 to +32, over five days, the data-driven pipeline skeleton and the retweet.rs module extraction were completed, and this commit added the Fetcher<V> trait, which made the claim idempotent. The moment pure_cores land, a Landing::SourcesJoined flow takes shape that expands request_tweets in flight and resends the inputless group. The pattern of calling hydrate twice was simplified to calling it once and expanding in flight. The NoSource / InBatch / Fetched metrics are captured as labels, so how often expansion batches occur can also be observed. The VF client response was also promoted from Thrift Action to TweetFieldsResultState, so callers can now construct the tweet-service response directly. Isolating the projection of rules results into thrift/proto/metrics in treatment.rs, and tying dashboard.py and six metric labels together with golden tests, completes this simplification.

  2. The Phoenix retrieval two-tower was promoted to a catalog. Until the previous day there was an xrecsys_two_tower.py file, plus small fixes: Gen4+6 put qk_norm=True, attn_logit_cap=-1 and right_anchored_rope=True into the nano config, Gen4+14 changed the base defaults to qk_norm=True and attn_logit_cap=-1, and Gen4+21 changed the H100 attn kernel from pallas_ranker_varlen_attn to cutedsl_ranker_varlen_attn. However, the registry catalog itself was not yet fixed. In this commit, six presets (default, immersive-only and its GB300 version, combined in H100 and GB300 versions, and nano) were fixed in MODEL_CFGS, and the catalog appears to have been migrated so that it connects to the production data pipe inferred. In particular, SID-only (a 6-level codebook hash instead of post embeddings), seqpack, the H100/GB300 hardware presets, and the sharing of two heads between Home and Immersive were organized in one file. Paired with the observation in Gen4+13 that sid_codes was also wired into DPA ads products, the SID vocabulary has greater potential to unify three uses: organic posts, ads DPA, and the retrieval two-tower.

  3. The ads conversion head was differentiated along web/app axes. The delayed feedback split in Gen4+6 and the unified ads_head_masking knob in Gen4+24 moved in this commit from a float multiplier inside the training graph to a boolean mask precomputed by the data loader. This move has two implications:

    • It makes the graph independent of sample_source, which secures compilation stability.
    • By training the web conversion head and the app (MMP) conversion head separately, the sample subsets of the two axes keep_web / keep_app can be used differently.

    In terms of mask semantics, this is an exact refactor that keeps the late-window twin suppression logic and changes only where it is computed. The new mutual-exclusion assert with use_seqpack exists because seqpack breaks the correspondence between candidate slots. With the new ADS_ATTRIBUTED_MACT_MMP_{CLICK, VIEW}_INSTALL = 194, 195 in the proto, there is now a channel for formally incorporating MMP-side app-install conversion into the training head.

  4. Home-mixer cold start was set to production parameters. After the HOME_COLD=13 channel introduced in Gen4+28 was incorporated into the cold-start arm-gate in Gen4+30, this commit fixed the parameters. The Phoenix cold retrieval channel was opened from 0 to 200 posts, and MaxPostAge was shortened drastically from 48h to 2h. FollowerCap was effectively lifted from 1000 to 50k, so the author pool surged. ImpressionThreshold was lowered from 1000 to 200, narrowing the targets so that only posts with fewer than 200 home impressions become cold-start slot candidates. Thompson sampling was also turned on. The combination in effect is extremely strict freshness at 2h with a widely opened author pool. Whether this tuning holds up in the A/B test is the checkpoint to watch at the next cron sync.

Adding nine handles to the Brazil 2026 election filter is list maintenance with no logic change. Deleting Composition.rs is a dedup that follows from reusing the shared candidate-pipeline library.

Gen4+33 (2026-10-01, b79b947) — VF introduced a limited-actions policy system (two files, 703 lines) + gRPC caller-identity logging (126 lines) + LimitedEngagement promoted to a structure that accumulates multiple reasons + a self-restart gate for abuse-enforcement-service config changes (173 lines) + Kafka producer rewired onto the boot config snapshot + phoenix-rankall standalone CLI config module split out (84 lines, SID backfill QPS cap 800k) + VF brazil_election_legal/drop/undesirable labels registered + fallback_cache rewritten with quick_cache sync + OptionsBuilder

From this commit on, VF's verdict response carries not only the restriction reason but also, for each reason, the list of actions to block and localized copy per action, and a single verdict can accumulate multiple restriction reasons verified. This means clients no longer need to maintain their own action mapping and localization. This commit was covered in the daily.

47 files changed, at a scale of +2,490/−506 verified — xai-org/x-algorithm@b79b947. It is the commit that came the day after Gen4+32 (77d431a).

A. VF gained two new files that handle the limited-actions policy and its notice copy.

The new files are limited_actions_copy.rs (NEW +397) and params/limited_actions_policy.rs (NEW +306). treatment.rs, introduced in the previous day's commit, handled projecting the verdict into a Thrift Action, but the Thrift LimitedEngagements payload carried only the reason enum. With these two files introduced in this commit, the payload carries the expansion "reason → action list → localized Prompt".

  • limited_actions_copy.rs loads the localized UI copy bundle verified — limited_actions_copy.rs:1-85.
    • The bundle path is /config/stringcenter/visibility-ltd-actions.json, and the pinned metadata path is /config/stringcenter/_BUNDLE_METADATA.json.
    • The live metadata directory is /usr/local/config/stringcenter/bundles/visibility-ltd-actions.
    • DriftCheck::run() compares bundle_hash between pinned and live and emits the limited_actions_copy_drift_checks{result=match|mismatch|unreadable} counter.
  • A 26-variant enum called LimitedActionType was introduced verified — params/limited_actions_policy.rs:16-43.
    • The variants are Reply · Retweet · QuoteTweet · Like · React · SendViaDm · AddToBookmarks · AddToMoment · PinToProfile · ViewTweetActivity · ShareTweetVia · Follow · ListsAddRemove · MuteConversation · Embed · ViewHiddenReplies · HideCommunityTweet · CopyLink · VoteOnPoll · RemoveFromCommunity · ShowRetweetActionMenu · ReplyDownVote · Autoplay · EditTweet · Highlight · ViewPostEngagements.
  • LimitedActionsPolicies::resolve assembles the reason → Vec<LimitedAction> mapping from four FeatureSwitches keys verified — limited_actions_policy.rs:48-115.
    • The keys are limited_actions_policy_limited_actions, limited_actions_policy_copy_namespace, limited_actions_policy_prompt_type, and limited_actions_policy_prompt_learn_more_url.

This mapping is fixed by tests:

LimitedEngagementReason Limited actions
ConversationControl Reply
RootAuthorBlockedViewer Reply
BlockedViewer Reply · Retweet · QuoteTweet · Like · React · SendViaDm · AddToBookmarks · AddToMoment · PinToProfile · ShareTweetVia · Follow · ListsAddRemove · Embed · CopyLink · VoteOnPoll · ShowRetweetActionMenu · ReplyDownVote · EditTweet · Highlight (19)
ReadonlyViewer AddToBookmarks · AddToMoment · Embed · HideCommunityTweet · Like · ListsAddRemove · PinToProfile · QuoteTweet · React · Reply · Retweet · VoteOnPoll · EditTweet · Highlight (14)
StaleTweet Reply · Retweet · QuoteTweet · Like · React · SendViaDm · AddToBookmarks · ShareTweetVia · AddToMoment · Embed · HideCommunityTweet · PinToProfile · ViewTweetActivity · VoteOnPoll · CopyLink (15)
  • The Prompt is built by looking up bundle keys for each (action, locale, country) verified — limited_actions_copy.rs:120-160.
    • The keys looked up are {namespace}_{action_key}_Headline and {namespace}_{action_key}_Subtext, and the result is Prompt { headline, subtext, kind }.
    • kind has three variants: Basic / SeeConversation (CTA) / LearnMore { language, link_text, url }.
    • The BlockedViewer and ReadonlyViewer namespaces share the common subtext key _GenericSubtext.
  • If an action or copy is missing, it is handled fail-fast and the counter MISSING_POLICY_COUNTER = limited_actions_policy_missing{reason, missing=actions|copy} is emitted.

B. A single restriction verdict can now accumulate multiple reasons.

LimitedEngagement was promoted to a structure that accumulates multiple reasons.

  • This type, which was a single wrapper until this commit, changed to a structure holding one reason and a Vec of the remaining reasons verified — verdict.rs:67-98.
    • Before: LimitedEngagement(pub LimitedEngagementReason)
    • After: { first: LimitedEngagementReason, rest: Vec<LimitedEngagementReason> }
  • The add(reason) method checks for duplicates and then pushes to the Vec, and the reasons() iterator walks first and rest.

Accordingly, the ActionSpec::LimitedEngagement(reason) match branch in rules/registry.rs::collect_specs splits into two cases verified.

  • If engagement is None, it is set anew (LimitedEngagement::new(reason)).
  • If engagement is Some, it is appended (limit.value.add(*reason)).

In other words, if both BlockedViewer and ReadonlyViewer apply to a viewer at the same time, both reasons are carried in the payload. BlockedViewer limits 19 actions and ReadonlyViewer limits 14. Until this commit, the second and later engagement matches were dead code, but from this commit they are actually accumulated. Other reason combinations, such as StaleTweet with BlockedViewer, or ConversationControl with RootAuthorBlockedViewer, work the same way.

A new LimitedEngagementReason::limited_actions_string() const fn maps each reason to a bundle key prefix verified.

  • ConversationControl → "limited_replies"
  • ReadonlyViewer → "readonly_viewer"
  • BlockedViewer → "blocked_viewer"
  • RootAuthorBlockedViewer → "root_author_blocked_viewer"
  • StaleTweet → "stale_tweet"

C. The Thrift projection function takes the limited-actions policy as its third argument.

The signature of the projection layer introduced in the previous day's commit was extended, and treatment.rs::thrift_action takes LimitedActionsPolicies as its third argument verified — treatment.rs:16-20.

pub(crate) fn thrift_action(
    verdict: &Verdict,
    level: SafetyLevel,
    policies: &LimitedActionsPolicies,
) -> Action

Nine symbols were added to the Thrift import.

  • BasicLimitedActionPrompt · CtaLimitedActionPrompt · LimitedAction · LimitedActionCtaType · LimitedActionPrompt · LimitedActionsPolicy · LocalizedMessage · LocalizedMessageLimitedActionPrompt · MessageLink.

On the path where the Verdict is Shown { engagement: Some(..) }, the action list and Prompt are combined for each reason, and the per-action localized Prompt is placed in the LimitedEngagements payload and sent over the wire. This completely replaces the former "payload that passed only the reason enum".

D. VF identifies which service a gRPC caller is by its certificate and records it.

The new VF file caller_identity.rs (NEW +126) extracts the S2S identity from the x509 cert of the gRPC peer and logs it. It emits the new metric vf_requests_by_caller{caller_identity, rpc, deadline} verified — caller_identity.rs:1-56.

  • caller_identity takes the SAN URI of the peer cert (format twtr:svc:<service>:<service>:<env>:<dc>) first, and the CN second if there is none. If neither has the twtr:svc: prefix, it is unknown.
  • rpc is the three-variant enum Endpoint { FilterTweets, EvaluateTweets, GetSafetyLabels }.
  • deadline is present|absent, depending on whether gRPC timeout metadata is present.

The request handlers of the three endpoints call caller_identity::record(Endpoint::XXX, &request) at the very start. The call site is evaluate_tweets.rs:46 and two other places. Three tests fix the identity extraction logic: SAN URI first, CN fallback, and non-S2S cert → None verified.

E. The EvaluateTweets endpoint assembles the limited-actions policy while handling a request.

  • EvaluateTweetsEndpoint in VF evaluate_tweets.rs changed to a three-field structure verified — evaluate_tweets.rs:22-40.
    • The three fields are (filter_tweets, client_switches, limited_actions_copy).
    • server.rs::build_grpc_services passes an additional limited_actions_copy argument to the constructor.
  • In the request flow, the Policy is assembled and then passed to the downstream thrift_action call.
    • The assembly call is client_switches.limited_actions_policies(viewer_profile, country_code, locale, reasons, &limited_actions_copy, Some(stats)).

F. VF formally registered tweets carrying Brazil election labels to be handled on the drop path.

The brazil_election_legal/drop/undesirable labels were registered in rules/registry.rs. These labels were added at two places in the country_specific_nsfw_content_gating policy tree (the general drop list and the label drop list) verified — registry.rs:513-517, 648-652.

In the previous day's commit (Gen4+32), the home-mixer Brazil 2026 handle list was expanded from 2,786 to 2,795. In this commit, VF formally registered that a tweet carrying the brazil_election_legal label from the labeling service is handled on the drop path. The cooperation of two axes, the handle filter on the home-mixer side and the label filter on the VF side, is now stated explicitly in code.

G. abuse-enforcement-service gained a gate that decides whether to restart itself when the Kafka config changes.

The new file restart_on_config_change.rs (NEW +173) has three board states verified — restart_on_config_change.rs:1-85.

  • Check::Unchanged: there is no change in the watched paths.
  • Check::Invalid: there is a change, but validate(live) failed. It does not restart and only writes an error log.
  • Check::Restart(Vec<String>): there is a change and validate also passed. It returns the list of changed paths.

The JSON pointer paths watched by default are three: /kafka/consumer/topic_labels, /topic_labels, and /kafka/producer. If the override key restart_on_config_change is an array, those paths are used. The state is reported through the CONFIG_RESTART_PENDING{valid=true|false} metric.

H. The Kafka producer is now built from the config snapshot taken at startup.

The Kafka producer binding in abuse-enforcement-service lib.rs (+325/−55) changed to be based on the boot config snapshot. The signature changed from build_kafka_producers(cfg, dynamic_config) to build_kafka_producers(cfg, boot_config) verified — lib.rs:831-840, 1385-1392.

boot_config is a startup-time snapshot captured with dynamic_config.config().unwrap_or(serde_json::Value::Null). When building the producer, it references this Value and extracts the producer spec with kafka_producers_from_config(Option<&Value>). This function is new in growthbook.rs.

The implication of this change is that the Kafka producer reacts only to the boot-time config. Config changes during operation are handled differently by area.

  • The non-kafka area is updated by live reload.
  • The kafka area prompts a service restart through the restart_on_config_change gate.

There is no hot swap.

I. The CLI config of phoenix-rankall was split into a separate module.

The new module is config/mod.rs (NEW +84). The binary name is xai-recsys-rankall, and there are about 20 CLI arguments backed by env vars verified — config/mod.rs:9-96.

The constants worth noting are as follows:

  • KAFKA_BOOTSTRAP="/s/kafka/main-2:kafka-tls": it starts with / because it is a wily service URL.
  • SCORE_PREFIX="phoenix.HomeExperiment1Lap7.": the Phoenix ranking service publishes scores for Home surface Experiment1 Lap7 (presumably an experiment iteration number inferred) to the Kafka topic. phoenix-rankall consumes only scores with this prefix.
  • SID_BACKFILL_QPS_CAP=800_000: the QPS cap of the SID backfill is 800k.
  • The SID backfill targets only posts from the last 1 hour, with a minimum window of 3 minutes.
    • SID_BACKFILL_BATCH_SIZE=5_000 · SID_BACKFILL_INTERVAL_SECS=30 · SID_BACKFILL_MAX_AGE_HOURS=1 · SID_MIN_POST_AGE_SECONDS=180
  • DUMP_INTERVAL_SECS=180: it dumps Kafka every 3 minutes.
  • VERSIONS_TO_KEEP=3
  • SEEK_HOURS=12: it rewinds up to 12 hours.
  • MAX_POLL_RECORDS=500 · COMMIT_AFTER_DUMP=true · COMMIT_INTERVAL_SECS=0

consumer/kafka.rs (+55/−33) is left with only a thin adapter that delegates config extraction to KafkaConsumerConfigBuilder. The SASL mechanism, the bootstrap wily/non-wily branch, and the SSL config assembly logic were deleted.

J. The VF fallback cache was rewritten from a Mutex-wrapped shard structure to quick_cache's sync cache.

The target file is VF hydration/fallback_cache.rs (+69/−173).

  • The cache structure up to this commit was simplified to a single quick_cache sync Cache and OptionsBuilder verified — fallback_cache.rs:1-50.
    • The previous structure was 64 shards × Mutex-wrapped unsync Cache, with a generation counter and a CacheEntry wrapper attached.
  • The generic parameters also narrowed from FallbackCache<K, V> to FallbackCache<V>, and the key was hardcoded to u64.
  • This refactor removed every call that took the Mutex each time with the with_locked_shard pattern.

K. The rest are small changes.

  • In VF rules/golden_corpus/oon_tweet_label.rs (+76/−3), the golden corpus was expanded slightly.
  • In VF params/client_switches.rs (+63/−13), the test fixture was extended to load two files, scala_client_switches.yml and limited_actions_policy.yml.
  • The kafka_producers_from_config function is new in abuse-enforcement-service growthbook.rs (+132/−32).
  • abuse-enforcement-service rules.rs (+65/−51) is a minor lifetime adjustment that changes rules_cache.resolve(entity_type, rules_override.as_deref()) to use as_ref(). There is no change in meaning.
  • Phoenix python/training/xai-checkpointing/xai_checkpointing/orbax_encrypted.py (+42/−11) refactored checkpoint encryption. It could not be confirmed whether this is directly tied to feed ranking.
  • home-mixer params/param.rs (+4/−4) changed only the cron sync time, and the actual values are unchanged.

Why it matters Four facts overlap in this commit.

  1. The authority to decide "restriction reasons" was pulled into VF. Before this commit, the only information VF put in the engagement axis of the Verdict was a single reason enum. Which of the 26 actions to block and what to show in the UI were left to downstream (home-mixer, clients). From this commit, three things happen inside VF:

    • LimitedActionsPolicies holds the reason → action-list mapping, based on FeatureSwitches.
    • LimitedActionsCopy holds the (action, locale, country) → localized Prompt mapping.
    • treatment.rs::thrift_action combines the two and puts a per-action Prompt list directly into the Thrift payload LimitedEngagements.

    Clients no longer need to maintain their own localization or action-mapping tables, because every decision from reason to action to wording is declared as server-side data. This change pairs with Gen4+32, where treatment.rs was isolated as the rules → thrift/proto/metric projection layer. If the previous day's commit "gathered the wire projection point into one file," this commit "raises the richness of the data passing through that projection point to the UI level." The behavior where the UI buttons a user can press change with operational state (block, conversation control, stale) is now completed as a server-driven declaration.

  2. As LimitedEngagement becomes a structure that accumulates multiple reasons, the situation where one tweet has several restriction reasons at once is modeled explicitly. Before this commit, once rules decided the engagement, the next ActionSpec::LimitedEngagement match was dropped (a dead-code path). From this commit, when rules decide the engagement several times, the reasons are accumulated in a Vec via limit.value.add(*reason). In other words, for a viewer who is both BlockedViewer and ReadonlyViewer, both lists go into the payload and the choice is handed to downstream. BlockedViewer restricts 19 actions and ReadonlyViewer restricts 14, and combinations such as ConversationControl with StaleTweet, or RootAuthorBlockedViewer with BlockedViewer, also become possible. For the UI, there is now a path to answer "why is the Reply button disabled for this viewer and tweet combination" with "both because of BlockedViewer and because of StaleTweet."

  3. abuse-enforcement-service switched to a "boot-time config snapshot" model. Since the Rust reconstruction was released on 2026-09-23, this service had used live config through a DynamicConfig object. From this commit, the Kafka producer binding depends only on a JSON Value snapshot taken at boot. Instead, when the kafka settings (topic, SASL authentication, bootstrap servers) change during operation, the restart_on_config_change module restarts the service to apply the new settings. This approach does three things:

    • It keeps the kafka producer lifecycle simple (no hot swap).
    • It makes config changes clearly visible in logs and metrics (restart events).
    • It provides a fail-safe that does not restart if validate(live) does not pass.

    This restart applies only to changes in core settings; other settings are still applied through the live reload path.

  4. phoenix-rankall exposed an experiment-group identifier and a QPS cap. The CLI config module split looks like an organizational refactor, but two figures are exposed in the process. The first is SCORE_PREFIX = "phoenix.HomeExperiment1Lap7.". It means the Phoenix ranking service publishes scores of a specific experiment group, "Experiment1 Lap7" on the Home surface, to a Kafka topic, and phoenix-rankall consumes only scores with that prefix. The name "Lap7" appears to be an experiment iteration number inferred. The second is SID_BACKFILL_QPS_CAP = 800_000, a QPS cap of 800k for the SID backfill. The SID backfill is not applied to all posts; it is designed to process only posts in a specific window (at most 1 hour, at least 3 minutes). Adding that the number of posts shown per day is in the hundreds of millions to billions gives one picture: the SID embedding table is maintained only for the subset of posts with freshness within the last hour that is covered within the QPS 800k range. Posts outside the freshness window have no SID or remain stale.

The VF brazil_election_legal/drop/undesirable label registration pairs with the previous day's commit expanding the home-mixer Brazil 2026 handle list (2,786 → 2,795). home-mixer filters on the author axis using the handle list, and VF filters on the label axis using signals from the labeling service (Grok or human assignment). In effect, the declaration that the two paths work together during the election period is now stated explicitly in code. The rewrite of fallback_cache.rs is a performance refactor that removes Mutex contention, which resulted from delegating shard distribution and creation logic to a library.

Gen4+34 (2026-10-02, 76843a5) — generative SID retrieval microservice release (home-mixer SidSource + Phoenix-specific Transformer training config/model/trainer/runner + row-sharded embedding CUDA kernel bundle, 3,180 lines) + Phoenix proto reorganization (LLM_JUDGE_* 108/109 reappear, WEB_CONV_VALUE_USD/RATIO, viewer×advertiser engagement/conversion counts, exclusion bloom-filter axis in the Retrieve request) + outcome label added to VF vf_requests_by_caller + simplified home-mixer cold-start and engagement counts hydrator axes

X released generative SID retrieval in one commit, bundled as a separate microservice, Phoenix-specific Transformer training and serving code, and a home-mixer candidate source verified (daily). The new candidate source is off by default verified (same daily).

This commit changed 105 files, at a scale of +5,227/−756 verified — xai-org/x-algorithm@76843a5. It came one day after the previous commit, Gen4+33 (b79b947).

A. home-mixer was wired to use a dedicated generative SID retrieval microservice as a candidate source.

The new files are sources/sid_source.rs (new, +112) and clients/sid_retrieval_client.rs (new, +246). The call target endpoint is xai-recsys-sid-retrieval.prod.fou.twitter.biz:443.

  • The request sends seed posts, and the response returns a post id and shared prefix depth for each candidate verified — sid_source.rs:60-85.
    • The gRPC request shape is RetrieveRequest { seed_post_ids, max_results, max_per_seed, min_prefix_depth, max_prefix_depth, caller }.
    • The response shape is RetrieveResponse { candidates }, and each Candidate carries post_id and shared_prefix_depth.
  • shared_prefix_depth is passed downstream as an f32 in PostCandidate.retrieval_sources[0].score.

The source turns on only when all five conditions are met verified — sid_source.rs:58-64.

  • EnableSidSource=true
  • !query.is_topic_request()
  • !query.in_network_only
  • !query.has_cached_posts
  • has_post_signals(query)

The last condition, has_post_signals and post_signal_ids, reuses exactly the same functions as SimclustersSource verified — sid_source.rs:14. The two OON retrieval sources branch from the same seed set (the user's positive-engagement posts).

Five parameters were added verified — home-mixer/params/param.rs.

Parameter Default Role
EnableSidSource false Turns on the source itself
SidSourceMaxResults 800 Response cap (tightens the theoretical seeds × per_seed = 50×100 = 5000)
SidSourceMaxSeeds 50 Cap on seed posts in a request
SidSourceMaxPerSeed 100 Maximum result posts per seed
SidSourceMinPrefixDepth 3 Returns only candidates matching 3 or more shared prefix levels out of 6

Candidates from the SID source are labeled with the same ServedType as PhoenixMOESource. The label value is served_type=ForYouPhoenixRetrievalMoe. That is, for downstream scorers and filters, candidates from the two sources are indistinguishable on the label axis.

The SID retrieval client code sets the call deadline and connection path.

  • The gRPC deadline of 300ms is hardcoded (TIMEOUT_MS: u64 = 300, sid_retrieval_client.rs:21).
  • Connection is handled first by an xDS P2C load balancer, with a DNS RotatingChannel (60-second refresh) as the second fallback verified — sid_retrieval_client.rs:120-180.
    • If the build does not finish within 10 seconds or there is no bootstrap, it moves to the second fallback.
    • The discovery authority is xdstp://discovery-*/envoy.config.endpoint.v3.ClusterLoadAssignment/fou-xai-recsys-sid-retrieval.prod.prod:grpc-tls.

The readiness probe is GET /readyz on port 29180. There are two metrics:

  • SidRetrievalClient.retrieve{result=success|timeout|failure, path=xds|dns} labels the call result and path.
  • SidSource.retrieved{type=seeds|retrieved|hydrated} measures the volume at three stages.

B. Phoenix gained training and serving code for a dedicated SID generative retrieval Transformer.

There are four new files: config, model, trainer, and runner.

The training config publishes its hyperparameters verified — xrecsys_sid_retrieval.py:11-45, 130-180.

  • The input sequence is 1030 long, combining 1 user prefix token, 1023 tokens of user action history, and 6 tokens of SID target. The candidate sequence length is 64.
  • SID uses sid_num_levels=6 and sid_codebook_size=256, and the theoretical space is $256^6 \approx 2.8 \times 10^{14}$.
    • The remaining SID settings are sid_embed_dim=1024, sid_prediction_hidden_dim=512, sid_level_loss_decay=1.0 (uniform per-level loss weight), sid_hash_level=True, and sid_cross_attn=True.
  • The Transformer has 8 layers and uses GQA 20:4 (query_heads=20, kv_heads=4).
    • The remaining settings are key_size=128, emb_size=2560, attn_logit_cap=80.0 with soft_sign, rotary and causal, widening_factor=2 with gated_mlp=False, and remat WHOLE.
  • The input vocab is user_vocab=0 (unused), item raw vocab=0, item hash vocab 100M, author vocab 30M, action type 60, and continuous action 8.
    • All three axes use double hashing with two hash pairs (linear scale × modulus prime).
    • These figures exactly match the two-tower config released on 2026-09-30. The two models share the same vocab space.
  • The training optimizer is Adam (b1=0.95, b2=0.98).
    • The remaining training settings are weight_decay=1e-3, constant lr=2e-3, batch size per device=128, parallel ep=128·dp=1 (128-way expert-parallel), and max_steps $\approx 3.125 \times 10^9$.
  • The dataset is set to aggregated_kafka, num_kafka_partitions=1024, use_post_sid=True, and multimodal_embedding_type=None.
  • The checkpoint is set to every_n=100 · keep_every_nth=1000 · keep_last_n=10 · verify_checksums=True · replica_axis="replica", and chunked and compressed are both False.

On the model side, there is only one positive action, SERVER_TWEET_FAV verified — recsys_sid_retrieval_model.py:40.

  • Seven hard-negative actions are enumerated explicitly.
    • CLIENT_TWEET_REPORT·_NOT_INTERESTED_IN·_SEE_FEWER·_UNFOLLOW_AUTHOR·_BLOCK_AUTHOR·_MUTE_AUTHOR·_NOT_RELEVANT
  • The SID token embedding consists of a separate table per level and a projection per level.
    • The table is sid_emb_level_{level} with size [256, 128].
    • The projection is sid_emb_proj_{level}, which raises 128 → 1024.

The inference runner sid_retrieval_runner.py holds two separate axes. One is beam search–based SID generation, and the other is reverse resolution from SID to post_id through SidPostIndex/SidBeamResolver.

  • The runner flags beam_width and decode_levels control the beam width and generation depth, respectively.
  • If decode_levels < 6, it stops at the prefix level and returns the set sharing the prefix (prefix retrieval).
  • This pairs with home-mixer SidSourceMinPrefixDepth=3. The server generates 3–6 levels, and a post matched at level 3 can return shared_prefix_depth=3 as its score.
  • _CorpusView exposes post_ids_with_sentinel, author_ids_with_sentinel, and sentinel_idx, and the serving-time corpus is a prebuilt SID trie with a set of post_ids at the leaves, with beam search running over the trie inferred.

This design overlaps in role with the Google TIGER (Rajput et al., Recommender Systems with Generative Retrieval, NeurIPS 2023, paper) · Alibaba PailitaoGR(2025) · Kuaishou OneRec(2025) · Tencent BARGE(2026) · ByteDance GEAR(arXiv 2026-09-30) line of work. Compared with these, the X version has four distinguishing features:

  • It uses 6-level × 256-codebook. TIGER uses 3-level × 256 codebook plus one fourth code to separate collisions verified — arXiv 2305.05065 (paper, 2023).
  • It conditions directly on a 1023-token action history.
  • It passes shared_prefix_depth to the ranking score of the stage above (TIGER uses generation probability).
  • It already shares the vocab design with the two-tower, placing the two retrieval axes in the same user, item, and author embedding space.

C. Phoenix gained a bundle of CUDA kernels for row-sharded embedding tables.

The new directory is phoenix/xrex/cuda/row_emb/, a 12-file bundle with +3,582 lines verified — phoenix/xrex/cuda/row_emb/.

  • Size per file: row_emb_api.cc(+766) · row_emb_comm.cc(+1,104) · row_emb_kernel.cu(+629) · row_emb.py(+290) · row_emb_comm.hpp(+256) · comm_utils.cc(+146) · row_emb_kernel.hpp(+135) · row_emb_common.cuh(+82) · cuda_error_utils.hpp(+68) · comm_utils.h(+57) · comm_utils.py(+47) · __init__.py(+2)

Phoenix's existing async_emb handles embedding tables sharded along the column axis. By contrast, row_emb handles tables sharded along the row axis.

  • If the vocab has N entries, N / group_size rows are loaded on each rank verified — row_emb.py:95-145.
  • Six JAX FFI targets are registered on the CUDA platform.
    • xrex_row_emb_lookup_start · _lookup_done · _stage_update · _rowwise_adagrad_update_start · _rowwise_adagrad_lazy_update_start · _rowwise_adagrad_update_done
  • RowEmbContextHandle bundles the following values.
    • (context_id · group_size · flatten_replicas · tokens_per_rank · emb_width · rows_per_rank · recv_capacity · num_devices_per_node · mesh · table_axis · data_axis)

There are 3 key design decisions.

  1. It splits lookup into two stages to expose the overlap of communication and compute.
    • lookup_start(token_ids, table, gate) returns (table_out, lookup_pin), and lookup_done(pin) returns embeddings.
    • The window between the two FFIs becomes the compute insertion point, and all-to-all overlaps with compute.
    • recv_capacity = min(ceil(tokens_per_rank × recv_factor), tokens_per_rank × group_size) sets an upper bound on the receive buffer in advance.
  2. Rowwise Adagrad is fused in the same two-phase structure as lookup.
    • rowwise_adagrad_update_start updates the table in place (input_output_aliases={0:0, 1:1}), and _update_done returns a completion signal.
    • rowwise_adagrad_lazy_update_start takes last_step and step and lazily updates only the accessed rows. This is a common optimization for sparse embeddings.
  3. The gate axis guarantees the XLA execution order.
    • Every FFI takes gate: jax.Array as an I/O axis to make the side-effect chain explicit. It is a device that keeps XLA from freely reordering.

Validation checks the following conditions:

  • vocab_rows % group_size == 0
  • tokens_per_batch % group_size == 0
  • emb_width % 8 == 0
  • recv_factor > 0
  • table_axis ⊂ data_axis: if it is not contained, a "token shard is not one per rank" ValueError is raised.

Phoenix's recsys_async_emb.py (+112/−36) was rewritten as an adapter layer that accepts both async_emb and row_emb verified.

  • The is_row_sharded(context_handle) branch decides between column and row.
  • table_spec returns P(table_axis, None) for row and P(None, table_axis) for column.
  • That is, the Phoenix async embedding utility was promoted to support both sharding strategies at once.

D. The Phoenix proto was reorganized, and the same change also went into the Python mirror. verified — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto

  • Two LLM judge actions whose slots had been vacated reappeared with changed numbers.
    • The new values are LLM_JUDGE_RELEVANT_TO_SEARCH=108 and LLM_JUDGE_NOT_RELEVANT_TO_SEARCH=109.
    • The two actions were introduced in Gen4+20 (2026-09-12) as 107/108.
    • They were vacated in Gen4+23 (2026-09-17), after 5 days. Slot 107 was permanently taken by CLIENT_TWEET_LIVE_STREAM_REPLY, and 108 was preserved as RESERVED_108.
    • In this commit (Gen4+34, after 15 days), they reappeared renumbered to 108/109. RESERVED_108 is reused as LLM_JUDGE_RELEVANT_TO_SEARCH, and the new number 109 is the NOT_RELEVANT side.
    • No training config or wire consumer was released in this commit. Only the proto claimed the slots.
  • A ContinuousActionName that splits ad conversion value into two axes, absolute USD value and ratio, was introduced.
    • The new values are WEB_CONV_VALUE_USD=6 and WEB_CONV_VALUE_RATIO=7.
    • Two were added to the six existing continuous actions (ACTIVE_SECS_* · BRIDGE_PROBABILITY=4 · ADS_WEB_CT_PURCHASE_VALUE=5).
  • Ads ranking can now receive viewer × advertiser engagement/conversion history as features.
    • AdInfo gained viewer_advertiser_engagement_counts (field 24) and viewer_advertiser_conversion_counts (field 25).
    • Both are the existing EngagementCounts type.
  • EngagementCounts gained collected_actions (field 6, repeated int32). It also carries on the wire which action types were included in the aggregation.
  • Two-tower retrieval now also accepts seed-conditioned search and bloom-filter exclusion of already seen posts. RetrieveTopKCandidatesRequest was expanded along three axes:
    • Field 14 seed_post_ids
    • Field 15 excluded_posts_bloom_filters (repeated ExcludedPostsBloomFilter { bit_array: repeated fixed64, size_cap, false_positive_rate })
    • Field 16 excluded_post_ids
    • The design converged on SidSource (a separate microservice) and two-tower retrieval sharing the same seed axis.
  • A UnifiedCardType enum (29 variants) that enumerates ad unified card formats at the proto level was introduced. Ads ranking claimed a slot to receive the unified card format as a categorical feature.
    • Example values: IMAGE_WEBSITE·VIDEO_WEBSITE·IMAGE_APP·...·VIDEO_PLAYABLE_WEBSITE·IMAGE_COLLECTION_WEBSITE
  • sid_lookup.proto was released in the new crate xai-recsys-sid-proto verified — phoenix/crates/serving/xai-recsys-sid-proto/proto/sid_lookup.proto.
    • SidLookupService.LookupSids(repeated int64 post_ids) → repeated PostSids { repeated int32 codes } provides only the post_id → SID code array mapping.
    • The lookup sidecar that disappeared on 2026-09-04 (Gen4+15) returned as a separate service.

E. A request-outcome label was added to VF's per-caller request metric.

The change is in VF caller_identity.rs (+90/−15), and the new label axis is outcome. The vf_requests_by_caller metric expanded from yesterday's three labels {caller_identity, rpc, deadline} to four labels {..., outcome} in this commit verified — caller_identity.rs:16-62.

  • A three-variant enum Outcome { Success, Failure, Cancelled } is new.
  • The record() function now returns a CallerRequest guard struct with a must_use annotation.
  • If a handler drops it without calling mark_success() or mark_failure(), it is counted as Cancelled.
  • The Drop impl emits the metric (RAII pattern).

The new test dashboard_generator_pins_the_requests_by_caller_metric pins the dashboard Python script and the Rust constants together. The script's REQUESTS_BY_CALLER_METRIC, REQUESTS_BY_CALLER_NO_DEADLINE_FILTER, and success/non-success filter constants, four in all, are the pass condition of the Rust test. The design subjects the metric definition to drift checks on both the Rust and dashboard sides at once.

F. Conditions in the home-mixer cold-start boost and the engagement counts hydrator were simplified.

  1. The is_arm_gated_retrieval function that identified Phoenix MoE/Cold retrieval sources was deleted verified — author_cold_start.rs +15/−38.
    • Until yesterday, the Control arm excluded "posts from the Control corpus that also came from Phoenix MoE/Cold retrieval" from the cold-start boost.
    • From this commit, in the Control arm all posts in the Control corpus are boost targets regardless of retrieval source.
    • The test cold_retrieval_keeps_its_score_in_every_arm was deleted, and control_viewer_lifts_cold_retrieval_from_control_corpus takes its place.
    • The is_moe label was removed from the metric home_mixer.cold_started_posts_total.
    • Meaning: the cold-start boost narrowed from three axes, arm × corpus × source, to two axes, arm × corpus.
  2. In EngagementCountsHydrator, the cold-start eligibility branch for the cached_posts situation was removed verified — engagement_counts_hydrator.rs +16/−48.
    • Previously, when has_cached_posts was set, it refetched only candidates that passed cold_start_base_eligible and kept cached values for the rest via preserve_counts.
    • From this commit, it refetches all candidates unconditionally.
    • The preserve_counts helper was deleted. The ColdStartFollowerCap dependency was removed.
    • Meaning: the interdependence between the engagement counts axis and cold-start is gone.
  3. home-mixer/util/author_rules.rs (156 lines) was deleted, and the symbol moved to xai_feature_switches::AuthorRulesEvaluator verified.
    • The author-rule evaluation utility inside home-mixer was promoted to a shared crate.

G. The rest are small changes.

  • Two tests were added to VF filter.rs (+94/−2). They pin the decision behavior of viewer_mutes_retweets and nullcasted_tweet when PureCore fails, and there is no policy change.
  • VF hydration/execute.rs (+68/−2), hydration/tweet_source.rs (+8/−14), and safety_label_source/metrics.rs (+29/−7) were refactored slightly as follow-ups to the Fetcher<V> rewrite in Gen4+32 (2026-09-30).
  • Test fixtures were separated in VF vf_client.rs (+1/−35) and test_support.rs (+37/−0).
  • Phoenix kms_client.py (+74/−0, new) is a shared KMS wrapper.
  • Phoenix trainer_recsys.py (+164/−39) separated the inheritance axis of the SID retrieval–specific trainer from the shared trainer inferred.
  • Phoenix rowwise_adagrad.py (+14/−9) received the row_emb adapter change.
  • abuse-enforcement-service/overturn_hold.rs (+22/−22), thunder/post_store.rs (+20/−20), and phoenix-rankall/store/base.rs (+12/−12) made minor fixes to tests and adapters.
  • home-mixer/params/param.rs (+31/−1) accounts for the addition of the five SID source params above.

Why it matters

Three facts coincide in this commit.

  1. This is the first commit in which X released generative SID retrieval as three complete parts: a "separate microservice + dedicated Transformer training and serving stack + home-mixer candidate source wiring".
    • So far, X's SID has appeared only indirectly. One appearance was as an input feature of the two-tower item tower (the use_post_sid flag in xrecsys_two_tower.py, released 2026-09-30).
    • The other was phoenix-rankall's backfill QPS cap of 800k (SID_BACKFILL_QPS_CAP = 800_000 in Gen4+33 yesterday).
    • However, no line that generates SIDs directly and returns the result as candidates had been released until now.
    • This release bundles the entire chain into one commit, made up of three parts:
      • It introduced the four files (config, model, trainer, runner) of a dedicated Transformer model (8-layer, GQA 20:4, 1030-length context, 6-level × 256-codebook) on the Phoenix side.
      • It released the gRPC client and request shape of the separate microservice (seed_post_ids, the prefix retrieval axis min_prefix_depth=3) and the shared_prefix_depth score of response candidates. That microservice is xai-recsys-sid-retrieval.prod.fou.twitter.biz.
      • It streams candidates labeled with the same ServedType as PhoenixMOESource through home-mixer SidSource.
    • Because it is off by default with EnableSidSource=false, the checkpoint to watch for real traffic is the day it is turned ON in the FeatureSwitches cron.
  2. Two-tower retrieval and SID generative retrieval are designed not as "replacements for each other" but as "two layers on the same vocab space."
    • The two models share the same user, item, and author hash vocab (100M · 30M · identical double hashing scales/biases). This shows up when comparing the figures in xrecsys_sid_retrieval.py:30-45 with those in xrecsys_two_tower.py, released 2026-09-30.
    • This matters because generative retrieval is expensive on two cost axes, SID generation time complexity (beam_width × decode_levels × codebook size) and corpus-side SID index maintenance. The design appears to be a complementary structure where two-tower first covers the broad recall that ANN can run, and SID retrieval adds supplementary recall on the prefix-sharing axis inferred.
    • With the same vocab, both models could run on a shared embedding table, but the config released at this commit gives each its own table. Whether they are unified into a shared embedding must be observed in the next commit.
  3. The row-sharded embedding CUDA kernel bundle is a two-axis extension of the Phoenix embedding design itself.
    • The 3,582-line row_emb was not added solely for the generative retrieval model. However, in the context of this commit bundle, the natural reading is that the huge-vocab embeddings that SID generative retrieval requires (100M · 30M · 6-level × 256 codebook) are the trigger.
    • The existing async_emb is column-sharded and the new row_emb is row-sharded, and a shared adapter (recsys_async_emb.py) selects one of the two sharding strategies. In effect, this provides a place to choose the optimal sharding per model.
    • two-tower can run with column-shard for each of the user and item towers, but in the SID generative model each token references only its own vocab range along the generation axis, so row-shard yields a more compact communication pattern inferred.

The reappearance of the LLM_JUDGE_* actions is a separate thread. Two actions that had vacated their slots 15 days ago reappeared with renumbered IDs (107/108 → 108/109). This indicates that the direction of "carrying the LLM judge on the action axis and consuming it as one of the training targets" has resurfaced after readjustment. The numbers changed because CLIENT_TWEET_LIVE_STREAM_REPLY permanently took slot 107 in between, and 108 was also vacated and then reused. No consumer in a model training config or on the wire appears in this commit; only the proto claimed the slots. Which head weight and which config's target list they enter is a checkpoint to watch in the next window.

With this commit, a server crate that receives real requests and responds has been attached to the semantic ID-based candidate search, for which only training code and a client had been released verified. home-mixer gained a source, off by default, that supplements out-of-network recommendations with recent posts from popular authors, and the mirror default of the ads brand safety V2 verdict switch changed from false to true verified (daily).

148 files changed, with a change scale of +9,128/−2,356 verified — xai-org/x-algorithm@b412112. It comes one day after the previous commit, Gen4+34 (yesterday's 76843a5).

A. A new Rust crate phoenix/crates/serving/xai-recsys-sid-retrieval/ that serves generative SID retrieval has been added, with 7 files and 1,062 lines. Yesterday's release (76843a5) brought the Phoenix-side training config, model, trainer, and runner and the home-mixer-side SidSource client. However, the server code that would receive real requests and return responses was missing. Today that slot was released verified — phoenix/crates/serving/xai-recsys-sid-retrieval/.

Per-file sizes are src/service.rs:208 · src/index.rs:223 · src/loader.rs:170 · src/http_api.rs:109 · src/main.rs:115 · src/seed_sids.rs:114 · src/metrics.rs:79. There is also a separate proto crate xai-recsys-sid-retrieval-proto (sid_retrieval.proto:46).

The proto defines one RPC, a request message, a candidate message, and a seed source enum verified — sid_retrieval.proto:

service SidRetrievalService {
  rpc Retrieve(RetrieveRequest) returns (RetrieveResponse);
}
message RetrieveRequest {
  repeated int64 seed_post_ids = 1;
  uint32 max_results = 2; uint32 max_per_seed = 3;
  uint32 min_prefix_depth = 4; uint32 max_prefix_depth = 5;
  string caller = 6;
}
message Candidate {
  int64 post_id = 1; int64 author_id = 2;
  int64 seed_post_id = 3; uint32 shared_prefix_depth = 4;
}
enum SeedSidSource { UNSPECIFIED=0; INDEX=1; SID_SERVICE=2; MISSING=3; }

The index ties each SID to one integer key and holds them in two vectors with different sort orders verified — src/index.rs:5-110:

  • SID_LEVELS = 6, BITS_PER_LEVEL = 8. So each level's code range is 0–255, and the full SID is packed into a single 48-bit u64 key. pack_full_key stacks the levels from the top level down using left shifts.
  • It maintains two IndexedPost {key, post_id, author_id} vectors.
    • by_key is sorted by key in lexicographic order, and within the same key by post_id in descending order. It is used for prefix range search.
    • by_post is sorted by post_id ascending. It is used for binary search when converting a seed post to its SID.
  • prefix_range(key, depth) preserves only the top d levels at depth d and returns a [lo, hi) range with the lower levels cleared to 0 or filled. It pulls the posts within that range from by_key using partition_point.

Search collects candidates going from deep prefixes to shallow ones verified — src/index.rs:138-193:

// retrieve_for_seed(seed_post_id, seed, params, excluded):
//   depth := max_prefix_depth..=min_prefix_depth in reverse
//     level_range := the by_key slice within prefix_range(seed.key, depth)
//     consumed := the inner_start..inner_end region used earlier at a deeper depth
//     level := level_range \ consumed \ excluded(seed post_ids)
//     if level.len() > remaining: truncate with select_nth_unstable_by(post_id DESC)
//     level sort by post_id DESC
//     emit Candidate(post_id, author_id, seed_post_id, shared_prefix_depth=depth)
//     consumed := level_range

This algorithm has three properties:

  • Each post is emitted "only once, at the deepest prefix level it shared." A post matched at depth 6 is not picked again at the levels of depth 5–3.
  • Within the same depth, posts with a larger post_id (= more recent) take priority.
  • The seed post itself is filtered out by the global excluded set (with multiple seeds, seed₂ does not get mixed into seed₁'s results).

When there are multiple seeds, the candidate lists of the seeds are drawn alternately in round-robin (seed 1 → seed 2 → ... → seed N → seed 1 → ...) while duplicates are removed globally verified — src/index.rs:196-222. It stops when it reaches max_results or there are no more candidates to draw. As a result, position bias is evened out so that it does not skew toward a particular seed.

The snapshot loader periodically checks the parquet snapshot path and reloads the entire index when the path changes verified — src/loader.rs:1-170:

  • The input is an Arrow Parquet file (post_sid_snapshot.parquet) with three columns: post_id: int64, author_id: int64, and post_sid: list<int64>. The record batch size is BATCH_SIZE = 65_536.
  • The timestamp is taken from the filename suffix (*_<unix_ts>.parquet), and it falls back to mtime if parsing fails.
  • watch_snapshots(path, poll_interval, index, ready_tx) calls canonicalize(path) every 30 seconds (default) and reloads everything if the path changed. The new index is built in tokio::spawn_blocking. On success it is atomically swapped via ArcSwapOption<SidIndex>, and ready_tx.send(true) raises /readyz to initial-ready.
  • pack_full_key(row_codes) returns None if len != 6 or any single code falls outside 0..=255, and the skipped counter increments. That is, invalid SIDs are silently dropped.
  • The metric function record_index_load(index, elapsed_s) emits the sid_index_posts gauge, sid_index_skipped_posts, and the sid_index_load_seconds histogram together.

The SID of a seed post is looked up first in the local trie, and if it is absent, an external lookup service is queried verified — src/seed_sids.rs:1-114:

  • If the seed post_id is in the local trie, the code of that trie entry (valid_depth=6) is used as is.
  • If not, it does a batch lookup against a separate gRPC service SidLookupService (xai-recsys-sid-proto, endpoint set by the --sid-endpoint flag).
  • SidLookupService is, in effect, the lookup service that was deleted as an internal sidecar of the Phoenix serving engine in Gen4+15 (2026-09-04, 9b0dc31), now reappearing as a separate external service.
  • The moka cache has 2 million entries and a TTL of 6 hours. If codes.is_empty() in the response, the seed is classified as Missing.

The service layer cleans up the seeds and then finds SIDs by source verified — src/service.rs:68-112:

  • It dedups seed_post_ids in the request and keeps only up to max_seeds (default 200).
  • For all seeds it tries lookup(post_id) in the local trie, and batch-calls SidLookupService.lookup(missing) only for the missing ones (in parallel). Then it classifies each seed into one of three paths (Index / SidService / Missing) and emits it as a metric label.
  • The request path has 5 metrics verified — src/metrics.rs:35-71.
    • sid_retrieval_requests_total{caller, result} counter
    • sid_retrieval_seeds_total{source} and sid_retrieval_sid_service_lookups_total{result} counters
    • sid_retrieval_candidates_per_request{caller} and sid_retrieval_request_latency_seconds{caller} histograms

The defaults of the main.rs flags are verified — src/main.rs:26-52:

  • Ports: --grpc-port=9090, --http-port=8080
  • Snapshot: --snapshot-path=post_sid_snapshot.parquet, --snapshot-poll-secs=30
  • SID lookup: --sid-endpoint=http://localhost:50051, --sid-timeout-ms=200, --sid-cache-size=2_000_000
  • Request caps and defaults: --max-seeds=200, --default-max-results=800, --default-max-per-seed=100, --default-min-prefix-depth=3, --default-max-prefix-depth=6

B. home-mixer added a new source that supplements OON recommendations by feeding the top-K popular authors into the Thunder in-network lookup as "fake following". The added files are sources/popular_posts_source.rs (+116), util/popular_authors.rs (+229), and clients/popular_authors_store_client.rs (+79), all of them new.

PopularPostsSource operates in this order verified — popular_posts_source.rs:68-115:

  1. The source is enabled when !has_cached_posts && !in_network_only && EnablePopularPostsSource==true.
  2. It calls PopularAuthorsCache.maybe_spawn_refresh(now_ms) to start a background refresh. The refresh runs only when needed, and a single token-held AtomicBool prevents duplicate runs.
  3. top_author_ids(top_k) picks the author_ids of the top K (default 1000).
  4. It sends GetInNetworkPostsRequest { user_id, following_user_ids=[top K authors], max_results=500, per_author_limits: {3, 0}, exclude_tweet_ids=seen_ids, algorithm=ThunderAlgorithm, debug=false, is_video_request=false } to Thunder. Thunder treats those K authors as if the user follows them and returns posts from its in-network post store in recency order.
  5. Response posts are tagged with the label served_type=ForYouPhoenixRetrieval and returned as PostCandidate. This label is the same as PhoenixSource's label.

PopularAuthorsCache holds a popular-author snapshot and updates it periodically verified — util/popular_authors.rs:135-223:

  • It consists of RwLock<Snapshot>, AtomicBool refreshing, and Arc<dyn PopularAuthorsStore>.
  • The refresh interval is authors.is_empty() ? 60 seconds : 60 minutes. It is a common pattern that retries aggressively only when the cache is empty.
  • The store's byte format is version=1(1B) + updated_at_ms(8B BE) + count(4B BE) + [author_id(8B BE), follower_count(8B BE)] * count verified — util/popular_authors.rs:64-104. That is, 1,000 authors take 13 + 16,000 = 16,013 bytes, all stored under one Manhattan key.
  • sort_by_followers sorts by follower_count in descending order (ties by author_id ascending).
  • The metrics are the PopularAuthorsCache.Refresh{result=ok|error} counter and the PopularAuthorsCache.LoadedAuthors gauge.

Five parameters were introduced verified — home-mixer/params/param.rs:

Parameter Default Role
EnablePopularPostsSource false Turns the source itself on
PopularPostsTopAuthors 1000 How many of the top authors from the KV store go into the "virtual following"
PopularPostsMaxResults 500 Cap on the Thunder response
PopularPostsMaxPerAuthor 3 Max posts for 1 author
PopularPostsMaxRepliesRepostsPerAuthor 0 Replies/retweets are excluded from the source entirely

By design, this source reuses the ForYouPhoenixRetrieval label (= PhoenixSource's label). So to downstream scorers and filters it looks like "additional OON candidates returned by Phoenix two-tower retrieval." However, the mechanism is not embedding similarity but a "global popularity centroid of the follow graph." It is effectively a global fallback for cold start/exploration on the follow graph. It is a typical global popularity backfill pattern, used "so that Thunder responds with something even if the user follows no one" or "to fill the gap when other OON sources cannot return results because user engagement signals are too few" inferred.

C. The ads brand safety V2 verdict default changed to ON. verified — home-mixer/params/param.rs:

  • EnableAdsBrandSafetyVerdictV2 changed from false → true. This switch was introduced as false in Gen4+2 (2026-08-17, b089ce6), was true from Gen4+8 (2026-08-25), and went back to false in Gen4+22 (2026-09-16) verified — param.rs in b089ce6, 0d3cdd8, and fad2f71. This is the second switch to ON. The value appeared at mirror sync point last sync 2026-10-01T16:00:46Z → 2026-10-02T16:00:41Z. It appears to mirror a state pushed by a production cron at some point during 2026-10-02.
  • EnableAdsAuthorBrandSafetyFallback was introduced (off by default). It is a path that, when an author-level Grok label (GrokSfa/GrokNsfaLimited/GrokNsfa) exists but no post-level label does, inserts the author label as a fallback post label.

The core data structures are the verdict enum and the author fallback enum verified — home-mixer/models/brand_safety.rs:6-80:

#[repr(i32)]
pub enum BrandSafetyVerdict {
  Unspecified = 0, Safe = 1, LowRisk = 2, MediumRisk = 3, HighRisk = 4,
}
pub enum AuthorBrandSafetyFallback { Sfa, NsfaLimited, Nsfa }

The Verdict 4-tier (Safe / LowRisk / MediumRisk / HighRisk) is consumed in the adjacency matrix that corresponds to the ads blender's AdAdjacencyControl.brand_safety_risk. This matrix can be seen in the MultiRiskAdsBlender entry of Gen4+10. In other words, the "post safety → whether ads can be inserted" decision axis of the ads surface has been on as a default source since today.

D. A VF staging replay subsystem was introduced (visibility-filtering/staging/, 6 new files, +1,334 lines). Per-file changes are recording.rs (+743) · replay_corpus.rs (+298) · capture.rs (+220) · reference/tweetypie.rs (+204/-81 renamed) · reference_compare.rs (+63/-39 renamed) · reference/mod.rs (+35/-72 renamed) · main.rs (+39) · serve.rs (+28) · mod.rs (+6). The directory as a whole is +1,636/−192 verified — per-file statistics of b412112. Until Gen4+34 yesterday, there was no visibility-filtering/staging/ folder. This folder, created today, has a crate entry point (staging/main.rs) separate from the main binary. It is a subsystem for running VF in a replay/capture mode fully separated from live traffic. The shared referential comparison (Tweetypie reference) code also moved here. Three parts need a deep dive:

  • What recording.rs's 744 lines record and in what format (presumably parquet/jsonl)
  • How capture.rs samples live traffic
  • The replay contract of replay_corpus.rs

E. A Phoenix row-emb checkpoint restore utility was introduced (phoenix/xrex/train/row_embedding_restore.py, +488 new). The checkpoint-restore side of the row-sharded embedding CUDA kernel bundle (cuda/row_emb/) released in Gen4+34 yesterday has been completed in Python. In effect, the shared adapter (recsys_async_emb.py) and the restore utility came in together. A deep dive is needed.

F. Phoenix gRPC server latency measurement was split into a separate module (phoenix/crates/serving/xai-recsys-engine/src/grpc_server_latency.rs, +361 new). It is a module dedicated to server-side latency, separate from the existing inflight metrics. This change widens operational observation and is not a model-side change.

G. Other small changes are as follows:

  • From the shared trainer trainer_recsys.py (+376/-853), the SID retrieval trainer share that was separated yesterday was removed, so the shared code shrank purely.
  • In recsys_model.py (+786/-431), model code was rearranged. The utilities that the SID retrieval model, introduced yesterday, shared with the common model were cleanly separated.
  • parquet_recsys.py (+325/-1) added a parquet source to the training data pipeline.
  • ads_brand_safety_vf_hydrator.rs (+275/-20), which until yesterday looked only at post-level labels, accepted a fallback branch for author-level Grok labels. This label is fetched via the new file author_brand_safety_client.rs (+281 new).
  • VF's filter_tweets.rs (+94/-22), hydration/sources.rs (+125/-22), rules/tweet_rules.rs (+84/-94), rules/registry.rs (+4), rules/golden_corpus/trusted_friends.rs (+31 new), and golden_corpus/mod.rs (+2) were slightly refactored, following yesterday's limited-actions system.
  • vm-ranker/scoring/value_model.rs (+96/-5) and scoring/mod.rs (+85/-2) declared pacing-input inputs in VMRanker. The linked param VMRankerSendPacingInputs is off by default.
  • In author_cold_start.rs (+53), is_arm_gated_retrieval, which was deleted yesterday, is used again on the author cold-start side. It was revived in a different branch specific to cold start.
  • The SimClusters ANN cache client was split out into home-mixer/clients/simclusters_ann_cache_client.rs (+211 new).

Why it matters

Three facts coincide in this commit.

  1. The "actual server crate" was attached today to the three axes of yesterday's released generative SID retrieval (home-mixer source, Phoenix model/trainer, proto). Until yesterday only the training recipe and the home-mixer source skeleton existed, and the server was empty. The xai-recsys-sid-retrieval crate released today states the following three things explicitly, so this server's actual query complexity can be measured from released code alone:

    • It is a fully static index that reloads everything from the parquet snapshot every 30 seconds. There are no real-time upserts; the pattern is to push in batches the code table that the model extracted.
    • It uses a 6-level trie packed into a 48-bit u64 key and partition_point-based range search.
    • It uses an algorithm that trims recall with reverse depth order and round-robin interleave.

    EnableSidSource=false is still the default, so the checkpoint to watch is the day it is turned ON in the FeatureSwitches cron for real-traffic use.

  2. PopularPostsSource reuses the Thunder in-network index from a "global popularity" angle and mixes its results, under the ForYouPhoenixRetrieval label, with Phoenix two-tower retrieval responses. In the 2026-10-16 commit (Gen4+43) the label was split into ForYouPopularPosts, so the description below holds only until then. It is interesting that it was designed not to be distinguishable from PhoenixSource by label. To scorers and filters it appears as "Phoenix retrieval returned K more," so at upper ranking stages it blends in with the same distribution as PhoenixScore. Mechanically, it is not embedding-based retrieval. It is a retro approach that puts the global popularity centroid of the follow graph into Thunder as "virtual following." EnablePopularPostsSource=false is still the default, so the point at which it is actually turned on is the checkpoint to watch in the next window.

  3. Both ads-side changes expand "SFA (Suitable For Advertising) labeling from post level → author level". It is no coincidence that the EnableAdsBrandSafetyVerdictV2=true switch and the introduction of EnableAdsAuthorBrandSafetyFallback arrived together. The direction is to use the author's past label pattern as a fallback for posts that do not yet have a post-level Grok label. This is a signal that it aims to complement, through author-level aggregation, the situation where Grok classifier coverage is sparse at the post level (especially for new posts) inferred.

Gen4+36 (2026-10-06, e62790c) — Added 3 "post-open dwell seconds" terms to the ranking value model (video continuation · per-user video continuation · profile visit secs, default weight 0) + rewrote PopularPostsSource as two view-count-based offline jobs + added "Under the Hood" pipeline trace response + added NOTICE level to VF verdicts

In this commit the ranking value model gained three expected-dwell-time terms that multiply a probability head by conditional dwell seconds, but all weights are 0, so serving is not yet affected verified. The popular-posts source now reads a global list that an offline job picked by view count, instead of calling Thunder on every request verified (daily).

The commit changed 103 files, at +6,916/−1,393 verified — xai-org/x-algorithm@e62790c. It came three days after Gen4+35 (b412112, 2026-10-03). The cron mirror sync time moved from 2026-10-02T16:00:41Z to 2026-10-05T16:00:50Z.

A. The value model gained three conditional expected-dwell-time terms. verified — xai-value-model/scoring.rs:61-63, 111-128, 141-158:

fn product(probability: Option<f64>, conditional_value: Option<f64>) -> Option<f64> {
    Some(probability? * conditional_value?)
}
...
apply(product(scores.video_open_score, scores.home_video_continuation_secs), weights.video_continuation),
apply(product(scores.video_open_score, candidate.user_video_continuation_secs), weights.user_video_continuation),
apply(product(scores.profile_click_score, scores.home_profile_visit_secs), weights.profile_visit_secs),
  • Phoenix proto ContinuousActionName gained HOME_PROFILE_VISIT_SECS = 0 and HOME_VIDEO_CONTINUATION_SECS = 3 verified — recsys.proto:594-599. Number 0 was held by INVALID_CONTINUOUS_ACTION_NAME and number 3 by ACTIVE_SECS_5M_RESIDUAL_NORM.
  • The two heads are multiplied with probability heads. That is, it is an expected-value decomposition: "probability of opening × seconds of continued viewing after opening." The existing cont_dwell_time and cont_click_dwell_time put the head value directly into the weighted sum verified (the apply(scores.dwell_time, ...) line in the same function).
  • set_user_video_continuation computes Σ P(open)·secs / Σ P(open) over all candidates in the request and assigns the same value to every candidate verified. As a result, this term does not change the ranking among candidates; it scales the coefficient of P(video_open) up or down per user inferred. The basis is that the value is constant across candidates, so the term takes the form weight × user_secs × P(open).
  • The cron mirror defaults for the three new weights (VideoContinuationWeight, UserVideoContinuationWeight, ProfileVisitSecsWeight) are all 0.0 verified — vm-ranker/params.rs. In home-mixer, weights_for fixes all three at 0.0 verified — home-mixer/scorers/value_model.rs:37-39. Therefore the only path that can actually turn them on is the VM-Ranker service.
  • home-mixer's extraction of the two heads from the Phoenix response is also behind EnableHomeExcursionScores (default false) verified — home-mixer/scorers/phoenix_scorer.rs:109-117, 141-153. The training-side aggregation type DENSE_WITH_LONG_DWELL_HOME_EXCURSION = 30 took the PLACEHOLDER_30 slot verified — recsys.proto:1815.
  • "home excursion" appears to be a name for the time spent after leaving the home feed for a video or profile inferred. The basis is that the head names all have the HOME_ prefix and are multiplied with the paired probability heads (video_open, profile_click).
  • In the README's Phoenix prediction list, the Attention line dropped active seconds and gained video continuation seconds · profile visit seconds verified — README.md:341.
  • The post_unexplored (new-author boost) term has so far been attached only to in-network candidates. Turning on PostUnexploredIncludeOutOfNetwork (default false) attaches it to OON candidates too verified — xai-value-model/scoring.rs:85-90.

B. PopularPostsSource was rewritten to read a view-count-ranked list built by offline jobs, instead of querying Thunder at request time. verified — home-mixer/sources/popular_posts_source.rs, home-mixer/util/popular_posts.rs:36-87, home-mixer/popular_posts_job.rs:21-178, home-mixer/util/popular_authors.rs:11, 25-40:

  1. popular_authors_job runs on a default 3600-second cycle. This job reads a follower-ranking snapshot from the BigQuery table twttr-bq-timelines-prod.pulse.popular_posting_authors and picks (number of authors active in the last 7 days × 0.00005) authors, which is the top 0.005%. It rejects a snapshot older than 30 hours (--popular-authors-max-snapshot-age-hours=30).
  2. popular_posts_job (default 300-second cycle) groups those authors 20 at a time and collects posts through Thunder GetInNetworkPostsRequest (50 per author, at most 1,200 per request). It drops replies and retweets (is_original).
  3. For posts within 24 hours, it fetches view_count from the engagement counts service, 500 at a time.
  4. The score is quality = views × (1 − e^{−λ·24}) / (1 − e^{−λ·max(age_h, 0.5)}), where λ = ln2 / 8. It assumes the view rate halves every 8 hours and extrapolates the views so far to 24-hour cumulative views inferred. The basis is that the denominator is the fraction accumulated by the post's age and the numerator is the fraction accumulated by 24 hours.
  5. It keeps the top 5 per author and the top 500 overall, writes them to Manhattan, and reads them back to check that generated_at_ms matches.
  6. The serving source refreshes the list every 60 seconds, takes PopularPostsMaxResults (500) items, and returns them excluding only seen_ids. List age is reported with the PopularPostsSource.ListAgeSecs gauge.

The Gen4+35 parameters PopularPostsTopAuthors (1000), PopularPostsMaxPerAuthor (3) and PopularPostsMaxRepliesRepostsPerAuthor (0) were deleted verified. Because the only per-user personalization input is seen_ids, this source is a global popular list verified. The default is still off.

C. A new "Under the Hood" response was added. verified — home-mixer/util/under_the_hood.rs, home-mixer/scored_posts_server.rs:

  • If ScoredPostsQuery.is_under_the_hood_request is true, the response carries a pipeline trace and per-post scores.
    • The trace contains per-stage latency, input, kept and removed counts, and filter rate.
    • Per-post scores contain weighted_score, the name of the value model used (vm_ranker/weighted), and a map of per-head Phoenix scores.
  • Each stage and component carries a public repository code URL of the form https://github.com/xai-org/x-algorithm/blob/main/home-mixer/... (code_url).
  • The default of the flag EnableUnderTheHood is false.
  • Given that the response embeds public repository links, it appears to be a feature that shows users or internal debugging screens "why this post appeared" inferred. The surface where it is displayed is not in this repository.

D. VF verdicts now have four levels. verified — README.md:205-232, visibility-filtering/models/verdict.rs. NOTICE (shows the post as is and attaches a notice) was added to ALLOW, INTERSTITIAL and DROP. The internal representation is SoftIntervention { policy: AppealablePolicy, level, proactive, appeal_submitted }. For home-mixer, as with an interstitial, the post stays in the feed. In the same commit VF introduced article_client.rs, trusted_friends_client.rs and community_source.rs (community post hydration). The evaluation result was also split into Evaluation::{Complete, Partial, NotFound, Failed}.

E. Values and settings also changed. verified — home-mixer/params/param.rs:

  • RetrievalCandidatesKafkaMaxCandidates changed from 200 to 100,000. It is the cap for logging retrieval candidates to Kafka. Effectively, the cap was lifted so that all candidates are logged inferred.
  • VMRankerClusterId changed from "Experiment6" to "Experiment1".
  • Phoenix training gained the H100 remat policy SAVE_H100_QKV_ATTN_FFN = 34, and the condition that prevented using num_microbatch > 1 together with use_row_emb was deleted verified — phoenix/xrex/models/remat.py, phoenix/xrex/train/trainer_recsys.py.

Why it matters

  • X's value model has so far been "weighted sum of probability heads + continuous-value heads as is." This time, an expected-dwell-time decomposition of probability × conditional expected seconds appears in code for the first time. The weights are 0, so serving is not yet affected verified.
  • The profile_click weight has been 0.0 since the Gen4 release (weight table above). The code does not reward the profile-click probability itself, but creates a separate path that rewards the time spent after the click inferred. The basis is that the same probability head is used only as a factor in the product.
  • Within three days of its release, the definition of PopularPostsSource changed from "latest posts of popular authors" to "posts of popular authors with a fast view rate." The Thunder call in the request path is gone, so the cost became constant verified.

Gen4+37 (2026-10-07, 78460ca) — PopularPostsSource on by default + new-author boost 0.02 → 0.015 (home-mixer mirror) + corrected lexical-match features of search ranking negative samples + optional per-layer activation L2 regularization for Transformer + cleaned up VF NotFound verdict path

In this commit the global popular-posts source was turned on by default, so a source whose only personalization input is the list of already-seen posts entered the default path for For You's out-of-network candidates for the first time verified. The same day, the home-mixer mirror's new-author boost dropped from 0.02 to 0.015 verified (daily).

The commit changed 22 files, at +775/−681 verified — xai-org/x-algorithm@78460ca. It came the day after Gen4+36 (e62790c, 2026-10-06). The cron mirror sync time moved from 2026-10-05T16:00:50Z to 2026-10-06T16:00:58Z.

A. PopularPostsSource is now on by default. verified — home-mixer/params/param.rs:944-948: EnablePopularPostsSource changed from false to true. The source is enabled when !has_cached_posts && !in_network_only && EnablePopularPostsSource verified — home-mixer/sources/popular_posts_source.rs:27-31. It was also listed for the first time in the README's candidate source table and OON description as "A shared list of popular recent posts, initially from the most-followed active accounts" verified — README.md. The source structure (two offline jobs, view-count extrapolation score, up to 500 items) is the same as in the Gen4+36 section.

B. The new-author boost was lowered. verified — home-mixer/params/param.rs:384-388: PostUnexploredWeight changed from 0.02 to 0.015. The parameter of the same name in vm-ranker/params.rs:91-95 is still 0.02 verified — vm-ranker/params.rs. The home-mixer value is the one weights_for uses in the local weighted sum verified — home-mixer/scorers/value_model.rs:28. Since Gen4+28, home-mixer computes the local score first and refines it with a VM-Ranker RPC, so for requests the RPC answers, the VM-Ranker value of 0.02 is likely the final one inferred. This is the first observed case of the two mirrors holding different values.

C. The lexical-match features of search ranking negative samples were corrected. verified — new phoenix/xrex/data/recsys/negative_word_match.py (51 lines), recsys_batch.py:1476-1488:

  • apply_negative_sampling builds negatives from other users in the batch. If there is a search query, it builds the two blocks below and drops pairs whose query-embedding cosine similarity is 0.9 or higher. Negative slots get the ClientTweetRecapNotDwelled label verified — same file apply_negative_sampling.
    • Block (1): another user's post and my query
    • Block (2): my post and another user's query
  • Post features are all copied from post_src, so the two features computed for the original query remained as they were on negatives paired with a different query verified (the copy code and this correction code). The two features are exactPhraseSeq (exact match of the query phrase) and matchedWordFractionSeq (fraction of query words matched).
  • With search_negative_clear_word_match (default False) on, the two features of negative slots are cleared to 0 verified. If the flag is on and the new columns lexicalTextSeq, lexicalQuerySeq and lexicalAuthorSeq exist, the features are recomputed against the newly paired query with the external library xai_search_lexical_match.search_lexical_match verified.
  • Before the correction, negatives in block (2) kept a feature saying "strongly matches the query" while receiving an irrelevant label, so the model was probably trained to trust the lexical-match features less inferred. The basis is that the features and labels were systematically mismatched.

D. An optional per-layer activation L2 regularization for the Transformer was added. verified — phoenix/xrex/models/transformer.py, recsys_model.py:664, 3827-3830: it collects the mean squares of each layer's attention output and FFN output (attn_loss, ffn_loss, reflecting the padding mask) and adds per_layer_l2_weight × (ffn-loss + attn-loss) to the regularization loss. The default is 0.0. In the same file, four loss-input construction functions (_candidate_inputs and others) were merged into one, loss_inputs_and_normalizers, and the sharding constraint changed to with_sharding_constraint_unless_manual. This appears to be a refactor with no change in training behavior inferred.

E. The VF NotFound verdict path was cleaned up. verified — visibility-filtering/evaluate_tweets.rs, visibility-filtering-client/evaluated.rs: Evaluation::NotFound is removed from the verdict targets and goes to Outcome::Failed. On the client side, the VF verdict overwrites the tweetypie result only when it is Found; Filtered, NotFound and Failed keep their original state.

Why it matters

  • The source introduced in Gen4+35 and rewritten as an offline global popular list in Gen4+36 was turned on by default four days after Gen4+35. A source whose only personalization input is seen_ids entered the default path for For You OON candidates for the first time verified. These candidates also pass through the Phoenix ranker, so ranking decides whether they are shown inferred. The basis is that the source only creates PostCandidate and assigns no score.
  • The same day, the new-author boost was cut by 25%. In effect, the correction for new authors was reduced on the day the popular-author list came in. Whether the two changes are part of the same experiment cannot be confirmed from the code inferred.

With this commit, a vertical video carousel module entered the For You feed, off by default, and this module picks videos by video-open probability instead of the value model score. The ranking model's dwell-time head is now trained with the CREAD (classification per threshold, then restoration) loss in the default configuration verified (daily).

The change scale is 173 files, +15,160/−2,465 verified — xai-org/x-algorithm@35650fb. The cron mirror sync time moved from 2026-10-06T16:00:58Z to 2026-10-07T16:00:42Z, and the only values that changed in the mirror are three new parameters. A large part of the increase is third-party CuTe DSL utilities (phoenix/xrex/cutedsl/quack/*, about 3,000 lines) and VF hydration and replay code. This section covers only the parts related to feed behavior. Items that "need a deep dive" are left in the open questions section.

A. A vertical video carousel module was added to For You. verified — home-mixer/util/video_carousel.rs, selectors/top_k_score_selector.rs:10-27, filters/video_carousel_filter.rs, params/config.rs:17-19, 35-36:

  • The module turns on under three conditions: RequestType::ForYou, EnableVideoCarousel (default false), and a fatigue gate. Fatigue is judged by whether VideoCarouselFatigueMinutes (60 minutes) have passed since the last VIDEO_CAROUSEL_MODULE impression time in the served history verified — query_hydrators/served_history_query_hydrator.rs:52-57, 110-126.
  • Selection happens in two stages.
    • In stage 1, TopKScoreSelector picks the top 50 by score (TOP_K_CANDIDATES_TO_SELECT). Then, from the remaining candidates, it picks up to 30 more (VIDEO_CAROUSEL_EXTRA_CANDIDATES) that are videos, are not replies, and have a video_open_score, in order of predicted video_open probability, and marks them video_carousel_extra.
    • In stage 2, these candidates pass through the VF and conversation dedup filters together. Then the last filter, VideoCarouselFilter, removes the extra candidates from the main feed.
    • The carousel candidate pool is "ranks 36 and below among the top 50 (skip(RESULT_SIZE=35)) + the removed extras."
  • From the pool, only vertical videos (width-to-height ratio < 1.0) are kept and up to 5 are picked in video_open order. If there are fewer than 3, no module is created.
    • The width-to-height ratio is filled from TES media metadata by the new VideoAspectRatioHydrator.
  • The blender inserts the module at the 6th slot (VideoCarouselPosition) and records it in the served history as a VIDEO_CAROUSEL_MODULE entry. The URT header string key is VideoCarouselModule.header, and the English value in the test is "Videos for you" verified — util/urt/video_carousel_marshaller.rs.
  • Carousel videos appear designed not to overlap with the top 35 shown in the main feed inferred. This is because the carousel candidate pool consists only of ranks 36 and below plus the extras. However, we did not check this time whether the final response size is cut at exactly 35.
  • Carousel selection uses the single-head probability of video_open, not the value model weighted sum verified. It is the first module to fill slots with an objective separate from main feed ranking inferred. The existing WTF, prompts and frames were separate candidates that did not use post ranking.

B. The ranking dwell_time training loss changed to CREAD, and it is on by default. verified — phoenix/xrex/configs/xrecsys.py:272-275, 687-742, models/loss_recsys.py cread_loss_compute, models/recsys_model.py _decode_cread:

  • In _home_direct_packed_base(), dwell_tweedie_loss_weight and dwell_mae_loss_weight became 0.0, and dwell_cread_loss_weight and dwell_cread_immersive_loss_weight became 0.1. Previously, Tweedie (power 1.5, cap 300) and MAE were each hardcoded at 0.1.
  • For each of its 30 thresholds, the CREAD head learns "does dwell exceed this threshold" with sigmoid BCE. The thresholds are log-spaced from 2 seconds up to the cap, and the last value is the cap. The reconstructed value is computed as Σ σ(logit_k) × (bin width)_k. A Huber loss in raw units (δ=5 s, weight 0.5) is added on top.
  • The CREAD loss is split in two by surface. The general surface has a cap of 120 seconds and excludes PRODUCT_SURFACE_GALLERY_PAGE. The immersive (gallery) surface has a cap of 240 seconds.
  • A feature that feeds history dwell time as a bucket embedding using the same threshold scheme is also on by default verified — models/recsys_feature_prep.py:180-184, 885-910, configs/xrecsys.py:301-304.
    • The settings are enable_dwell_bucket_table=True, a cap of 240 seconds and 30 thresholds, with linear interpolation between two adjacent buckets.
  • CREAD is a method Kuaishou published for predicting video watch time (arXiv 2401.07521, 2024, paper). The name and structure (classification and restoration) of the X code match the paper verified. Instead of the paper's core error-adaptive interval design, the X code uses fixed log spacing verified (cread_log_thresholds).

C. Options for MoL and multiple query heads were added to two-tower retrieval (off by default). verified — phoenix/xrex/models/recsys_two_tower_model.py:523-598, 1629-1665, 2214-2224, phoenix/xrex/cuda/mol_epilogue/__init__.py, configs/xrecsys_two_tower.py:699-743:

  • With user_query_heads=K, the user sequence output is attention-pooled with K learned query vectors to make K user vectors. Each head has its own recency decay coefficient (recency_gamma).
  • With mol_item_components=L, a low-rank adapter is applied to the item embedding to make L normalized component vectors. The K×L cosine values are combined by a weighted sum through a small MLP gate (softmax) to produce the final score. There is also a mol_conditioned_gate option that adds user and item embedding bias to the gate.
  • The (K×L, hidden) combinations the serving CUDA epilogue kernel supports are {(4,32), (8,16), (8,32), (16,16)}, and it supports only up to 512 users per request.
  • Four contrastive loss options were added in the same commit, all off by default.
    • A fixed temperature
    • Masking of false negatives, where the same post in a batch is used as a negative
    • Logit offset for posts of the user's own negative actions
    • User weighting based on the number of positives
  • None of the 6 presets turns on MoL or query heads verified (grep of the configs directory). Whether current production retrieval is dot-product-based or MoL cannot be determined from this code inferred. The experiment configs are not public.

D. Other changes

  • Topic feed requests (requests with topic_ids) are excluded from author cold-start promotion verified — home-mixer/scorers/author_cold_start.rs:384-386.
  • Lookup table functions for the first 2-digit and 3-digit Semantic ID prefixes were added to Phoenix embeddings. The 3-digit one reduces the row count by hashing verified — phoenix/xrex/models/recsys_sid.py:32-79. We did not check whether this function is actually wired up.
  • A path was added for the ads blender to collect dropped ads into query.ad_drops (home-mixer/ads/drops.rs, 87 lines). VF introduced a 345-line user-location (geo/service/userLocation) client and a 528-line hydration exchange.rs verified. Whether this is a policy change needs a deep dive.

Why it matters

  • For You gained a video-only slot, and that slot is filled by video_open probability, not by value model score verified. Because it is still off by default, we treat it as being at the experiment stage inferred. The cron mirror default is false.
  • On the ranking side, the default training approach of the dwell-time head changed verified. Together with the Gen4+31 increase of ContClickDwellTimeWeight from 0 to 0.4, it suggests continued tuning of the dwell-time signal inferred.

Gen4+39 (2026-10-09, cb45b55) — Added feed request context (FeedRequestContext) and cursor direction fields to the ranking request proto + retuned the H100 home ranker training preset (batch 512, 2 microbatches, packing that removes empty candidate slots) + a candidate-region FA4 attention kernel

This commit added two fields to the Phoenix ranking request that carry feed request context such as app launch, refresh, and scroll down, but no public code at the commit reads these fields. Most of the remaining changes fix the preset and sequence packing for training the home ranker on H100 verified (daily).

The change spans 16 files, +1,228/−218 verified — xai-org/x-algorithm@cb45b55. Only the cron mirror sync time changed, from 2026-10-07T16:00:42Z to 2026-10-08T16:00:35Z, and no mirror value changed verified. Most of the changes are Phoenix ranker training-efficiency code. The reference commit for line numbers in this section is cb45b55.

A. Two request context fields were added to the Phoenix ranking request PredictNextActionsRequest. verified — phoenix/python/common/xai-proto/proto/recsys.proto:27-50, 175-176 (the same holds for the serving crate's xai-recsys-proto/proto/recsys.proto):

  • feed_request_context (field 25) records how this feed request was triggered. It has 14 values.
    • They include app launch (LAUNCH), return to foreground (FOREGROUND, FOREGROUND_TRUNCATE), pull to refresh (PULL_TO_REFRESH), manual refresh, screen navigation (NAVIGATE), viewport-based refresh, polling, background fetch, timeline gap filling (GAP), right after signup (SIGNUP), own thread, and AUTO.
  • feed_cursor_direction (field 26) is one of the first request (INITIAL), fetching newer posts upward (TOP), scrolling down to fetch more (BOTTOM), and gap (GAP).
  • Both enums had been defined in the proto since an earlier commit but were not used in any message. This commit connected them to the Phoenix ranking request message for the first time verified (the diff only adds two field lines).
  • At the commit, no code in the public tree reads these fields verified. Even grepping all of phoenix/xrex and home-mixer, the enum names and field names appear nowhere outside the proto. So it is not yet possible to tell whether they are used as ranking features or only for logging inferred.

B. The H100 home ranker training preset was retuned. verified — phoenix/xrex/configs/xrecsys.py:324-345, 427-432:

  • The home_direct_packed preset is _home_direct_packed_base() merged with _H100_OVERRIDES. This time bs_per_device went from 256 to 512, ep (expert parallelism) went from 256 to 128, and num_microbatch=2 was added. One microbatch (the approach of splitting a step's batch, computing the parts sequentially, and summing the gradients) is 256 users.
  • use_async_emb, seqpack_drop_empty_candidates, and overlap_step_host_work became True. remat_policy=SAVE_H100_QKV_ATTN_FFN and unroll_layer_stack=True entered the H100 override. muon_ns_shard_group_size=8 was added to the Muon optimizer.
  • The learning rate (7.1e-4) and checkpoint_every_n (150) are unchanged. The GB300 preset (bs_per_device 512, ep 64) did not change.

C. Sequence packing that removes empty candidate slots was introduced. verified — phoenix/xrex/data/recsys/sequence_packing.py:171-203, 579-754, phoenix/xrex/train/trainer_recsys.py:172-195, 291-292, 404-470, 2033-2060:

  • The previous packing reserved a fixed number of token positions per user, equal to the number of candidate slots. The new layout rounds up only the candidates actually filled for each user to a multiple of 16 (CANDIDATE_TOKEN_ALIGN) and gathers them in a "candidate region" at the end of the sequence.
  • The per-user candidate token budget is one of the stages [32, 128] of seqpack_candidate_tokens_per_user. At each step, all processes agree on the required maximum through a distributed KV store (max_over_processes, 2-hour timeout). Then the smallest stage above that value is chosen. As the budget grows, the mean of the history length distribution (mean_len 510) is reduced to keep the total token count the same.
  • If even the largest stage exceeds the budget, candidates are dropped from the training batch within the user, starting from the later slots. The number dropped is recorded in the train/seqpack_dropped_candidate_slots metric. A warning is logged if the last stage (128) is smaller than the total number of candidate slots.
  • The candidate-region attention kernel ranker_candidate_attention_fa4 was introduced (369 lines). This kernel is a FlashAttention-family CuTe DSL kernel and supports only SM90 (H100) verified — phoenix/xrex/cutedsl/ranker_candidate_attention_fa4.py:31, 295. Each candidate token sees only the history key range of its own user (candidate_key_starts, candidate_key_counts).
  • To account for the reduced padding tokens, the summary values of the attention and FFN auxiliary losses and the active L2 loss are multiplied by the token count ratio, which keeps them on the same scale as the old layout verified — phoenix/xrex/models/transformer.py:761-781.

D. Other changes

Why it matters

  • A path now exists for the ranking model to receive "whether this request is a refresh or a scroll down" verified. The same user is likely to respond with different probabilities right after opening the app and after scrolling deep into the feed, so the next step may be to use this value as a context feature inferred. The only evidence is that the fields were added to the inference request message.
  • B and C are training-efficiency changes that do not alter serving ranking results inferred. The model structure and loss definition are unchanged; only the packing approach changed. However, candidates are dropped from training when the budget is exceeded, so the training data distribution may differ slightly inferred.
  • A GB300-specific override came first in Gen4+6, but this time an H100-specific kernel and default preset tuning were added. We infer that home ranker training continues to run on H100 as well inferred. This is because the default preset name home_direct_packed uses the H100 override.

This commit raised the like and dwell time weights in the home-mixer cron mirror and lowered the video open weight from 0.07 to 0.02, but the VM-Ranker mirror keeps its previous values. The combined retrieval preset also gained a dwell-based head, so the home retrieval objective widened from like to like plus dwell verified (daily).

The change spans 179 files, +26,532/−5,971 verified — xai-org/x-algorithm@e1a0375. The cron mirror sync time went from 2026-10-08T16:00:35Z to 2026-10-12T16:00:33Z. More than half of the increase comes from abuse-enforcement-service (about 10,000 lines) and the visibility-filtering refactor (about 9,000 lines changed). This section covers only the parts related to feed behavior. The reference commit for line numbers is e1a0375.

A. Three weights in the home-mixer cron mirror changed. verified — home-mixer/params/param.rs:320, 341-346, 401-406:

Parameter Before After
FavoriteWeight 0.5 0.6
VideoOpenWeight 0.07 0.02
ContDwellTimeWeight 0.004 0.006
  • In the VM-Ranker mirror, the values under the same names (the same feature switch keys) remain 0.5, 0.07, and 0.004 verified — vm-ranker/params.rs:3, 12-17, 54-59. This is the second case of the two mirrors diverging, after PostUnexploredWeight in Gen4+37.
  • The judgment in the Gen4+37 section applies unchanged. For requests that the VM-Ranker RPC answers, the VM-Ranker value is likely the final one inferred. home-mixer first computes a local weighted sum and refines it with VM-Ranker (Gen4+28).
  • The direction is "reduce the share of video open probability and increase the share of like and dwell time" verified (the values themselves). It is notable that this runs opposite to Gen4+38, which made the carousel fill by video_open probability. It may be a division of labor in which the main feed reduces the push toward opening videos and sends videos separately to the carousel slot inferred. The only evidence is that the two changes are 6 days apart, and the carousel is still off by default.

B. A third dwell-based head ld was added to two-tower retrieval. verified — phoenix/xrex/configs/xrecsys_two_tower.py:251-300, phoenix/xrex/models/recsys_two_tower_model.py:93-120, 1916-1920, 2385-2412, 2527-2541:

  • The candidate heads of _xrecsys_two_tower_combined_base() went from 2 (home, immersive) to 3 (home, immersive, ld). The positive action of the ld head is the single CLIENT_TWEET_RECAP_DWELLED, and its negatives use the same list as home.
  • The serving score is w_fav × 〈u, home embedding〉 + w_ld × 〈u, ld embedding〉. The combined preset uses w_fav=1.0 and w_ld=0.75.
    • The post embedding table doubles in width, so a home slot and an ld slot sit side by side in one row (num_table_slots=2).
    • A single user vector scores each of the two slots separately and takes the weighted sum (score_over_slots). The dot product, MoL, and int8 paths all work the same way.
  • The positive of the home head has been the single SERVER_TWEET_FAV since Gen4+32. With this change, the home retrieval objective of the combined preset widened from "like" to "like + 0.75 × dwell" verified (config values).
  • ld appears to stand for long dwell inferred. The positive action is named RECAP_DWELLED, and "Recap" was the internal name Twitter used for the ranked home timeline. The dwell decision threshold is not in the public code.
  • In the same base, enable_user_installed_apps changed from True to False verified (line 259). The list of apps a user has installed is no longer used as a user tower feature. The immersive-only base (line 344) is still True.
  • Whether xrecsys_two_tower_combined is the production home retrieval preset cannot be confirmed from the public code inferred. The evidence is that, among the six presets released in Gen4+32, it is the only one that trains home and immersive together.

C. The conversation-screen reply ranker reply_two_tower_v2 was introduced. verified — phoenix/xrex/configs/xrecsys_reply_two_tower_v2.py (79 lines), phoenix/xrex/models/recsys_reply_two_tower_v2_model.py (1,077 lines), phoenix/crates/common/xai-recsys/src/reply.rs (680 lines):

  • This model scores up to 64 reply candidates of one post (the focal post) per user. The serving registration file calls this model "reply-ranker" verified — phoenix/xrex/inference/reply_services.py.
  • Its scale is much smaller than the home ranker.
    • It has 1 Transformer layer, emb_size=256, history 127, a vocab of 20 million each for users and items, 5 million for authors, and 16 SID levels.
    • The loss is the action multi-hot with a negative feedback weight of 10.0 and a dwell time Tweedie loss (weight 0.02).
  • Unlike home Phoenix, it uses many hand-engineered features.
    • Reply-side features include the reply's like, reply, repost, quote, and view counts, and the author's follower count and verification status.
    • Also included are whether the original post's author liked the reply, whether the original post's author replied, whether it is the author's own reply, the reply section, and cumulative report, block, and mute counts.
    • The grox reply score (grox_score, the 1001 offset divided into 26 buckets of 25) is also included.
    • On the focal post side, the like, reply, and view counts, the author's follower count, and whether the viewer is the original post's author are included.
  • The grox score appears to be the reply ranking score that ReplyRankingTaskGenerator, covered in this document's "Grox" section, assigns with Grok inferred. Apart from the name (grox_score) and the 1001 offset encoding, this diff has no link connecting the two pieces of code.
  • No code in home-mixer calls this ranker verified. The 17 home-mixer files changed in this diff contain no reply ranker call. We infer that the conversation screen is handled by a service different from the For You feed inferred.

D. A react_with_video term was added to the value model (weight 0). verified — xai-value-model/scoring.rs:136, home-mixer/params/param.rs:395-400, vm-ranker/params.rs:102-107:

  • The action SERVER_TWEET_REACT_WITH_VIDEO entered the proto ActionName at number 44. This slot was originally CLIENT_CARD_CLICK and was renamed in this change verified — recsys.proto:449.
  • A react_with_video_score × ReactWithVideoWeight term was added to the value model weighted sum, and this term also enters the sum of positive weights. The default is 0.0 in both mirrors.
  • By its name it is the action of posting a video reply to a post, but the code does not explain what product feature "reacting with video" is inferred.

E. Other feed-related changes

  • Two kinds of dedup were added to video carousel selection verified — home-mixer/util/video_carousel.rs, param.rs:1071-1082.
    • EnableVideoCarouselAuthorDedupe (default false) keeps only one video per original author.
    • VideoCarouselSemanticIdDedupeLevels (default 0) keeps only one video among those whose first N SID digits are the same.
    • Filtered slots are filled by lower-ranked items, and if the result has fewer than 3 items, no carousel is built.
  • In video-excluded requests (exclude_videos), posts whose quoted post is a video and posts whose conversation ancestor post has a video are now also filtered out verified — filters/video_filter.rs, new filters/ancestor_video_filter.rs and candidate_hydrators/ancestor_video_hydrator.rs. Previously only the candidate's own video was checked.
  • PhoenixOonRepliesRootOnly (default false) was added; when OON replies are allowed, it allows only replies made directly to the root post verified — filters/oon_retweet_reply_filter.rs, param.rs:79-84. The parent switch EnablePhoenixOonReplies is also still false.
  • The retrieval dataset RETRIEVAL_DATASET_TYPE_ZERO_FAV = 16 and the served type ForYouPhoenixRetrievalZeroFav were introduced, and statistics for selected and unselected candidates are recorded separately verified — sources/phoenix_source.rs, side_effects/scored_stats_side_effect.rs. By its name, it is a retrieval channel that collects only posts with zero likes inferred. The code that fills the channel is not public.
  • home-mixer fills FeedRequestContext and FeedCursorDirection, which Gen4+39 added to the Phoenix request, for the first time. However, the place it fills is the ad index request (AdIndexRequest), and the Phoenix ranking request is still not filled in this diff verified — sources/ads_source.rs, grep over the whole diff.
  • The proto gained 18 ads view-through delayed conversion actions (numbers 234-251), UserActionMeta.fair_view_time_ms, and UserContext.id_list_features_ipc verified — recsys.proto.
  • abuse-enforcement-service introduced action_control.rs, which changes the enforcement action per rule at runtime, and off_switch.rs (1,619 lines), which turns off all enforcement verified.
    • action_control.rs (3,218 lines) contains a dry-run label, the confirmation word ENFORCE, and change log retention.
    • Because this is account enforcement infrastructure, we infer it is not directly related to feed ranking inferred. It needs a deep dive together with the VF refactor (explain.rs 1,248 lines, rules/levels.rs, the rule_spec/ split).

Why it matters

  • This is the first change to the weight mirror values since Gen4+37. It raised the like and dwell shares and lowered the video open share to less than one third verified. However, the VM-Ranker mirror keeps its previous values, so the actual serving values cannot be confirmed inferred.
  • Retrieval also put dwell into its objective verified. Together with Gen4+31 (ContClickDwellTimeWeight 0 → 0.4), Gen4+38 (CREAD), and this ranking cont_dwell_time increase, X appears to keep increasing the share of the dwell signal from retrieval through ranking inferred.
  • The reply ranker is a design opposite to the README claim that home Phoenix "removed hand-engineered features" verified (feature list). This gives one more piece of evidence that the same company applies different principles depending on the surface.

Gen4+41 (2026-10-14, 17c96bf) — A page-level RL re-ranker for the immersive surface (page_rl: autoregressive page decoder + twin critic, training config introduced, serving off by default) + home-mixer cron mirror favorite 0.6 → 0.66 and not_interested −47.52 → −52.272 + installed-apps feature off for the home ranker + DPP skipped by default for topic requests + Brazil election filter reduced to 25 accounts

This commit released the training code for an RL re-ranker for the immersive surface that picks the slots of a page one at a time, but the serving switch is off by default. In the home-mixer cron mirror, the like and not-interested weights both became 1.1 times their previous values verified (daily).

The change spans 53 files verified — xai-org/x-algorithm@17c96bf. The cron mirror sync time went from 2026-10-12T16:00:33Z to 2026-10-13T16:00:30Z. The reference commit for line numbers is 17c96bf. The three new files first appeared in this commit, so their line numbers equal the new-file line numbers in the diff.

A. Training code for the page-level RL re-ranker page_rl appeared. verified — phoenix/xrex/models/page_rl.py (403 lines), page_decoder.py (592 lines), page_critic.py (211 lines), configs/xrecsys.py:388-417, 477-483:

  • The training config immersive_page_rl was introduced. It adds the H100 settings to the home ranker base (_home_direct_packed_base) and turns on page_rl=PageRlConfig(enabled=True). The Kafka training topic is also separate, user_action_sequence_immersive_page_rl verified — data_feeds.py.
  • The structure is actor-critic. First, a weighted-sum score (behavior score) is built from the head probabilities of the Phoenix ranker, and the top 48 at most (max_steps) are cut as the candidate pool. Then the page decoder picks slots one at a time, autoregressively. The number of slots picked at serving time is 12 (serve_steps) verified — page_decoder.py:22-34, page_rl.py:340-391.
  • The logit the decoder assigns at each step is the sum of three terms verified — page_decoder.py:85-119, 233-247.
    • "the state attending to the items picked so far × the candidate key"
    • the standardized behavior score (score_anchor_alpha=1.0)
    • a negatively weighted penalty for whether the author is the same as an already picked item and how many leading SID levels overlap
  • In this structure, diversity is learned inside the decoder rather than applied as a post-hoc rule.
  • The reward is per-slot dwell time. A slot viewed for 3 seconds or more gets 0.1 × min(dwell seconds, 300) × (1 + min(engagement count, 3)), and it is 0 if there is any negative action (not interested, report, block, mute, show less). If the page is shown to the end and the last slot has a video quality view (VQV), a completion bonus of 1.0 is added. The suffix return is computed with a discount rate γ=0.8 verified — page_rl.py:51-59, 100-158.
  • There are two critics (twin). The smaller (min) of the two critic values is used. Training uses a TD(λ=0.9) target. The return loss on exploration pages (requests with a logged rerank probability) gets a weight of 9.0. There is also a continuation head that predicts whether the page continues to the next slot verified — page_critic.py:18-25, 121-139, 174-193.
  • The actor loss has three terms, to which a behavior cloning loss (weight 0.25) that imitates the logged order is added verified — page_decoder.py:37-48, 495-592, page_rl.py:80-88.
    • The first term is an advantage based on the critic Q. The support is limited to candidates within 0.6 of the highest behavior score.
    • The second term is a KL leash toward the behavior policy (score softmax, τ=0.2). The target is 0.35, and β is adjusted automatically between 0.1 and 3.0 as a dual variable.
    • The third term is a penalty (weight 0.5) for placing the same author or similar SIDs consecutively.
  • For serving decoding, if the temperature is 0, it builds 4 beams and picks the beam with the largest sum of critic values; if it is above 0, it uses Gumbel sampling verified — page_decoder.py:427-477.
  • The behavior score contains separate weights that differ from the home-mixer mirror verified — page_rl.py:62-77, 286-337.
    • favorite 0.5, reply 2.0, retweet 1.0, click 0.4, profile_click 0.5, VQV 0.5 (only for videos longer than 10 seconds), share 40.0, quote 5.0, cont_dwell_time 0.1, not_interested −500, report −1000, not_dwelled −1.2
    • It is not possible to tell whether these values are the serving defaults or only training defaults inferred. They are config class defaults and there is no mirror comment.
    • In the 2026-10-16 commit (Gen4+43, bc3948b), VQV changed from 0.5 to 1.0 and share from 40.0 to 4.0, and the weights can now be overridden per request (Gen4+43 in the change log).
  • serve_page_decoder: bool = False was added to the serving runner verified — inference/model_runner.py:3984. No code in home-mixer uses the decoder output in this diff verified (the 3 changed home-mixer files are only the filter, param, and phoenix_source.rs).
  • Because the training data already has the fields rl_served_slot, rl_rerank_prob, rl_decode_prob, and rl_impressed, we infer that some form of probabilistic re-ranking is already running on part of the traffic and leaving logs inferred. The logged probability fields are for off-policy training, and a policy that is not served produces no logs. The rewardRerankSlotProb proto field from Gen4+4 may be the source of this log inferred.
  • Because the surface name is immersive, the vertical video immersive surface appears to be the first target inferred. The evidence is the config name and the completion bonus tied to VQV.

B. Two home-mixer cron mirror weights changed. verified — home-mixer/params/param.rs:320, 414-419:

Parameter Before After
FavoriteWeight 0.6 0.66
NotInterestedWeight −47.52 −52.272
  • Both values are exactly 1.1 times the previous ones verified (arithmetic). The NotInterestedWeight change in Gen4+31, −43.2 → −47.52, was also 1.1 times. This appears to be a rollout practice of raising values step by step in 10% increments inferred. The only evidence is that three changes have the same ratio.
  • The VM-Ranker mirror keeps favorite 0.5 (line 3) and not_interested −47.52 (lines 108-113) verified — vm-ranker/params.rs. The weights on which the two mirrors diverge now number five (PostUnexploredWeight, favorite, video_open, cont_dwell_time, not_interested).

C. Other feed-related changes

  • In the home ranker base _home_direct_packed_base, enable_user_installed_apps changed from True to False verified — configs/xrecsys.py:300. After two-tower retrieval turned off the same feature in Gen4+40, the ranker turned it off too. xrecsys_seqpack (line 430) is still True.
  • In VM-Ranker, topic requests skip DPP diversity re-ranking by default verified — vm-ranker/scoring/mod.rs:85-87, vm-ranker/params.rs:241-246. The default of the new switch TopicDppEnabled is false. We presume the reasoning is that a topic feed is already gathered around one topic, so semantic diversification does not fit inferred.
  • PhoenixSource records the number of candidates from the ZERO_FAV retrieval channel per request as a metric (ScoredStats.PhoenixRetrievalZeroFavTweets.Retrieved) verified — sources/phoenix_source.rs. This attaches an observability metric to the channel introduced in Gen4+40.
  • The Brazil2026ElectionFilter list was reduced to 25 accounts related to the runoff verified. Details are in the filter section.
  • We infer that the remaining changes are not directly related to feed ranking behavior inferred.
    • These include the abuse-enforcement action_control and confidence refactors, VF hydration cleanup, the FA4 attention kernel, and moving the reply ranker's SID decoder path.

Why it matters

  • For the first time, X released training code for a stage in which the For You family of surfaces goes beyond score-order sorting and post-hoc DPP diversification and generates the whole slate with RL verified (the code exists). The serving switch is off by default, so the actual deployment scope cannot be confirmed inferred.
  • The reward is not engagement probability but dwell time × an engagement multiplier, and it is 0 if there is a negative action verified. In the same direction as Gen4+38 (CREAD), Gen4+40 (the ld head), and this mirror change, the trend of making dwell the main objective has extended to the re-ranking stage inferred.

With this commit, the For You video carousel can pick videos by blending in the immersive-surface model's video quality view (VQV) prediction, but the default weight is 0.0, so default behavior is the same as before verified. In the same commit, the ads brand-safety fallback that substitutes an author-level label when no post-level Grok safety label exists became on by default, and the VF rule definition layer was rewritten with no policy change verified (daily).

The commit changed 57 files verified — xai-org/x-algorithm@92f0918. The home-mixer changes cover 12 files and about 330 lines, and most of the rest (about 8,000 lines) is in visibility-filtering. The cron mirror sync time went from 2026-10-13T16:00:30Z to 2026-10-14T16:00:44Z. The commit used for line numbers is 92f0918.

A. The video carousel selection score became a weighted sum of the video_open probability and the immersive VQV prediction verified — home-mixer/util/video_carousel.rs:17-21, 104-123, params/param.rs:1083-1106.

  • The sort score for carousel videos changed from the video_open probability alone to a weighted sum of two predictions. The defaults are a video_open weight of 1.0 and a VQV weight of 0.0, so default behavior is the same as before verified.
    • The formula is VideoCarouselVideoOpenWeight × video_open + VideoCarouselImmersiveVqvWeight × immersive_vqv.
    • A video with no immersive score gets 0 for that term.
  • If the VQV weight is not 0, two components are turned on. One is a hydrator that fetches the user behavior sequence once more with a different aggregation. The other is a scorer that requests predictions for the carousel target videos from a different Phoenix cluster (Experiment3Fou) with ProductSurface::GalleryPage verified.
    • ImmersiveScoringSequenceQueryHydrator fetches the user behavior sequence once more with the IMMERSIVE_LONG_DWELL aggregation verified. IMMERSIVE_LONG_DWELL (=29) is an aggregation type that existed only as a proto name since Gen4+19, and this is the first home-mixer code that consumes it verified — query_hydrators/immersive_scoring_sequence_query_hydrator.rs (new, 75 lines).
      • The home ranker's default aggregation is DENSE_WITH_LONG_DWELL (params/param.rs lines 163-168).
    • ImmersiveVideoScorer collects only the carousel's target videos and requests predictions for them verified — scorers/immersive_video_scorer.rs (new, 65 lines), util/phoenix_request.rs:228-252.
  • The scorer order is PhoenixScorer → ImmersiveVideoScorer → VMRanker verified — candidate_pipeline/phoenix_candidate_pipeline.rs:462-471.
  • The immersive prediction is stored only in the immersive_scores field and does not enter the For You main feed score verified.
    • The diff has no code in which weighted_score or VMRanker reads this field.
  • The same weighted sum applies to both stages of carousel selection verified — selectors/top_k_score_selector.rs:16-25, filters/video_carousel_filter.rs.
    • The TopKScoreSelector stage, which picks 30 carousel candidates from outside the top 50
    • The VideoCarouselFilter stage, which picks the final 3–5 after the filter
  • Because GalleryPage, Experiment3Fou, and IMMERSIVE_LONG_DWELL are all names from the immersive vertical-video surface, we infer the aim is to pick the carousel's first screen with the model of the immersive viewer that follows a carousel tap inferred. The evidence is the names of the three identifiers and the fact that the Gen4+41 immersive_page_rl reward counts VQV completion separately.

B. The ads author brand-safety fallback became on by default verified — params/param.rs:807-812. EnableAdsAuthorBrandSafetyFallback, introduced off by default in Gen4+35, changed from false to true. This switch turns on a path that substitutes an author-level label (GrokSfa/GrokNsfaLimited/GrokNsfa) when no post-level Grok safety label exists. The detailed behavior is in the Gen4+35 section. As a result, the V2 verdict switch that returned to on by default in Gen4+35 and the author fallback switch introduced in the same commit are both on by default verified. The V2 verdict switch itself has existed since Gen4+2 (2026-08-17) (param.rs at b089ce6).

C. Other changes

  • One assert line in Phoenix recsys_model.py that required right_anchored_rope when seqpack (packing that concatenates several user sequences into one batch row) is on was deleted verified — phoenix/xrex/models/recsys_model.py.
    • We infer the aim is to allow experiments that train on packed sequences with a RoPE that is not right-anchored inferred. The evidence is that this commit only lifted the constraint and made no other config change.
  • The VF rule definitions changed from a flat condition array to a boolean tree. The rule lists, order, actions, and country lists of the five SafetyLevels are unchanged verified — visibility-filtering/rules/levels.rs, rules/rule_spec/rule.rs (new, 305 lines).
    • The old format is Clause { when: &[Cond] } and the new format is Rule { when: RuleCond }. The tree is built from all!/any!/!/named conditions.
    • The five SafetyLevels are FILTER_ALL, TIMELINE_HOME, TIMELINE_HOME_RECOMMENDATIONS, TIMELINE_HOME_HYDRATION, and IMMERSIVE_EXPANDED_RECOMMENDATIONS.
  • The commit added a test adapter that prints the new tree back out as the old flat table and compares it with the existing per-level golden files. The golden files did not change, so there is no policy change verified.
    • The adapter is rules/legacy_table.rs and is test-only.
    • This is the fifth "no policy change" refactor, following Gen4+11, +13, +14, and +29.
  • The same rule definition produces evaluation, policy catalog documentation, and a per-node verdict trace. The serving path uses short-circuit evaluation without a trace verified.
    • Catalog documentation is generated by the test-only rules/catalog.rs.
    • The verdict trace is produced by rules/rule_spec/observed.rs (new, 275 lines).
    • The debug tool explain.rs exposes this trace and the retweet merge selection (MergeSelection).
  • We infer the direction is to explain "why was this post hidden" per rule inferred. The evidence is the Effect enum, which records for each row how a rule line contributed to the verdict.
  • Behavior may change in two hydration places outside the rules verified (code).
    1. The viewer Gizmoduck (X's user information service) lookup changed verified — clients/gizmoduck_client.rs:23-29, 55-62. It used to be called without context (None), and it is now called with a LookupContext that includes include_soft_users, deactivated, erased, offboarded, and other states. The test is named viewer_lookups_include_every_user_state_tweetypie_reads, so we infer the change aligns the lookup scope with the Tweetypie reference (Gen4+31) inferred.
    2. When a found viewer has no age information, it is now decoded as NotStated regardless of state verified — hydration/decode/viewer.rs:76-79.
  • When the two changes combine, viewers in states that were not looked up before may become subject to the SENSITIVE_VIEWER_NO_STATED_AGE_DROP_* rules inferred. The scale cannot be determined from the diff.
  • The viewer state and age distribution metrics vf_viewer_state and vf_viewer_age (with a country tag) were introduced verified — hydration/metrics.rs.
  • The commit also added a pre-check (fail-closed) that fails every candidate of a request when a hydrator required by the policy is missing from the plan, but because the plan is derived from the same policy, it does not fire on the normal path inferred — rules/registry.rs:418-443.
  • In visibility-filtering/params.rs and limited_actions_policy.rs, only the visibility modifiers changed to pub, and the values are the same verified.

Why it matters

  • In Gen4+38, the carousel picked videos by "the video-open probability predicted by the main feed model." This commit added a path that picks by importing the video quality view (VQV) prediction of a model trained for a different surface verified (code). This is the first release of a structure in which a module inside one feed calls a surface-specific model separately verified.
  • This path follows the Gen4+40 cut of the video_open weight (0.07 → 0.02) and points the same way as the shift of the video-side objective from "open" to "time actually watched and video quality view" inferred. The default is 0, so whether it is served is unknown.

With this commit, candidates from the popular posts source carry their own label, and in the behavior score of the immersive page re-ranker the video quality view weight doubled and the share weight fell to one tenth verified. In visibility-filtering, a verdict now counts as complete even if 13 auxiliary lookups fail, and the tests state explicitly that rare over-drops can occur as the cost verified (daily).

The commit is xai-org/x-algorithm@bc3948b verified. The cron mirror sync time went from 2026-10-14T16:00:44Z to 2026-10-15T16:00:34Z. The commit used for line numbers is bc3948b.

A. The popular posts source label was split from Phoenix retrieval. verified — home-mixer/sources/popular_posts_source.rs:13

  • The served type of PopularPostsSource changed from ForYouPhoenixRetrieval to ForYouPopularPosts verified.
  • The design that the 2026-10-03 commit (Gen4+35) described as "mixed in indistinguishably from Phoenix retrieval" has ended verified.
  • Downstream stages that read the served type (metrics, served history) now distinguish the two sources inferred. We did not check the consuming code.

B. The default weights of the page_rl behavior score changed, and weights can now be overridden per request. verified — phoenix/xrex/models/page_rl.py:69-70, 80-94, 319-324, recsys.proto:198-214:

-    vqv_weight: float = 0.5
-    share_weight: float = 40.0
+    vqv_weight: float = 1.0
+    share_weight: float = 4.0
  • In the behavior score of the immersive page re-ranker released in the 2026-10-14 commit (Gen4+41), the score that trims the candidate pool and serves as the reference policy for the KL leash, the video quality view weight doubled and the share weight fell to one tenth. Decoder serving is still off by default.
  • PageDecodeParams gained RankingScoreWeights (13 fields, the last being minVideoDurationMs), so the caller can send weights with each request.
    • If a value is NaN, the config default is used.
    • This diff has no code that converts the proto's default of 0 into NaN verified.
  • The Python for loop in the page decoder was changed to hk.fori_loop (a compilation optimization; the semantics are the same).

C. Embedding dedup was added to the video carousel, off by default. verified — util/video_carousel.rs:29, 72-105, params/param.rs:1083-1094, clients/post_v8_search_embedding_client.rs:12-17:

  • While picking videos in carousel score order, the carousel skips a video whose embedding cosine similarity to an already picked video is 0.72 or higher.
    • Quote posts and videos without an embedding are not compared.
    • The check order is author → SID prefix → embedding.
  • The embedding is read from Manhattan's tweet_multimodal_embeddings (version v8_search_1, 1024 dimensions, timeout 100ms, no dedup on failure). This is the first home-mixer serving code that reads grox's v8 search multimodal post embedding, and its use is dedup rather than retrieval verified.
  • It changed to on by default in the 2026-10-17 commit (Gen4+44).

D. The auxiliary lookups in visibility-filtering became best-effort (a behavior change). verified — visibility-filtering/hydration/plan.rs:375-409, hydration/mod.rs:30, rules/registry.rs:167-175:

  • 13 lookups, including follow relationships, block relationships of the author and conversation root, conversation control, and viewer country, are classified as best-effort and get a 300ms cap (1 second for ordinary lookups).
  • If these lookups fail, the verdict is Complete, not Partial, and the rules receive default values.
    • A verdict becomes Partial only when a required lookup such as post, safety label, viewer, block, or mute fails.
  • A test (rules/rule_spec/laws.rs:262-293) states explicitly an allow list of the rules that read best-effort lookups.
    • For example, it records the acceptance that if the follow lookup fails, posts from a protected account (protected_author) are dropped even for actual followers.

E. Other changes

  • The training label IsBrandEngagement was introduced. The 10 positive actions are like, reply, retweet, author follow, click, link open, profile click, photo expand, and carousel swipe (forward and back); video watch milestones and dwell are not included verified — phoenix/crates/serving/xai-recsys-proto/src/brand_engagement.rs:5-19.
    • No head or loss in this diff uses it.
    • We infer it is a label for the brand objective of ads inferred. The evidence is only the name.
  • The parent post ID (inReplyToPostId) was added to the reply retrieval index events (1fav_reply, 32fav_reply) — phoenixRankAllCandidateProcessor.strato:283, 295.
  • The commit added an option for a separate process to preload the retrieval post corpus in two-tower training (retrieval_staged_corpus=False), and handling in serving feature extraction that fills type-mismatched columns with 0 and counts them instead of panicking — trainer_recsys.py:420-423, xai-recsys/src/util.rs:115-126.

Why it matters

  • ⚠ Claim changed: the statement in the 2026-10-03 commit (Gen4+35) subsection that "PopularPostsSource candidates are mixed in under the Phoenix retrieval label and cannot be distinguished" no longer holds from this commit (Gen4+43) verified.
  • With the share weight in the page_rl behavior score cut to one tenth and the video quality view weight doubled, the score now leans toward video quality view rather than shares verified (values). The higher VQV weight points the same way as the carousel VQV path in the 2026-10-15 commit (Gen4+42) inferred.
  • The VF change tolerates failures of auxiliary relationship lookups to reduce latency, and the tests state explicitly that rare over-drops can occur as the cost verified (test comments).

Gen4+44 (2026-10-17, 862a123) — Three video carousel dedup checks on by default + carousel removed when Who-to-Follow is present + post engagers list feature (PostEngagers, on only in shadow traffic) + ZeroFav channel two-sided experiment filter (off by default) + two-tower microbatch shared candidate pool

With this commit, three dedup checks inside the For You video carousel, which is off by default, became on by default, and the carousel is no longer inserted when the same response has a Who-to-Follow module verified. The commit also added a feature that puts the list of user IDs who engaged with a candidate post into the ranker request; it is off by default and always on in shadow traffic verified (daily).

The commit is xai-org/x-algorithm@862a123 verified. The cron mirror sync time went from 2026-10-15T16:00:34Z to 2026-10-16T16:00:32Z. The commit used for line numbers is 862a123.

The visibility-filtering, abuse-enforcement, and bdsm changes are renames (FilterTweets → PostEvaluator, rules/registry.rs → rules/engine.rs, and so on), metrics, and a Kafka authentication change (SASL → mTLS) inferred. We found no place where what gets filtered out of the feed changed inferred. We did not read every VF hunk line by line.

A. The three video carousel dedup checks became on by default. verified — home-mixer/params/param.rs:1095-1112

  • The three parameters changed as follows: EnableVideoCarouselAuthorDedupe false → true, VideoCarouselSemanticIdDedupeLevels 0 → 3, and EnableVideoCarouselEmbeddingDedupe false → true verified.
  • The first two were introduced off by default in the 2026-10-13 commit (Gen4+40), and the embedding dedup in the 2026-10-16 commit (Gen4+43).
  • The carousel itself is still off by default, so these matter only in traffic where the carousel is on.

B. The carousel is not inserted when the same response has a Who-to-Follow module. verified — home-mixer/selectors/blender_selector.rs:50, 156-161:

fn drop_carousel_if_who_to_follow_present(
    video_carousel: Option<VideoCarouselModule>,
    wtf_modules: &[WhoToFollowModule],
) -> Option<VideoCarouselModule> {
    video_carousel.filter(|_| wtf_modules.is_empty())
}
  • It is an unconditional rule with no parameter, so a response contains only one of the WTF module and the video carousel.
  • The carousel header icon (VIDEO_PLAYER_LIST) added in the 2026-10-16 commit (Gen4+43) became None again — util/urt/video_carousel_marshaller.rs:88.

C. The post engagers list feature PostEngagers was introduced (off by default, always on in shadow traffic). verified — candidate_hydrators/post_engagers_hydrator.rs:43-45, util/post_engagers.rs:7-8, 48-73, params/param.rs:785-790:

  • For each candidate post, the commit fetches the list of user IDs who engaged with that post from a Redis cache (post-engagers-cache, response timeout 200ms) and puts it in the new ranking request field TweetInfo.postEngagers = 39.

    • Each entry has a user ID, the action bits actionBits, and the seconds elapsed since the engagement ageSecs.
    • It holds up to 32 positive and up to 8 negative users.
  • Engagements by the viewer and those after the serving time are excluded. We infer this is a device to prevent training-time leakage inferred. The evidence is the engaged_at_secs <= serve_secs condition and the fact that requests to the experiment cluster use the candidate's last_scored_at_ms as the serving time.

    (query.params.get(EnablePostEngagers) || query.is_shadow_traffic) && !query.has_cached_posts
    
  • The field is cleared when the request is stored in the cache — side_effects/phoenix_request_cache_side_effect.rs:95-98. This diff has no code in which a Phoenix model reads this field verified.

  • In shadow traffic, opt-in clusters (for example, Experiment18Fou) now receive 0% unless explicitly overridden — util/shadow.rs:9-29.

D. The two-sided experiment filter ZeroFavCodivertFilter for the ZeroFav channel was introduced, off by default. verified — filters/zero_fav_codivert_filter.rs:44-80, 105-110, params/param.rs:230-247

  • If the viewer is in treatment, a candidate that came in only through the ZeroFav retrieval channel (ForYouPhoenixRetrievalZeroFav) from the 2026-10-13 commit (Gen4+40) is kept only when the author is also in the treatment bucket verified.
  • The filter order is right after DropDuplicatesFilter verified.
  • We infer this is an experiment that splits viewers and authors together to measure the effect of ZeroFav channel impressions on the author side inferred. The evidence is the counters that count the author treatment and control buckets separately and the name codivert.

E. The Phoenix training code also changed. verified — phoenix/xrex/train/trainer_recsys.py:2349-2356, models/recsys_two_tower_model.py:556-618:

  • Two-tower retrieval supports num_microbatch > 1.
    • The candidate tower runs only once for all microbatches, and the users of each microbatch use the whole pool as in-batch negatives.
    • The loss is normalized by the number of valid users in the whole step.
    • This change increases the number of negatives with the same memory inferred. No two-tower preset in this diff uses num_microbatch > 1.
  • The following three other items are off by default.
    • The MoL gate CUDA kernel (mol_fused_gate, same formula)
    • A per-user ID list feature loader (top_p_, top_au_, latest_p_, and so on, with id_list_features="")
    • The attention output gate (attn_output_gate)
  • A proto RecentSearchQueries, which holds recent search queries and their embeddings, was also introduced, but no code uses it.

Why it matters

  • While the carousel is still off by default, the dedup and the conflict rule with other modules were settled as defaults first verified (values). We infer the order is to settle slot quality rules before turning the carousel on inferred. The evidence is that all five carousel-related changes since the 2026-10-08 commit (Gen4+38), namely +38, +40, +42, +43, and +44, dealt with internal rules of a carousel that is off by default.
  • PostEngagers is a feature that feeds "who reacted to this post" to the ranker as an ID list. What is new is that it passes the identities and action types of the engagers rather than aggregate counts verified (proto). Because it is always on in shadow traffic, we infer a model experiment is under way inferred.

↑ Contents