- Sources read: 1
- Canon files updated: 1
- Radar entries: 1
1Safety filter waits longer on backends and caches authors for failed lookups — X
Source xai-org/x-algorithm@fee1d0f — public source code, 2026-09-10, 33 files, +1587/−391. These notes number X commits after the Gen4 release in sequence; this commit (2026-09-10, fee1d0f) is Gen4+19.
Background Since the Gen4 release on 2026-08-13, X has mirrored the For You pipeline source code to xai-org/x-algorithm about twice a day. visibility-filtering (VF, code in visibility-filtering/) is the gRPC safety filter service of the X home timeline and a Gen3 stack rewritten in Rust. When home-mixer (the service that assembles the For You feed) builds the candidate list, VF fetches tweet metadata and author labels for each candidate from two backends, fills them in, and feeds them to the rule engine. This commit is a medium-sized diff focused on VF; it fixes the time budget and failure handling of the backend lookups and shrinks the author label data structure.
The step in which VF fetches data from the backends and fills it in is called hydration. Tweet metadata comes from TES (Tweet Entity Service, X's core tweet lookup service). Author labels come from Gizmoduck (the user profile service). Hydration involves three intertwined decisions: how long to wait for the two backends, how far to proceed with verdicts when a lookup fails, and where latency is spent. This commit touched all three.
The changes fall on two main axes.
- Axis 1 is the hydration time budget and failure handling. The wait time was increased, a path was opened to evaluate author-based rules from the cache even on failure, and recording of where latency is spent began.
- Axis 2 is author label reduction and preparation of a cache primitive. Only the 11 labels the rules use remain, and the new cache is not yet used.
- There are also side changes outside axis 2. The proto constants and model code of the recommendation model, an ads log field, a batch job of the label transparency tool, and the image builder changed.
What we learnedA. Unified the wait time for both backends into one 390 ms constant, raising it substantially
VF's hydration timeout was replaced by one unified constant, and the timeout of Strato (the X internal system that code goes through to look up or store data) calls rose from 100 ms for TES and 80 ms for Gizmoduck to 390 ms verified —
visibility-filtering/hydration/mod.rsOther files covered by the same change:
visibility-filtering/server_deps.rs,visibility-filtering/hydration/gizmoduck_hydrator.rs,visibility-filtering/hydration/tes_hydrator.rsA new crate-internal constant was added to
visibility-filtering/hydration/mod.rs:pub(crate) const HYDRATION_TIMEOUT: Duration = Duration::from_millis(390);
This constant is used in two layers. The first is the hydrator layer. The client timeout of the TES and Gizmoduck hydrators had been a local constant of 150 ms each since the Gen4 release; both now reference this constant verified (same commit).
- The
CLIENT_TIMEOUTof the two hydrators was previously a local constant of 150 ms each, and now both referencecrate::hydration::HYDRATION_TIMEOUT.
- The
The second is the Strato client layer. The timeout constants hardcoded per service were both replaced by the value of this constant verified (same commit).
- Before: TES 100 ms (
TES_STRATO_REQUEST_TIMEOUT_MS: u64 = 100), Gizmoduck 80 ms (GIZMODUCK_STRATO_REQUEST_TIMEOUT_MS: u64 = 80) - After: both
HYDRATION_TIMEOUT.as_millis() as u64
- Before: TES 100 ms (
That is, the timeout that Strato callers actually wait for rose 3.9× and 4.9× for the two services respectively verified (same commit). It is a decision to give the VF service a much more generous latency budget for hydration.
Timeout-induced disagreements were possibly observed in the verdict-comparison canary introduced by Gen4+7 (
reference_compare.rs, 2026-08-24) inferred, and this increase reads as the response to that.
What we learnedB. Even when the TES lookup fails, author-based rules are evaluated with the author id left in the cache
A tweet whose TES lookup fails can now also have its author found through the author id previously recorded in the cache; this fallback is on by default and the default capacity is 1 M entries verified —
visibility-filtering/hydration/tes_hydrator.rs(+234/−42)- Other files changed with it:
visibility-filtering/models/mod.rs(+49/−9),visibility-filtering/config.rs(+14)
- Other files changed with it:
Previously, when a tweet was not found in TES or the lookup failed and the request also carried no author id, that candidate skipped verdict evaluation entirely verified (same commit).
- A TES lookup of one tweet has three outcomes:
Found(core_data),NotFound,Failed(error). - In the earlier code, if the result was not
Found, there was noPureCoreDatafor that tweet. Ifresolve_candidatethen could not findraw.request_author_id(the author id home-mixer put in the request, which may be absent), that candidate dropped out of the candidate list altogether.
- A TES lookup of one tweet has three outcomes:
This commit reinforced that case with a cache fallback. An author-id fallback cache field was added to
TesHydratorverified (same commit).New field definition:
pub(crate) type AuthorIdFallbackCache = FallbackCache<TweetId, u64>; pub struct TesHydrator { pub tes_client: Arc<dyn TESClient + Send + Sync>, author_id_cache: Option<AuthorIdFallbackCache>, }
fetch_pure_corenow returns a struct holding both the core data and the author ids recovered from the cache, instead of aHashMap<TweetId, PureCoreData>verified (same commit).New return type:
pub(crate) struct PureCoreHydration { pub(crate) core: HashMap<TweetId, PureCoreData>, pub(crate) recovered_authors: HashMap<TweetId, u64>, }
If the lookup result is
Found, it records (tweet_id, author_id) in the cache and also fills in the core. ForNotFoundandFailed, it looks up the previous author_id of this tweet in the cache verified (same commit).The priority in which
resolve_candidatepicks the author id is request, TES core, then cache verified (same commit).If the request carries the author id directly, that is used; otherwise it is taken from the TES core if present; otherwise the cache fallback is used. If no author id is obtained from any of the three paths, that candidate skips verdict evaluation.
Code:
let author = raw .request_author_id .or_else(|| core.get(&raw.tweet_id).map(|c| c.author_id)) .or_else(|| recovered_authors.get(&raw.tweet_id).copied())?;
As a result, even if TES is down or does not respond, author-side rules are still evaluated as long as the previous author_id of this tweet is in the cache verified (same commit).
- Author-side rules are the user's account state (suspended/deactivated/protected/erased/offboarded) and the 11 author user_labels. The 11 are covered in C below.
Tweet-side rules cannot be evaluated without the TES core, so they are still skipped verified (same commit).
- Tweet-side rules are nullcast, community-only, NSFW media, takedown reason, exclusive content, and edit control.
The fallback settings are exposed through two new environment variables, and when the variables are not set the default is on verified (same commit). In a production deployment it can only be turned off by opt-out, and it is on from the moment of deployment.
Using
is_none_or, it is on if the environment variable is absent, and if a value is present, that value is parsed.Code that reads the two environment variables:
pub(crate) fn author_id_fallback_enabled() -> bool { std::env::var("VF_AUTHOR_ID_FALLBACK_ENABLED") .ok() .is_none_or(|value| parse_env_flag(Some(&value))) } pub(crate) fn author_id_fallback_capacity() -> usize { std::env::var("VF_AUTHOR_ID_FALLBACK_CAPACITY") .ok() .and_then(|value| value.trim().parse::<usize>().ok()) .filter(|capacity| *capacity > 0) .unwrap_or(1_000_000) }
At initialization,
server_deps.rsleaves these two settings asinfo!fields verified (same commit). This log is captured per metric in the boot log, so if the actual production values differ, it is detected immediately.Initialization log:
info!( hydrator_count = 5, fallback_cache_enabled, author_id_fallback_enabled, author_id_fallback_capacity, home_rule_count, recommendations_rule_count, "VFServer initialized with prod clients" );
A and B are effectively one project. While A allows hydration to take longer by raising the timeout 2.6–4.9×, B builds an automatic fallback path for the case where it still fails.
This fallback does not reverse the "TesHydrator media fallback cache removed wholesale" observed in Gen4+9 (2026-08-26). The media fallback cached and reused the media information TES returned; this author-id fallback revives the previous value only for tweets TES could not respond for, and makes a partial verdict that evaluates only the author-side rules. In other words, it is the revival of a partial verdict, not the reuse of a result.
This change reads as narrowing the remaining robustness gaps after the large VF hydration simplification in Gen4+15 (2026-09-04) thinned the code by a net reduction of 800+ lines.
What we learnedC. Changed author labels to a data structure that holds only the 11 labels the rules actually use
The data structure that holds author labels in VF changed from a hash set to an enum representing only 11 labels plus a 64-bit bitset verified —
visibility-filtering/models/author.rs(+49/−13)- Other files changed with it:
visibility-filtering/hydration/gizmoduck_hydrator.rs,visibility-filtering/rules/author_rules.rs(+30/−27),visibility-filtering/rules/context.rs - Since the Gen4 release, VF's
AuthorFeatureshad held author labels in aHashSet<LabelValue>(a heap data structure holding 20+ thrift-defined labels). - This commit removed the existing
HashSet<LabelValue>and narrowed it to anAuthorLabelenum with 11 explicit variants and a 64-bit bitset.
- Other files changed with it:
This reduction is correct because the rules reference only these 11 labels. However, other labels coming from thrift are now explicitly discarded at the input stage and only counted by a counter.
models/author.rswas rewritten entirely verified (same commit).Previous definition:
use std::collections::HashSet; use xai_x_thrift::user_labels::LabelValue; #[derive(Clone, Debug, Default)] pub struct AuthorFeatures { pub is_suspended: bool, // ... pub user_labels: UserLabelSet, } #[derive(Clone, Debug, Default)] pub struct UserLabelSet { labels: HashSet<LabelValue>, }New definition:
#[derive(Clone, Copy, Debug, Default)] pub struct AuthorFeatures { pub is_suspended: bool, // ... pub user_labels: AuthorLabelSet, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum AuthorLabel { NsfwHighRecall, NsfwHighPrecision, NsfwNearPerfect, NsfwAvatarImage, NsfwBannerImage, SpamHighRecall, AbusiveHighRecall, Compromised, ReadOnly, ImpersonationHighPrecision, DoNotAmplify, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct AuthorLabelSet(u64);
AuthorLabelSet(u64)is a single integer mapping the 11 variants to individual bits, and insert and has are handled with shift and AND operations verified (same commit).Two new asserts pin the size.
AuthorFeaturesis exactly 16 bytes, andOption<AuthorFeatures>is also 16 bytes verified (same commit).One bool field is used as the
Optionniche.Asserts that pin the size:
assert_copy::<AuthorFeatures>(); assert_eq!(std::mem::size_of::<AuthorFeatures>(), 16); assert_eq!(std::mem::size_of::<Option<AuthorFeatures>>(), 16);In the earlier
HashSet<LabelValue>days, a heap allocation and hash buckets had to be taken every time.
The label mapping of the Gizmoduck hydrator shrank to a single explicit function verified (same commit). Each
LabelValuecoming from thrift (an enum of 20+ kinds) passes through this mapping and is narrowed toOption<AuthorLabel>, and if it isNone, only a count is incremented.Mapping function:
fn author_label(value: LabelValue) -> Option<AuthorLabel> { match value { LabelValue::NSFW_HIGH_RECALL => Some(AuthorLabel::NsfwHighRecall), LabelValue::NSFW_HIGH_PRECISION => Some(AuthorLabel::NsfwHighPrecision), LabelValue::NSFW_NEAR_PERFECT => Some(AuthorLabel::NsfwNearPerfect), LabelValue::NSFW_AVATAR_IMAGE => Some(AuthorLabel::NsfwAvatarImage), LabelValue::NSFW_BANNER_IMAGE => Some(AuthorLabel::NsfwBannerImage), LabelValue::SPAM_HIGH_RECALL => Some(AuthorLabel::SpamHighRecall), LabelValue::ABUSIVE_HIGH_RECALL => Some(AuthorLabel::AbusiveHighRecall), LabelValue::COMPROMISED => Some(AuthorLabel::Compromised), LabelValue::READ_ONLY => Some(AuthorLabel::ReadOnly), LabelValue::IMPERSONATION_HIGH_PRECISION => Some(AuthorLabel::ImpersonationHighPrecision), LabelValue::DO_NOT_AMPLIFY => Some(AuthorLabel::DoNotAmplify), _ => None, } }
A new metric
vf_author_labels{result="mapped"|"unmapped"}was added, along with a test that enforces at compile time that the contract does not drift verified (same commit).Test:
#[test] fn dashboard_generator_pins_the_author_labels_metric_name() { // ... asserts dashboard.py contains AUTHOR_LABELS_METRIC = "vf_author_labels" }
These 11 are exactly 1:1 with the 11
OON_USER_LABEL_DROPSrules folded into the canon in Gen4+13 (7ba7768, 2026-09-01) verified (same commit). That is, only the labels actually referenced by the rules remain, and the rest were removed from the input.
⚠ Re-checked the canon content. The safety pipeline label list in services/x.md already contained these 11 before. What this commit did was fix that fact into a data structure; it did not add new labels. However, the fact that other LabelValues defined in thrift (for example NSFW_TEXT_HIGH_PRECISION, NSFW_TEXT_HIGH_RECALL, NSFW_NEW_USER) are explicitly discarded at the hydrator layer is confirmed for the first time by this commit.
What we learnedD. VF now records, for the first time, whether it responded within the caller's time budget
VF now records as a metric whether it responded within the gRPC deadline carried in the request, and records request time split into two phases verified —
visibility-filtering/filter_tweets.rs(+42)- Other files changed with it:
visibility-filtering/filter.rs(+6/−1),visibility-filtering/rules/metrics.rs - The deadline metric is
filter_tweets_deadline{outcome=within|overrun|absent}, and the two phases are hydration and post_hydration.
- Other files changed with it:
filter_tweets.rs::FilterTweetsEndpoint::filter_tweetsparses the standard gRPCgrpc-timeoutheader in the request metadata verified (same commit).It supports the unit suffixes
H/M/S/m/u/n(hours, minutes, seconds, milli, micro, nano) of the gRPC spec. The number must be at most 8 digits, and any other format yieldsNone.Parsing function:
fn parse_grpc_timeout(metadata: &MetadataMap) -> Option<Duration> { let raw = metadata.get("grpc-timeout")?.to_str().ok()?; let (digits, unit) = raw.split_at(raw.len().checked_sub(1)?); if digits.is_empty() || digits.len() > 8 || !digits.bytes().all(|b| b.is_ascii_digit()) { return None; } let value: u64 = digits.parse().ok()?; let nanos_per_unit = match unit { "H" => 3_600_000_000_000, "M" => 60_000_000_000, "S" => 1_000_000_000, "m" => 1_000_000, "u" => 1_000, "n" => 1, _ => return None, }; Some(Duration::from_nanos(value.checked_mul(nanos_per_unit)?)) }
At the point of a successful response,
RequestMetricsGuard::record_deadline(grpc_timeout)is called verified (same commit). Counters count whether the response was within the deadline, exceeded it, or there was no deadline, and histograms record the remaining time and the overrun time.Recording function:
pub(crate) fn record_deadline(&self, grpc_timeout: Option<Duration>) { let Some(deadline) = grpc_timeout else { incr(DEADLINE, &[("outcome", "absent")], 1); return; }; let elapsed = self.start.elapsed(); if elapsed <= deadline { incr(DEADLINE, &[("outcome", "within")], 1); observe_vm(DEADLINE_REMAINING_MS, &[], millis(deadline - elapsed)); } else { incr(DEADLINE, &[("outcome", "overrun")], 1); observe_vm(DEADLINE_OVERRUN_MS, &[], millis(elapsed - deadline)); } }The three counters are
filter_tweets_deadline{outcome=within|overrun|absent}, and the two histograms arefilter_tweets_deadline_remaining_ms/filter_tweets_deadline_overrun_ms.
VF now observes for the first time how much slack its own latency had against the deadline given by the caller (home-mixer), whether it overran, or whether it could not be measured verified (same commit).
filter.rs::FilterTweets::runmeasures request time in two phases verified (same commit). With the new histogramfilter_tweets_phase_ms{stage="hydration"|"post_hydration"}, it is also possible to observe for the first time how total request latency is allocated between hydration and the rule engine.Code that measures the two phases:
let started = Instant::now(); let hydration = self.hydration_pipeline.hydrate(...).await; let hydrated_at = Instant::now(); ft_metrics::record_phase("hydration", hydrated_at - started); // ... rule engine ft_metrics::record_phase("post_hydration", hydrated_at.elapsed());
It is natural for A's timeout increase and this added observation to ship together. Once the timeout is raised, one needs to see how much it actually rose, and to know in which phase that latency is spent.
Also, the convention so far that home-mixer puts the standard gRPC grpc-timeout header in the request when calling VF is confirmed in code for the first time in this commit. In earlier commits the timeout was enforced only on the caller side, and the VF service did not read that value.
What we learnedE. A new sharded cache primitive arrived, but nothing uses it yet
A new 32-way set-associative × 64-shard CLOCK cache primitive arrived in VF, but it is not yet connected anywhere verified —
visibility-filtering/clock_cache.rs- Other file changed with it:
visibility-filtering/lib.rs visibility-filtering/clock_cache.rsis a new file of 446 lines, registered invisibility-filtering/lib.rswith the single linepub(crate) mod clock_cache;. No code in this commit references this file.
- Other file changed with it:
The 32-way × 64-shard cache that arrived as the new file
clock_cache.rs(446 lines) carries adead_codeattribute that suppresses the compiler warning verified (same commit). From this we infer it has no consumer yet, and which hydrator consumes the cache inclock_cache.rsin the next commit is the thing to watch.File header:
#![cfg_attr(not(test), expect(dead_code, reason = "TODO: no consumer wired yet"))]It tells the compiler explicitly: "In non-test builds, we know this module is dead; do not emit a warning; the reason is that no consumer is attached yet."
It takes capacity
capacityas an argument, createsset_count = capacity / WAYSsets, and distributes them across 64 shards verified (same commit). One shard holds several sets, and one set holds 32 slots (ways).The value stored in a slot is the tuple
(key: u64, generation: u64, value: Option<V>).Main definitions:
const WAYS: usize = 32; const SHARDS: usize = 64; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Lookup<V> { Miss, // key is not even registered in this cache NotFound, // registered but the value is None (e.g., the backend returned NotFound) Found(V), } pub(crate) struct ClockCache<V> { /* shards, set_count, next_generation */ }
The CLOCK algorithm runs as follows verified (same commit).
- Insert (
insert(generation, key, value)): if the same key already exists and the stored generation is larger, it is skipped. This is to keep an old value from overwriting a new one. Otherwise it overwrites in place and marks itMark::Referenced. If the key is absent, the hand rotates, decayingReferenced → Cold, and inserts at the firstColdorEmptyslot it meets. - Lookup (
get(key)): if it finds the same key, it promotes it toMark::Referencedand returnsLookup::Found(value). If absent it isMiss, and if the stored value isNoneit isNotFound. - Generation: for each request,
begin_request() -> u64assigns a monotonic value. This value decides whether a later request may overwrite an older cache value.
- Insert (
This cache is a primitive separate from the existing
FallbackCacheverified (same commit).- The existing
FallbackCacheishydration/fallback_cache.rs, folded into the canon in Gen4, and is a TinyLFU based onmoka::sync::Cacheprotected by a single mutex.
- The existing
The two caches differ in locking unit, replacement policy, slot layout, value type, and concurrency verified (same commit).
| Axis | Existing FallbackCache (moka and Mutex) | New ClockCache |
|---|---|---|
| Locking unit | One Mutex for the whole cache | One Mutex per shard (64) |
| Replacement policy | TinyLFU (probabilistic frequency) | CLOCK (Referenced ↔ Cold decay) |
| Slot layout | Dynamic hash map | Fixed 32-way set-associative array |
| Value type | Arbitrary (Clone) | Copy constraint (suited to values around the size of u64) |
| Concurrency | Can bottleneck on one mutex | Mostly non-blocking with 64-way sharding |
That is, it reads as a new hot-path cache that is far more resistant to write throughput and lock contention. The main thing to watch is which hydrator (TES author_id? Gizmoduck AuthorFeatures? SafetyLabel expiring cache?) it connects to as a backend in the next commit.
What we learnedF. In the Phoenix ranker proto, two surface constants and one behavior aggregation type got names
In the ranking wire proto of Phoenix (the Grok-based Transformer ranker), surface constants for Trend and Explore Spotlight and an aggregation constant for long-dwell immersive behavior were newly added verified —
phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto- Other file changed with it:
phoenix/python/common/xai-proto/proto/recsys.proto - The new constants are the two surface constants
TREND_PAGEandEXPLORE_SPOTLIGHTand the action aggregation constantIMMERSIVE_LONG_DWELL. - As enum values, they are two
ProductSurfacevalues (TREND_PAGE=16,EXPLORE_SPOTLIGHT=17) andUserActionAggregationType::IMMERSIVE_LONG_DWELL=29.
- Other file changed with it:
Numbers 16 and 17 of the
ProductSurfaceenum got the names of the Trend surface and the Explore Spotlight surface respectively (previouslyPLACEHOLDER_16andPLACEHOLDER_17) verified (same commit). The two surfaces were added after the 15 constants folded into the canon in Gen4+3.Changed enum:
enum ProductSurface { // ... 1-15 as before PRODUCT_SURFACE_HOME_TIMELINE_RANKED_FOLLOWING = 15; PRODUCT_SURFACE_TREND_PAGE = 16; PRODUCT_SURFACE_EXPLORE_SPOTLIGHT = 17; PRODUCT_SURFACE_PLACE_HOLDER_18 = 18; PRODUCT_SURFACE_PLACE_HOLDER_19 = 19; PRODUCT_SURFACE_PLACE_HOLDER_20 = 20; }
There is no actual home-mixer connection yet, so no code in this commit puts these two constants into Phoenix for ranking verified (same commit). For now it is only a signal that the proto slots are ready.
In
UserActionAggregationType, the previousPLACEHOLDER_29got the nameIMMERSIVE_LONG_DWELL = 29verified (same commit).Changed enum:
enum UserActionAggregationType { // ... IMMERSIVE = 26; ADS_CHAD_WITH_CONV_TYPES = 27; ADS_WITH_KV_AD_HISTORY = 28; IMMERSIVE_LONG_DWELL = 29; PLACEHOLDER_30 = 30; // ... }
Separately from the existing
IMMERSIVE = 26(aggregation of interaction sequences on the immersive surface), immersive interactions with long dwell time can now be split into a separate aggregation type verified (same commit).Which training config consumes this new aggregation does not appear in this commit verified (same commit).
What we learnedG. The user tower of the two-tower model now uses a separate segment ID for padding positions
The user tower of the two-tower model (a retrieval model structure that embeds the user side and the candidate side separately) was changed to use a separate segment ID for padding positions verified —
phoenix/xrex/models/recsys_two_tower_model.py(+15/−4)- It uses
PADDING_SEGMENT_IDfor padding positions andHISTORY_SEGMENT_IDfor real history positions. It changed from the earlier all-zero to one that reflects the mask.
- It uses
The earlier
user_tower_segment_idslogic had two branches verified (same commit).If
use_history_segment_ids=True, it filled the whole array withHISTORY_SEGMENT_ID. This is an inaccurate approach that treats even padding as the same segment as history.If
False, it treated every position as 0 withjnp.zeros. RoPE (rotary position encoding) and attention bias were computed even at padding positions.Earlier code:
def user_tower_segment_ids(batch: int, seq_len: int, *, use_history_segment_ids: bool) -> jax.Array: if use_history_segment_ids: return jnp.full((batch, seq_len), HISTORY_SEGMENT_ID, dtype=jnp.int32) return jnp.zeros((batch, seq_len), dtype=jnp.int32)
When padding_mask is passed in, the new logic mixes
HISTORY_SEGMENT_IDandPADDING_SEGMENT_IDaccording to the mask value verified (same commit).Later code:
def user_tower_segment_ids( batch: int, seq_len: int, *, use_history_segment_ids: bool, padding_mask: jax.Array | None = None, ) -> jax.Array: if use_history_segment_ids: return jnp.full((batch, seq_len), HISTORY_SEGMENT_ID, dtype=jnp.int32) if padding_mask is None: return jnp.zeros((batch, seq_len), dtype=jnp.int32) return jnp.where(padding_mask, HISTORY_SEGMENT_ID, PADDING_SEGMENT_ID).astype(jnp.int32)Call site:
user_segment_ids = user_tower_segment_ids( B, T, use_history_segment_ids=self.config.use_history_segment_ids, padding_mask=user_padding_mask, )
Now the user tower's attention, RoPE, and attention bias are computed so that padding positions are distinguished from real history verified (same commit). It is a correctness fix in the same direction as "native support for variable-length history in the FA4 attention kernel" folded into the canon in Gen4+15 (2026-09-04, 9b0dc31).
What we learnedH. The ads injection log now carries an ads blender label
A field that records the ads blender label was added to home-mixer's ads injection log verified —
home-mixer/side_effects/ads_injection_logging_side_effect.rs(+5/−1)The field name is
ads_blender, and the diff is short:ads_blender: match query.request_type { RequestType::Following => "following".to_string(), _ => query.params.get(AdsBlenderType), },
The
ads_blendervalue attached to the ads injection log payload is determined by request type. ForRequestType::Followingit uses the hardcoded"following", and only on other surfaces does it look up theAdsBlenderTypefeature-switch param verified (same commit).Following the five ads blenders introduced by Gen4+10 (2026-08-28, 24c6094), a separate blender label
"following"for the Following surface formally enters the logging axis verified (same commit).- The five blenders are
safe_gap,partition_organic,partition_organic_low_risk,time_gap, andmulti_risk.
- The five blenders are
Whether this is a sixth blender, or the Following surface goes through a separate pipeline outside the five blenders so that only the label is captured in logging, cannot be confirmed from this diff alone inferred. The latter fits the code flow better inferred. Skipping the param lookup entirely reads as a sign of different pipelines.
What we learnedI. The label transparency tool (UTH) gets a daily per-country account takedown batch, and label aggregates get a country field
A daily batch job for per-country account takedowns was newly added to the Under-The-Hood (UTH, X's label transparency tool) data pipeline verified —
under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala- The job name is
UthPctdAccountTakedownEventsJob, and the new batch job is 143 lines.
- The job name is
A
countryCodesfield was added to the UTH label aggregate verified —under-the-hood/thrift/uth_serving.thriftunder-the-hood/is the backend of the transparency tool for users' personal account and post labels that X exposes at x.com/i/under_the_hood verified (same commit).The batch job joins two sources verified (same commit).
AuditServicePctdActionsScalaDatasetis the PCTD (post and content takedown) action log. It keeps only entries that are of account type, were successfully taken down, and have a country:.filter { a => a.type == PctdActionType.User && a.takendown && a.countryCode.nonEmpty }.UsersourceScalaDatasetisuser.takedowns.takedownCountryReasonsof the user snapshot. The reasons are converted asTakedownReason::LegalRequest → "LEGAL_REQUEST",BystanderReport → "BYSTANDER_REPORT",Dmca → "DMCA"(country hardcoded as"xy", no country information), andUnspecifiedReason → "UNSPECIFIED".
If several reasons are captured for the same (userId, country), the one with the lower priority value is kept verified (same commit).
Priority table:
private val ReasonPriority = Map( "LEGAL_REQUEST" -> 0, "BYSTANDER_REPORT" -> 1, "DMCA" -> 2, "UNSPECIFIED" -> 3 )
The result is written as
UthAccountTakedownEvent(userId, day, reason, country)to a partitioned parquet atdaily_account_takedowns/YYYY/MM/DDverified (same commit).- Default reducers are 50, default usersourceSnapshotMaxAgeDays is 10, and cross-DC snapshot access (
AllowCrossDC) is allowed. - Test accounts are excluded by the test user filter (
TestUserFilter.isTestUserId).
- Default reducers are 50, default usersourceSnapshotMaxAgeDays is 10, and cross-DC snapshot access (
At the same time, two structs in
uth_serving.thriftgain a country field verified (same commit).Changed structs:
struct UthPostLabelAggregate { 1: optional string label (personalDataType = 'TweetSafetyLabels') 2: optional list<UthDayCarriedRemoved> days 3: optional list<string> countryCodes // NEW } struct UthAccountLabelAggregate { 1: optional string label (personalDataType = 'UserSafetyLabels') 2: optional list<i32> activeDays 3: optional list<string> countryCodes // NEW }
In sum, the UTH transparency tool gains two new things verified (same commit).
- It now has its own dataset of daily event logs of when, in which country, and for which reason each account was taken down.
- It is ready to expose a country code list in the label aggregate.
This change pairs with Gen4+12 (2026-09-01, 6384ca7), where the 16 countries subject to NSFW gating were stated explicitly in code for the first time. It reads as a plan to let users see in UTH which label was active in which country. We are watching whether per-country filters or displays appear on the UTH screen.
What we learnedOther changes
- The Safety-label expiring cache reserves the capacity to use in advance at initialization, avoiding reallocation on the first insert verified —
visibility-filtering/safety_label_source/expiring_cache.rsExpiringCache::with_clockcallscache.reserve(capacity)at initialization.
- The dead keyed-result path was removed from the hydration metrics code, and a new author label aggregation helper was added verified —
visibility-filtering/hydration/metrics.rshydration/metrics.rs+21/−154:timed_keyed_rpc,KeyedResultCounts::from_results, andouter_timeoutwere removed.- After the batch unification in Gen4+15 (9b0dc31, 2026-09-04), this keyed-result path was dead code.
- The new helper is
record_author_labels(mapped, unmapped).
- The motion-reveal thumbnail generation in grox (the Grok-based nearline classifier sub-repo) was refined to take more frames and pass them through alignment, denoise, and color verification stage by stage verified —
grox/libs/video_tools/image.py(+288/−25)- Thumbnail generation creates a single-frame highlight from a video. Two stages, ECC affine alignment and Farneback optical flow refinement, were added to the motion-reveal image builder (the video → highlight frame generator) that handles this.
- In sum, the changes are a 32 → 48 frame cap, ECC affine alignment, Farneback optical flow refinement, weighted median, and an LAB skin color plausibility gate.
_MOTION_REVEAL_MAX_INPUT_FRAMESchanged from 32 to 48, and six new helpers were added._ecc_align: aligns the affine transform withcv2.findTransformECC._is_static: decides whether a frame is static withcv2.phaseCorrelate. A frame is static if both the shift magnitude and the phase correlation response are at or below the threshold._dense_align_residual: finely aligns the residual with Farneback dense optical flow._weighted_median: computes the per-pixel weighted median of the overlay shifted after alignment._native_chroma: computes the median chroma coordinate of the brightest 30% of pixels in the LAB color space._skin_plausible: a gate that checks whether the overlay's chroma median is within a distance of 12 on the skin-color axis. If false, it prevents the false positive of a skin overlay.
- The
PlanSafetyPtosSpecialVideoDAG, which was folded into the canon in Gen4+12 (2026-09-01), consumes these motion-reveal frames, so this refinement likely directly affects the verdict quality of that DAG inferred.
- The feature-switch defaults mirror in
home-mixer/params/param.rshas no substantive change other than its timestamp being updated from2026-09-09T16:23:17Zto2026-09-10T16:21:03Z.
"pub(crate) const HYDRATION_TIMEOUT: Duration = Duration::from_millis(390);" — visibility-filtering/hydration/mod.rs:28, 2026-09-10
"let author = raw.request_author_id.or_else(|| core.get(&raw.tweet_id).map(|c| c.author_id)).or_else(|| recovered_authors.get(&raw.tweet_id).copied())?;" — visibility-filtering/models/mod.rs:45-49, 2026-09-10
Canon update In services/x.md, a new ### Gen4+19 subsection was created under the ## Safety section listing ten axes, A–J, and the last updated date at the top of the document was moved from 2026-09-11 to 2026-09-12. B (AuthorId fallback) and D (gRPC deadline observation) are new data points for the safety pipeline, so they were explicitly folded into the canon. F (TREND_PAGE, EXPLORE_SPOTLIGHT, IMMERSIVE_LONG_DWELL) and G (padding segment ID) are changes to the ranking wire and training configuration, so they were folded in as well. E (the clock_cache primitive) is not yet connected, but a consumer is expected to attach in the next commit, so it was documented explicitly.
Why it matters This commit raises the verdict success rate by extending the timeout and opens a partial-verdict path, gaining robustness through partial-open instead of fail-open (passing content through when a verdict fails). It continues the line of visibility-filtering robustness improvements that has run since the Gen4 release, and Gen4+19 adds a higher timeout budget, a fallback cache, a smaller label data structure, and phase observation on top of it.
The main milestones of this line so far are as follows:
- Shadow deployment mirroring in Gen4+4 (2026-08-19,
dark_traffic_setup.rs) - A verdict-comparison canary in Gen4+7 (2026-08-24,
reference_compare.rs) - The rules restructuring in Gen4+13 (2026-09-01, 7ba7768)
- The hydration simplification with a net reduction of 800 lines in Gen4+15 (2026-09-04, 9b0dc31)
⚠ Claim changed: none to update. This diff overturns no earlier canon statement, and caveat list #1 in the canon (this repository's canon document), that VF is fail-closed, is still valid. A tweet that could not be judged because hydration failed is still skipped, and the author fallback only turns what used to be skipped into a partial verdict.
Radar
Added one Kuaishou cascade recommendation paper to "New this month" in radar.md. The UniRec proposed in the paper is a method that aligns the fusion of the pre-ranking and ranking stages of a cascade recommender through joint learning of the two stages, and in the Kuaishou production A/B it raised app usage duration by +0.616% verified — arXiv 2609.11052, submitted 2026-09-10.
- The problem it addresses is that the cascade stages of industrial recommendation (pre-ranking, ranking) are optimized separately under different objectives, feature spaces, and latency constraints verified (same paper). The upstream filters out items the downstream prefers, and the downstream fusion offsets upstream improvements.
- The two fusion agents share part of the input embedding and are trained together in a single computation graph, so gradients flow between stages verified (same paper).
- Dual-axis preference alignment works in two directions verified (same paper).
- Vertical: transfers downstream pairwise preference to the upstream fusion score.
- Horizontal: recasts dozens of pairwise objectives over heterogeneous prior signals as bidirectional preference evidence.
- In addition, attribute group-relative regularization suppresses the side effect of unconstrained end-to-end fusion over-concentrating on particular attribute groups verified (same paper).
- In a 21-day production A/B, app usage time showed a +0.616% relative gain, and the model was fully deployed on the Kuaishou platform verified (same paper).
What to check next
In the next commit, we look at how the new cache and the longer timeout are actually used, and where the new proto constants and the country field of the transparency tool get connected.
- Check where
clock_cacheis actually used: the next commit should show which of the TES author_id, Gizmoduck AuthorFeatures, and SafetyLabel expiring cache three is replaced by this new primitive. Whetherfallback_cache.rsis replaced by clock_cache or attached to a separate hot path is also something to watch. - See how the
HYDRATION_TIMEOUTof 390 ms actually behaves: once the newfilter_tweets_phase_ms{stage="hydration"}histogram andfilter_tweets_deadline_overrun_msappear on production dashboards, the real P50 and P99 latency distributions and the overrun rate become known. It would help to confirm in code the gRPC deadline value that home-mixer passes to VF. For now only the code that parses thegrpc-timeoutheader has been confirmed, and the actual value is not public. - See where the
TREND_PAGEandEXPLORE_SPOTLIGHTsurfaces get connected: the proto slots are open, so gRPC endpoints or candidate pipelines for these surfaces may appear in home-mixer in the next commit. The relationship toxrecsys_search(the search ranking config introduced in Gen4+4) is also something to watch. - See where the
IMMERSIVE_LONG_DWELLevent is defined: check where code that builds this aggregation from a dwell time threshold on immersive surfaces appears in grox or home-mixer. Also check whether the training config consumes this aggregation. - See whether country-level takedown information appears on the UTH screen: watch whether a country filter or a country display on labels appears at x.com/i/under_the_hood. How the explicit 16-country NSFW codes in Gen4+12 and this UTH country field expansion combine on the user screen is the next thing to watch.
- Keep looking for the actual use of
Action::Downrank: it is an open question left by the Gen4+18 commit, and it still does not appear in this commit. - See whether header-image-prompt-ad enters the ads blender: this is an open question left by the Gen4+18 commit. The
ads_blenderlabeling expansion (H) in this commit is a related piece. The fact that thefollowinglabel explicitly takes a special path suggests that the new ad format may also take a per-surface blender branch inferred.
Lookup failed: none.