- Sources read: 2
- Canon files updated: 2 (services/x.md, radar.md)
- Radar entries: 1
Other changes reviewed today:
- X added a path in the same commit that attaches an AI trend label to original posts in For You. It is off by default.
- In the same commit, the share of child-safety cross-model validation and deluxe 4.3 category reclassification calls sent to Grok 4.6 rose from 0.1 to 0.3.
- The Xiaohongshu OneModel paper, which unifies three surfaces (organic recommendation, ads, and store) into one ranker, was added to the radar with production A/B results.
1For You ranking can receive slate context from Phoenix serving — X
Source xai-org/x-algorithm @ d0cef2f — "Open-source X Recommendation Algorithm" — 2026-08-20 20:22 UTC, public source code. These notes number X commits after the Gen4 release in order, and this commit is Gen4+5. It changed 14 files, at a scale of +479/−200, which is about 1/7 the scale of Gen4+4 (2026-08-19, aad7179, 79 files +3.3k/−0.7k).
Background After the Gen4 release, follow-up releases have continued in the X repository on a daily commit rhythm. The order is 8/14 Gen4+1 (c65aa17) → 8/17 Gen4+2 (b089ce6) → 8/18 Gen4+3 (11a71f8) → 8/19 Gen4+4 (aad7179, large). What the canon (this repository's canon documents) had folded in from Gen4 through Gen4+4 went only as far as "proto and config are ready." The 8/20 commit is small because it follows the large expansion of Gen4+4, but it is the first commit in which two scaffoldings that Gen4+4 had only prepared in proto and config reach the actual serving and ranking pipeline.
- What Gen4+4 prepared: the new
SlateContext.sid*fields in the Phoenix (X For You's ranking model) proto, the newRewardOutputs, and thexrecsys_searchconfig. - This commit is the first place in the code where
SlateContextis confirmed to flow along the Phoenix prediction response into home-mixer (the service that assembles the For You feed).
Three concepts are new in this commit:
- AI trend is an item that appears on X's "Trending" surface (events many people are talking about).
- Before this commit, the home-mixer pipeline had no logic that attached an "AI trend" label to tweets.
- Strato is the internal system that X code goes through to look up or store data.
- An upstream pipeline in which Grok looks at a tweet, decides which AI trend it belongs to, and stores the result in Strato likely existed (the
batch_get_tweet_ai_trendAPI), but that pipeline is outside this repository inferred.
- SlateContext is the context vector that each slot holds in a DPP (a diversity technique that selects items as a set so that the score drops when similar items are picked together) re-ranking slate.
kis the slot index,pool_rankis the rank in the original score ordering, andpool_rank_gapis the pool rank difference from the slot above.fatigue,pre_diversity_score, andsid_knownare added to these.sid_k_l1/l2/l3hold the slot index for each of the three SID (semantic ID, a token sequence that represents an item as hierarchical codes) layers.sid_gap_l1/l2/l3hold the inter-slot gap for each SID layer.- It is the same field set as when seven SID fields were added to VMRanker (the service that handles DPP slate reordering) in the 8/18 commit, Gen4+3.
- The served slate context is a schema field in which the Phoenix serving response returns, for each candidate, the material used for re-ranking.
- The material is the pool rank, fatigue, pre-diversity score, three SID layers, and three SID gap layers.
- In the Gen4+4 proto it was only defined; this commit connected the consumption path.
A hydrator that attaches AI trend labels to original posts in For You was added
This commit adds a new stage that selects standalone original posts among For You candidates and attaches AI trend labels verified. At most 2 labels are attached per page, and the gate that turns on this stage is false by default verified.
A hydrator that fills AI trend information into candidate posts was added as a new 157-line file verified —
home-mixer/candidate_hydrators/ai_trend_feedback_context_hydrator.rs.- The pipeline wiring is at
phoenix_candidate_pipeline.rs:424-428. - It was added right after the existing
TopicFeedbackContextHydrator, side by side. Topic feedback context is a context label used to infer the user's topics of interest.
Box::new(TopicFeedbackContextHydrator { strato_client: strato_client.clone() }), Box::new(AiTrendFeedbackContextHydrator { strato_client: strato_client.clone() }),- The pipeline wiring is at
This hydrator runs only when all three gate conditions are met verified —
ai_trend_feedback_context_hydrator.rs:59-63.- The gate parameter
rust_home_mixer_enable_ai_trend_feedback_contextmust be on. It is false by default. - The request must not be a topic request (so that it does not overlap the topic surface).
- The request must not be for the Following surface (requests that receive only posts from followed accounts). This condition applies per request and does not check whether a candidate is from a followed account.
- The hydrate loop (L65-75) also filters only by the original-post condition below, so candidates from followed accounts that are mixed into a For You request are also targets.
- It is not wired into the Following pipeline.
query.params.get(EnableAiTrendFeedbackContext) && !query.is_topic_request() && !query.in_network_only- The gate parameter
Candidates pass a four-part condition that keeps only the "standalone original post" verified —
ai_trend_feedback_context_hydrator.rs:19-24.- A candidate is dropped if it is a reply, is a retweet, has a conversation ancestor, or is a post that a followed user replied to. Meeting any one of the four drops it.
- In effect, AI trend labels can be attached only to original posts that are not replies, reposts, posts with conversation ancestors, or posts that followed users replied to. Whether a post is a quote post is not checked.
fn is_eligible_original_post(candidate: &PostCandidate) -> bool { candidate.in_reply_to_tweet_id.is_none() && candidate.retweeted_tweet_id.is_none() && candidate.ancestors.is_empty() && candidate.following_replied_user_ids.is_empty() }The Top-2 trends are chosen by candidate frequency, and one candidate is picked at random per trend to receive the label verified —
ai_trend_feedback_context_hydrator.rs:24-52.- The procedure has five steps.
- For each candidate that meets the conditions, look up the trend_id with Strato
batch_get_tweet_ai_trend. - Build a frequency histogram of the candidates' trend_ids.
- Pick the top 2 trends. On a tie, the smaller trend_id takes priority.
- For each trend, pick one candidate at random.
- Look up the display name of that trend with Strato
batch_get_ai_trend_name.
- For each candidate that meets the conditions, look up the trend_id with Strato
- In effect, at most 2 AI trend labels are attached per page, and each trend's label goes on only one candidate.
- It is a suppression mechanism that prevents the same label from being attached repeatedly to multiple candidates in the same trend.
const TOP_TREND_COUNT: usize = 2; fn select_feedback_targets(candidate_trends: &[(usize, i64)]) -> HashMap<usize, i64> { let mut frequency: HashMap<i64, usize> = HashMap::new(); for (_, trend_id) in candidate_trends { *frequency.entry(*trend_id).or_default() += 1; } let mut ranked: Vec<(i64, usize)> = frequency.into_iter().collect(); ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); ranked.truncate(TOP_TREND_COUNT); let mut rng = rand::rng(); let mut selected: HashMap<usize, i64> = HashMap::new(); let mut used_indices = std::collections::HashSet::new(); for (trend_id, _) in ranked { let candidates_for_trend: Vec<usize> = candidate_trends .iter() .filter(|(idx, id)| !used_indices.contains(idx) && *id == trend_id) .map(|(idx, _)| *idx) .collect(); let Some(&idx) = candidates_for_trend.choose(&mut rng) else { continue }; used_indices.insert(idx); selected.insert(idx, trend_id); } selected }- The procedure has five steps.
On screen it goes out in the same form as a topic label, and AI trend is used only when there is no topic feedback verified —
home-mixer/util/urt/post_marshaller.rs:72-110.- At the render stage it joins the topic path of the URT (X's response rendering schema)
TweetContextas a fallback. - Both go out in URT as the same context type,
ContextType::TOPIC. - The AI trend path differs from the topic path in two ways:
landing_urlis filled, andTopicFeedbackContext.topic_idisNone. - So when a user taps the topic-style chip attached above this tweet, it goes to
x.com/i/trending/{trend_id}, the trend surface, rather than the topic feedback page.
if let Some(topic) = post.topic_feedback_topic.as_ref().filter(|t| !t.is_empty()) { return Some(TweetContext { context_type: ContextType::TOPIC, text: topic.clone(), landing_url: None, context: Some(TweetContextDetails::TopicFeedbackContext(TopicFeedbackContext { topic: Some(topic.clone()), url: None, topic_id: post.topic_feedback_topic_id.clone(), })), .. }); } let trend = post.ai_trend_name.as_ref().filter(|t| !t.is_empty())?; let trend_id = post.ai_trend_id.as_ref().filter(|id| !id.is_empty())?; let landing_url = Url { url_type: UrlType::EXTERNAL_URL, url: format!("https://x.com/i/trending/{trend_id}"), urt_endpoint_options: None, }; Some(TweetContext { context_type: ContextType::TOPIC, text: trend.clone(), landing_url: Some(landing_url.clone()), context: Some(TweetContextDetails::TopicFeedbackContext(TopicFeedbackContext { topic: Some(trend.clone()), url: Some(landing_url), topic_id: None, })), .. })"
https://x.com/i/trending/{trend_id}" —post_marshaller.rs:92- At the render stage it joins the topic path of the URT (X's response rendering schema)
The following four items are not public:
- The definition of
AI trend(who creates trend ids and how, and whether tweets are clustered with Grok or curated by people) - The upstream pipeline of
batch_get_tweet_ai_trend - Whether
EnableAiTrendFeedbackContextis actually deployed - The tuning history of
TOP_TREND_COUNT = 2
Because the rust_home_mixer_ prefix in the gate name follows the same convention as other rust-migration gates in this repository, an A/B experiment may be in progress inferred.
Why it matters X opened a second context channel alongside the topic feedback context it has used so far. It is a fallback structure in which topic wins if it already exists and AI trend enters only when it does not, so the two do not overlap.
- The observation signals widen as well. A user's reaction of tapping, dismissing, or ignoring this chip becomes a label source for judging whether this user is interested in this AI trend.
- This is a different direction from the "layer expansion of the safety pipeline" folded in from Gen4 through Gen4+4. It confirms a trend of widening the signal source upstream of ranking itself.
The ranking stage consumes the slate context that Phoenix computed at serving
The SID fields created in the Phoenix proto in the 8/19 Gen4+4 commit (three SID layers and three gap layers for each slot) were connected to an actual pipeline consumption path in this commit. The proto field name is SlateContext.sid*. Four places opened at the same time:
The candidate struct gained one more slot for slate context, so it now has two verified —
home-mixer/models/candidate.rs:26-27, 76-77.- The struct is named
PostCandidate, and the new field isserved_slate_context. - The existing
slate_contextis a value that home-mixer computed and cached. - The new
served_slate_contextis a value that Phoenix computed at serving and sent in the response.
pub struct PostCandidate { pub score: Option<f64>, pub slate_context: Option<SlateContext>, #[serde(default)] pub served_slate_context: Option<SlateContext>, // ← new ... pub ai_trend_name: Option<String>, // ← new in the section above pub ai_trend_id: Option<String>, // ← new in the section above }- The struct is named
The conversion impl between proto and Rust renames the proto's short field names to home-mixer names verified —
candidate.rs:99-118.- It renames the proto's
sid_k1/2/3andsid_gap1/2/3to the home-mixer naming conventionsid_k_l1/l2/l3andsid_gap_l1/l2/l3. - Here "l" stands for level.
impl From<xai_recsys_proto::SlateContext> for SlateContext { fn from(c: xai_recsys_proto::SlateContext) -> Self { Self { k: c.k, pool_rank: c.pool_rank, pool_rank_gap: c.pool_rank_gap, fatigue: c.fatigue, pre_diversity_score: c.pre_diversity_score, sid_known: c.sid_known, sid_k_l1: c.sid_k1, sid_k_l2: c.sid_k2, sid_k_l3: c.sid_k3, sid_gap_l1: c.sid_gap1, sid_gap_l2: c.sid_gap2, sid_gap_l3: c.sid_gap3, } } }- It renames the proto's
PhoenixScorer extracts the slate context from the Phoenix prediction response and fills the new field verified —
home-mixer/scorers/phoenix_scorer.rs:107-125.- When the Phoenix prediction response carries
SlateContextalongside the 24 candidate scores (engagement probability heads), PhoenixScorer sets that value inserved_slate_contextand passes it down the pipeline. - The value is taken from
candidate_slate_context()of the prediction response.
.map(|c| PostCandidate { phoenix_scores: predictions.candidate_scores(&c.get_original_tweet_id()), served_slate_context: predictions .candidate_slate_context(&c.get_original_tweet_id()) .map(Into::into), prediction_request_id: Some(query.prediction_id), last_scored_at_ms, ..Default::default() })- When the Phoenix prediction response carries
RankingScorer decides the slate context with a three-way fallback verified —
home-mixer/scorers/ranking_scorer.rs:700-710, 806-825, 875-895.- If the
UseServedSlateContextgate is on and Phoenix sent slate context for all candidates, that value is used as is. - If the gate is off or the data is incomplete, and the Redis warming cache hits, the slate context stored for a previous request is reused.
- Otherwise, it is computed locally with
compute_slate_contexts(candidates, weighted_scores).
- This order applies identically to both ranking branches.
fn served_slate_contexts( query: &ScoredPostsQuery, candidates: &[PostCandidate], ) -> Option<Vec<SlateContext>> { if !query.params.get(UseServedSlateContext) { return None; } candidates.iter().map(|c| c.served_slate_context).collect() } // ... in each of the two ranking paths (the mpn_scoring branch and the diversity branch): 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)), };- If the
The gate that turns on this path came in with a default of false. Only the gate has been created, and the actual rollout is deferred to a later parameter change.
- The gate name is
UseServedSlateContext.
- The gate name is
Why it matters This is the first step of a migration in which the point that computes slate context moves from home-mixer locally to Phoenix serving. Phoenix already knows each candidate's pool rank, fatigue, pre-diversity, and three SID layers at the moment it produces predictions, so it can send them in the serving response instead of home-mixer recomputing them.
- That saves ranker CPU.
- The richer context that Phoenix holds can be reflected in re-ranking. For example, the three SID layers cannot be computed without Phoenix.
- The direction is for home-mixer to keep shrinking into a thin layer that handles only candidate allocation and re-ranking.
- The direction is the same as SID being connected to VMRanker in the 8/18 commit (Gen4+3). By definition SID is a Phoenix training output, so home-mixer cannot compute it locally, and this commit resolved that problem at serving time.
- The point at which a commit promotes
UseServedSlateContextto true is the point at which Phoenix serving's slate context output reaches production traffic.
The core logic of the embedding table loader was separated from the Python bindings
The embedding table loader of the Phoenix serving engine was split into a Python-side wrapper and a core that does not depend on pyo3 verified.
The core logic was split out of
emb_table.rsasload_tensor_into, and the existingload_tensorwas redefined as a thin wrapper around that core verified —phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs.- The file overhaul is +189/−169, and it is essentially an equivalent refactor.
- The existing
load_tensorwas tied to pyo3 types and pyo3 errors, so it could not be reused in non-Python contexts. - The tied types were
Bound<'py, PyString>,PyReadwriteArray1<'py, u8>, andBound<'py, PyList>, and the errors werePyValueError,PyOSError, andPyTypeError. - The new core does not depend on pyo3; it takes a
&mut [u8]interface and returnsResult<(), String>.
pub fn load_tensor_into( path: &str, urls: &str, shard_sources: &[(String, String, usize, usize)], tensor: &mut [u8], row_size: usize, num_row_segments: usize, ) -> Result<(), String> { ... } #[pyfunction] pub fn load_tensor<'py>( py: Python<'py>, path: Bound<'py, PyString>, urls: Bound<'py, PyString>, shard_sources: Bound<'py, PyList>, mut tensor: PyReadwriteArray1<'py, u8>, row_size: usize, num_row_segments: usize, ) -> PyResult<()> { let mut parsed = ...; // PyList → Vec<(String, String, usize, usize)> let path = path.to_string(); let urls = urls.to_string(); let tensor_slice = tensor.as_slice_mut()?; py.detach(|| { load_tensor_into(&path, &urls, &parsed, tensor_slice, row_size, num_row_segments) .map_err(PyOSError::new_err) }) }Safety was also tightened.
- Replaced the
unwrap()on tokio runtime creation withmap_err. The changed code ismap_err(|e| format!("tokio runtime: {e}")). - Added a
checkpoint path too shortguard. The condition isprefix.len() < 3, and the message ischeckpoint path too short: {path}. - Unified modulo checks such as
row_segment_size % num_row_segments != 0to theis_multiple_ofAPI.
- Replaced the
Why it matters By exposing the embedding table loader core through a pure Rust interface, this change prepares it for reuse without the pyo3 stack in training pipelines or other paths of Rust-native serving code. The direction is the same as the Phoenix xrecsys_search vocab growing to 100M/100M/30M in the 8/19 commit (Gen4+4). It suggests a growing need to load large embedding tables in multiple serving and training contexts.
The share of calls sent to Grok 4.6 at two judge call sites rose from 0.1 to 0.3
In grox (the Grok-based nearline classifier sub-repo), the Grok 4.6 dial introduced by Gen4+4 on 2026-08-19 rose 3× in a day, from 10% to 30% verified. This dial is the probability of sending calls to 4.6 instead of Grok 4.5 at two judge call sites (child-safety cross-model validation and deluxe 4.3 category reclassification). Because the 4.6 response is used for verdicts just like 4.5's, it is a dial that splits live traffic, not a shadow that discards results verified — classifier.py:281-290@aad7179.
What changed is one constant line verified —
grox/flows/ptos/classifier.py:114.-_GROK_4_6_INTERNAL_DIAL = 0.1 +_GROK_4_6_INTERNAL_DIAL = 0.3The circuit breaker configuration and the places where the dial applies are unchanged.
- The parameters of the
_EAPI_4_6_INTERNAL_BREAKER_CONFIGcircuit breaker (failure rate 50%, window 600 seconds, recovery 600 seconds, half-open 5 calls) did not change. - The two places where the dial applies, child-safety cross-model validation and deluxe 4.3 category reclassification, are also unchanged.
- The parameters of the
Why it matters This increase supports the view that, in the gradual rollout from 4.5 to 4.6, 4.6 did not trip the circuit breaker with 10% of live traffic and that verdict quality was within the acceptable range inferred. Otherwise the dial could not have been raised 3×.
- The Grok 4.5 judge layer introduced by Gen4+2 is being refined daily, and this commit is the fourth in that flow. The flow is +2 introduction → +3 finer logging → +4 4.6 dial 10% → +5 30%.
- If the next commit jumps to 50% or 100%, the full switch to 4.6 is close; if the dial drops again, a regression was observed.
Grok streaming responses can now be cut off midway when a condition is met
The grox Grok sampler gained a callback that ends a streaming response early, and it increments a counter each time it cuts one off verified.
A
stop_predicatecallback was added togrox/libs/grok_sampler/llm.pyverified — that file.- The scale is +27/−13.
- The existing
_sample_streamingkept consuming tokens from the raw stream as they arrived. - Now it wraps the raw stream in an explicit context with
async with aclosing(...), which guarantees cleanup even on early termination. Tokens are received inside thisasync with aclosingblock. - After each token is yielded, it checks
stop_predicate(resp); if True, it breaks immediately and increments the counterllm.sample.early_stop.count.
stop_predicate: Callable[[str], bool] | None = kwargs.get("stop_predicate") ... async with aclosing(self._sample_streaming_raw(request, **kwargs)) as raw_stream: async for tok in raw_stream: ... yield tok.text if stop_predicate is not None and stop_predicate(resp): logger.info(f"Response complete after {tokens_received} tokens, cancelling the stream") Metrics.counter("llm.sample.early_stop.count").add(1, attributes=attributes) break
Why it matters This change can be read as a sampling infrastructure optimization to handle the traffic increased by the Grok 4.6 dial introduced in Gen4+4 inferred. The basis is the circumstance that it is in the same commit as the change raising the dial to 30%.
- Safety verdict responses often arrive wrapped in a JSON tag (
<json>...</json>), and once the tag closes, the tokens that follow are meaningless. - Cutting off early with a callback such as
stop_predicate=lambda r: "</json>" in rreduces token cost and also the latency after TTFT.
Grok classifiers can now reference the author's profile image
A profile image field was added to the User schema of the grox data loader verified. The field is filled when the author metadata has a profile image URL verified.
The Grox
Userdata loader gained aprofile_imagefield verified.- The type is
profile_image: Image | None. - The changed files are
grox/core/data_loaders/data_types.pyandpost_mapper.py. - If
AuthorMetadata.profileImageUrlexists, it is filled withImage(url=...). - Previously the User schema held only the URLs, the affiliated business, and recent posts, with no profile image.
class User(BaseModel): urls: list[str] | None = None affiliated_business: AffiliatedBusiness | None = None recent_posts: list["Post"] | None = None profile_image: "Image | None" = None # ← new- The type is
Why it matters Grok classifiers can now reference the profile image as verdict context. For example, one can imagine ads brand-safety verdicts checking whether a profile image contains adult content. This may be upstream data wiring prepared for the adult content cross-validation judge introduced in Gen4+2 or for later expansions of the verdict layer inferred.
Summary of the six directions in this commit (Gen4+5, d0cef2f)
| Direction | Files | Scale | Nature |
|---|---|---|---|
| AI trend feedback context | ai_trend_feedback_context_hydrator.rs, post_marshaller.rs, candidate.rs, param.rs |
+157/+29+ ... | New feature: adds one For You context label channel |
| served_slate_context | phoenix_scorer.rs, ranking_scorer.rs, candidate.rs, param.rs |
+22/−10, etc. | Wiring starts: consumption path for the Gen4+4 proto |
| emb_table refactor | emb_table.rs |
+189/−169 | Refactor (equivalent): core made pyo3-free |
| Grok 4.6 dial 3× | classifier.py |
+1/−1 | Parameter change: live traffic 10% → 30% |
| grok_sampler stop_predicate | llm.py |
+27/−13 | Infrastructure: early-termination callback |
| profile_image field | data_types.py, post_mapper.py |
+7/+0 | Schema extension: image field on User |
Canon update Changed the following parts of services/x.md:
- Added a Gen4+5 (d0cef2f, 2026-08-20) row to the generation table (+479/−200, 14 files).
- Folded the following six items into the one-line summary:
- AI trend feedback context hydrator
- served_slate_context wiring starts
- emb_table core separation
- Grok 4.6 dial 0.3
- grok_sampler stop_predicate
- profile_image
- Rather than placing it under "Candidate generation", added a new subsection "AI trend feedback context — new in Gen4+5" between ranking and filters. It is positioned as a channel alongside topic feedback.
- After the Gen4+4 subsection of the "Phoenix training and index" section, created a new subsection "Gen4+5 (2026-08-20) — home-mixer starts consuming the slate context produced by Phoenix serving".
- After the Gen4+4 item in the "Grox → PtOS Adult Content Cross-Validation" section, added one line "Gen4+5 (2026-08-20) Grok 4.6 internal dial 0.1 → 0.3 (raised 3×)".
- Added a subsection "Resolved by the Gen4+5 release / New (2026-08-22)" to the open questions section. It lists 5 resolved items and 5 that need a deep dive.
- Added links to 6 files related to d0cef2f in the sources section.
Why it matters The pattern folded into the canon from Gen4 through Gen4+4 was confirmed again this time. Within the daily commit rhythm, the proto scaffolding and the actual consumption wiring arrive in succession, a day or two apart. Taken together with the new AI trend context channel, this reinforces the observation that X's ranking and candidate pipelines are expanding in two directions at once.
- Gen4+4's
SlateContextproto starting to be consumed in Gen4+5 is the third instance of this rhythm.- The first is Gen4+2's V2 label leading to the dual-write redesign in +3.
- The second is Gen4+3's VMRanker SID wiring leading to the formalization of the
sid*fields in the +4 proto. - The third is Gen4+4's SlateContext proto leading to the phoenix_scorer wiring in +5.
- The two directions are the axis that widens upstream signal sources (AI trend) and the axis along which home-mixer consumes Phoenix serving outputs in a thinner way.
2OneModel unified ranking across three surfaces: organic, ads, and merchant — Xiaohongshu
Source OneModel: A Unified Foundation for Platform-Scale Multi-Scenario Ranking (arXiv 2608.18606) — submitted to arXiv on 2026-08-19 (v2; v1 was the initial submission), paper. It has 14 authors (Yinqi Zhang, Peiyu Hu, Yuntian Tang, Siying Gu, Jiahao Liang, Longxin Kou, Haiqing Hu, Shuman Zhuang, and others). The abstract states that the deployed service is Xiaohongshu (小红书, a Chinese lifestyle social platform).
Background Xiaohongshu is a service the canon does not yet track. Even so, serving multiple business streams (organic recommendation, ads, merchant products) in one app through a unified system is unusual compared with the per-service fragmented rankers at X, Meta, and Netflix. This paper is a production deployment claim that three surfaces were unified into one ranker, and it adds a new angle to the following three lines of production claims:
- Netflix Foundation Model (FM) placed per-service heads on top of a single FM.
- Meta GEM built a backbone that unifies IG and FB within the ads domain.
- Yandex Music Gryphon-v2/Sona unified retrieval and ranking.
OneModel moves to a different angle: one backbone serves different business streams (organic, ads, merchant) at the same time.
What we learnedA. Final ranking for three streams is unified in one framework based on shared event sequences
The abstract points out that keeping a separate ranking system per stream fragments user representations and raises engineering cost, and it proposes OneModel, which merges final ranking into one.
"Platform-scale recommender systems often span multiple business streams such as organic recommendation, advertising, and merchant services, where user behaviors form a continuous cross-stream trajectory. Maintaining separate ranking systems fragments user representations and increases engineering cost. We propose OneModel, a unified framework for multi-stream final ranking. OneModel maps heterogeneous behaviors into shared event sequences, learns long-context user representations with an action-oriented backbone, and introduces Scenario-aware Information Modulation to balance cross-stream transfer and stream-specific specialization."
— OneModel (arXiv 2608.18606) abstract (paper)
There are three core architecture elements:
- The shared event sequence unifies user behavior from the three streams (organic, ads, merchant) into one event sequence schema.
- The action-oriented backbone is the part that learns long-context user representations. The abstract does not give the specific architecture.
- Scenario-aware Information Modulation balances cross-stream transfer and stream-specific specialization through switching and gating. The former corresponds to sharing, the latter to dedicated handling.
The abstract lists six optimization elements for production deployment.
- stratified user representation
- multi-objective training
- feature decomposition
- user feature prefetching
- shared user-tower computation
- graph-level inference optimization
What we learnedB. In Xiaohongshu production A/B tests, metrics rose on all three surfaces
- The A/B results came from the Xiaohongshu production deployment.
| Surface | Metric | Result |
|---|---|---|
| Explore Feed (organic recommendation) | Time Spent | +0.33% |
| Explore Feed (organic recommendation) | Engagement | +1.25% |
| Feed Advertising (ads) | advertising value | +3.43% |
| Feed Advertising (ads) | CTR | +8.18% |
| Merchant Recommendation (merchant) | DGMV (Daily GMV) | +1.1867% |
| Merchant Recommendation (merchant) | GPM (GMV Per Mille) | +2.1585% |
Canon update Added an OneModel entry (a 2–4 line pointer) to the "new this month" section of radar.md [radar promotion]. OneModel passed the radar acceptance criteria on arXiv. It is not yet promoted to a service canon (a new services/xiaohongshu.md), because the canon does not track it as a service. If a second company outside China makes a similar claim on a platform, it becomes a promotion candidate for a topic (multi-task-ranking.md or a newly created unified-multi-scenario topic).
Why it matters OneModel is the third production claim of a company-level architecture decision that "a single ranker serves multiple business streams in a unified way." Its direction differs somewhat from the LLM-native ranking thread. Meta GEM expanded to ads, IG, and FB but is still centered on the ads domain, whereas Xiaohongshu OneModel is new in unifying ads, organic, and merchant on equal footing.
- The first is Netflix FM (service diversity, from foundation to head).
- The second is Meta GEM (surface diversity within the ads domain).
- The third is Xiaohongshu OneModel (business stream diversity).
Corrections
None.
Radar
- OneModel (Xiaohongshu, arXiv 2608.18606, 2026-08-19) is covered in section 2 above and was added to "new this month" in radar.md.
What to check next
- Check three things in the follow-up commits to X d0cef2f — if the daily commit rhythm holds, they can be observed in the next commit.
- Watch for when the
UseServedSlateContextgate is promoted to true in production. That is the point when the slate context produced by Phoenix serving is reflected in real traffic. - Watch whether the
EnableAiTrendFeedbackContextgate is enabled. - Watch the next move of the Grok 4.6 dial. The question is whether it rises from 30% to 50% or 100%, or falls.
- Watch for when the
- Track the upstream of the X
AiTrenddefinition — the Strato columns read bybatch_get_tweet_ai_trendandbatch_get_ai_trend_nameare outside this repository. If related documents appear (for example, a new md file underdocs/or a README update), fold them in. - Check follow-ups to Xiaohongshu OneModel — v2 was submitted on 8/19, so the paper itself is still a preprint.
- Check whether it is published at a conference (RecSys/KDD/WWW 2026·2027).
- Watch for follow-up releases from the authors, in particular details of the "action-oriented backbone" architecture and the cross-stream transfer gating mechanism.
- Watch for statements that Xiaohongshu has also deployed it to other surfaces (such as search).
- The X Gen4+4 (aad7179) deep dive is deferred — it starts when a person requests it.
- The trace of
phoenix/xrex/models/recsys_model.py(+265/−25) in aad7179 is still unfinished. - We need to confirm in which training loop
RewardOutputsis filled and consumed, and whether the link-open 5-quantile is quantile regression.
- The trace of