- 읽은 자료 1건
- 정본 반영 1개 파일
- 레이더 1건
1안전 필터가 백엔드를 더 오래 기다리고, 조회 실패에 대비한 저자 캐시를 갖췄다 — X
자료 xai-org/x-algorithm@fee1d0f — 공개 소스코드, 2026-09-10, 파일 33개, +1587/−391. 이 노트들은 4세대 공개 뒤의 X 커밋에 차례로 번호를 붙여 부르며, 이 커밋(2026-09-10, fee1d0f)은 4세대+19다.
배경 X는 2026-08-13 4세대 공개 이래 For You 파이프라인 소스코드를 xai-org/x-algorithm에 매일 두 번 정도 미러링한다. visibility-filtering(VF, 코드는 visibility-filtering/)은 X 홈 타임라인의 안전 필터 gRPC 서비스이고, Rust로 재작성된 3세대 스택이다. 홈믹서(For You 피드를 조립하는 서비스)가 후보 리스트를 만들면, VF는 후보마다 tweet 메타데이터와 저자 라벨을 두 백엔드에서 가져와 채운 뒤 규칙 엔진에 넣는다. 이번 커밋은 VF에 집중한 중형 diff로, 백엔드 조회의 시간 예산과 실패 처리를 고치고 저자 라벨 자료구조를 줄였다.
VF가 백엔드에서 데이터를 가져와 채우는 이 단계를 하이드레이션이라 한다. tweet 메타데이터는 TES(X의 tweet 코어 조회 서비스인 Tweet Entity Service)에서 가져온다. 저자 라벨은 Gizmoduck(user profile 서비스)에서 가져온다. 하이드레이션에는 두 백엔드를 얼마나 기다려 줄지, 실패했을 때 판정을 어디까지 진행할지, 지연이 어디서 소진되는지라는 세 결정이 얽혀 있다. 이번 커밋은 셋을 모두 건드렸다.
변경은 크게 두 축이다.
- 축 1은 하이드레이션의 시간 예산과 실패 처리다. 기다리는 시간을 늘렸고, 실패해도 캐시로 저자 기준 규칙을 판정할 길을 열었으며, 지연이 어디서 소진되는지 기록하기 시작했다.
- 축 2는 저자 라벨 감축과 캐시 primitive 준비다. 라벨은 규칙이 쓰는 11종만 남았고, 새 캐시는 아직 쓰이지 않는다.
- 축 2를 벗어나는 부수 변경도 있다. 추천 모델의 proto 상수와 모델 코드, 광고 로그 필드, 라벨 투명성 툴의 배치 잡, 이미지 빌더가 바뀌었다.
새로 알게 된 것A. 두 백엔드를 기다리는 시간을 390 ms 상수 하나로 통일하며 크게 늘렸다
VF의 hydration timeout이 통합 상수 하나로 바뀌었고, Strato(코드가 데이터를 조회하거나 저장할 때 거치는 X 내부 시스템) 호출의 timeout은 TES 100 ms, Gizmoduck 80 ms에서 390 ms로 올랐다 확인 —
visibility-filtering/hydration/mod.rs같은 변경이 걸친 파일:
visibility-filtering/server_deps.rs,visibility-filtering/hydration/gizmoduck_hydrator.rs,visibility-filtering/hydration/tes_hydrator.rsvisibility-filtering/hydration/mod.rs에 새 crate 내부 상수가 추가됐다:pub(crate) const HYDRATION_TIMEOUT: Duration = Duration::from_millis(390);
이 상수는 두 층에서 쓰인다. 첫째는 hydrator 층이다. TES·Gizmoduck hydrator의 클라이언트 timeout은 4세대 공개 이래 각각 로컬 상수 150 ms였는데, 이번에 둘 다 이 상수를 참조하게 됐다 확인(같은 커밋).
- 두 hydrator의
CLIENT_TIMEOUT이 이전에는 각각 로컬 상수 150 ms였고, 이제는 둘 다crate::hydration::HYDRATION_TIMEOUT을 참조한다.
- 두 hydrator의
둘째는 Strato 클라이언트 층이다. 서비스별로 하드코딩돼 있던 timeout 상수가 둘 다 이 상수 값으로 바뀌었다 확인(같은 커밋).
- 이전: TES 100 ms(
TES_STRATO_REQUEST_TIMEOUT_MS: u64 = 100), Gizmoduck 80 ms(GIZMODUCK_STRATO_REQUEST_TIMEOUT_MS: u64 = 80) - 이후: 둘 다
HYDRATION_TIMEOUT.as_millis() as u64
- 이전: TES 100 ms(
즉 Strato 호출자가 실제로 기다리는 timeout은 두 서비스에서 각각 3.9배, 4.9배 늘었다 확인(같은 커밋). VF 서비스가 하이드레이션에 훨씬 더 넉넉한 지연 예산을 쓰겠다는 결정이다.
4세대+7(
reference_compare.rs, 2026-08-24)이 도입한 verdict 대조 canary에서 timeout으로 인한 disagreement가 관측됐을 가능성이 있고 추정, 이번 상향은 그 대응으로 읽힌다.
새로 알게 된 것B. TES 조회가 실패해도 캐시에 남은 저자 id로 저자 기준 규칙을 판정한다
TES 조회에 실패한 tweet도 이제 캐시에 기록해 둔 이전 저자 id로 저자를 찾을 수 있으며, 이 폴백은 기본 활성이고 기본 용량은 1 M entries다 확인 —
visibility-filtering/hydration/tes_hydrator.rs(+234/−42)- 함께 바뀐 파일:
visibility-filtering/models/mod.rs(+49/−9),visibility-filtering/config.rs(+14)
- 함께 바뀐 파일:
이전에는 TES에서 tweet을 찾지 못했거나 조회가 실패했을 때 요청에도 저자 id가 없으면, 그 후보는 판정 자체를 건너뛰었다 확인(같은 커밋).
- TES가 tweet 하나를 조회한 결과는 세 갈래다:
Found(core_data),NotFound,Failed(error). - 이전 코드에서는 결과가
Found가 아니면 그 tweet의PureCoreData가 없었다. 이때resolve_candidate가raw.request_author_id(홈믹서가 요청에 실어 준 저자 id, 없을 수도 있다)를 찾지 못하면, 그 후보는 candidate 리스트에서 아예 빠졌다.
- TES가 tweet 하나를 조회한 결과는 세 갈래다:
이번 커밋은 이 경우를 캐시 폴백으로 보강했다.
TesHydrator에 저자 id 폴백 캐시 필드가 붙었다 확인(같은 커밋).새 필드 정의:
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_core는 이제HashMap<TweetId, PureCoreData>대신, core 데이터와 캐시로 되살린 저자 id를 함께 담은 구조체를 돌려준다 확인(같은 커밋).새 반환 타입:
pub(crate) struct PureCoreHydration { pub(crate) core: HashMap<TweetId, PureCoreData>, pub(crate) recovered_authors: HashMap<TweetId, u64>, }
조회 결과가
Found면 (tweet_id, author_id)를 캐시에 기록하고 core도 채운다.NotFound·Failed면 캐시에서 이 tweet의 이전 author_id를 조회한다 확인(같은 커밋).resolve_candidate가 저자 id를 고르는 우선순위는 요청, TES core, 캐시 순이다 확인(같은 커밋).요청이 저자 id를 직접 실어 주면 그것을 쓰고, 아니면 TES core가 있으면 거기서 뽑고, 그것도 없으면 캐시 폴백을 쓴다. 세 경로 모두에서 저자 id를 얻지 못하면 그 후보는 판정을 건너뛴다.
코드:
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())?;
그 결과, TES가 죽었거나 응답이 없어도 이 tweet의 이전 author_id가 캐시에 있으면 author-side rules는 여전히 판정된다 확인(같은 커밋).
- author-side rules는 유저의 계정 상태(suspended/deactivated/protected/erased/offboarded)와 저자 user_labels 11종이다. 11종은 아래 C에서 다룬다.
tweet-side rules는 TES core 없이는 판정할 수 없으므로 여전히 건너뛴다 확인(같은 커밋).
- tweet-side rules는 nullcast, community-only, NSFW media, takedown reason, exclusive content, edit control이다.
폴백 설정은 새 환경변수 두 개로 노출되며, 환경변수가 세팅되지 않았을 때는 활성이 기본이다 확인(같은 커밋). 프로덕션 배포에서는 opt-out으로만 끌 수 있고, 배포 순간부터 켜져 있다.
is_none_or를 써서, 환경변수가 없으면 활성이고, 값이 있으면 그 값을 파싱한다.두 환경변수를 읽는 코드:
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) }
server_deps.rs는 초기화할 때 이 두 설정값을info!필드로 남긴다 확인(같은 커밋). 이 로그는 부팅 로그에 지표별로 잡혀 있어서, 실제 프로덕션 값이 다르면 즉시 감지된다.초기화 로그:
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와 B는 사실상 한 프로젝트다. A가 timeout을 2.6~4.9배 늘려 하이드레이션이 더 오래 걸리는 것을 허용하는 대신, B는 그래도 실패할 경우의 자동 폴백 경로를 만들었다.
이 폴백은 4세대+9(2026-08-26)에서 관측한 "TesHydrator media fallback cache 통째 제거"를 뒤집는 방향이 아니다. media fallback은 TES가 반환한 media 정보를 캐싱해 재사용하는 것이었고, 이번 author-id fallback은 TES가 응답을 못 준 tweet에 대해서만 이전 값을 되살려 author-side rules만 부분 판정한다. 즉 결과의 재사용이 아니라 부분 판정의 부활이다.
이번 변경은 4세대+15(2026-09-04)의 VF hydration 대규모 단순화로 코드가 800+ 줄 순 감소해 얇아진 뒤, 남은 강건성의 빈틈을 좁힌 것으로 읽힌다.
새로 알게 된 것C. 저자 라벨을 규칙이 실제로 쓰는 11종만 담는 자료구조로 바꿨다
VF에서 저자 라벨을 담는 자료구조가 해시 집합에서 11개 라벨만 표현하는 enum과 64-bit bitset으로 바뀌었다 확인 —
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 - VF의
AuthorFeatures는 4세대 공개 이래HashSet<LabelValue>(thrift 정의 라벨 20+ 종을 담는 heap 자료구조)로 저자 라벨을 담아 왔다. - 이번 커밋은 기존
HashSet<LabelValue>를 걷어 내고, 11개 explicit variant를 갖는AuthorLabelenum과 64-bit bitset으로 좁혔다.
- 함께 바뀐 파일:
규칙에서 실제로 참조하는 라벨은 이 11개뿐이므로 이 축소는 맞다. 다만 thrift에서 오는 다른 라벨은 이제 입력 단계에서 명시적으로 버려지고 카운터에만 잡힌다.
models/author.rs는 전면 재작성됐다 확인(같은 커밋).이전 정의:
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>, }새 정의:
#[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)는 11 variant를 각 비트에 매핑한 정수 하나이고, Insert·has는 shift와 AND 연산으로 처리한다 확인(같은 커밋).새 assert 두 개가 크기를 고정한다.
AuthorFeatures는 정확히 16 바이트이고,Option<AuthorFeatures>도 16 바이트다 확인(같은 커밋).bool 필드 하나가
Optionniche로 활용된다.크기를 고정하는 assert:
assert_copy::<AuthorFeatures>(); assert_eq!(std::mem::size_of::<AuthorFeatures>(), 16); assert_eq!(std::mem::size_of::<Option<AuthorFeatures>>(), 16);이전
HashSet<LabelValue>시절에는 heap 할당과 hash bucket을 매번 잡아야 했다.
Gizmoduck 하이드레이터의 라벨 매핑은 명시적 함수 하나로 줄었다 확인(같은 커밋). thrift에서 오는
LabelValue(20+ 종의 enum) 각각이 이 매핑을 거쳐Option<AuthorLabel>로 좁혀지고,None이면 카운트만 올라간다.매핑 함수:
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, } }
새 메트릭
vf_author_labels{result="mapped"|"unmapped"}가 생겼고, 계약이 어긋나지 않게 컴파일 시 강제하는 테스트도 있다 확인(같은 커밋).테스트:
#[test] fn dashboard_generator_pins_the_author_labels_metric_name() { // ... asserts dashboard.py contains AUTHOR_LABELS_METRIC = "vf_author_labels" }
이 11개는 4세대+13(7ba7768, 2026-09-01)에서 정본에 반영된
OON_USER_LABEL_DROPS규칙 11개와 정확히 1:1이다 확인(같은 커밋). 즉 규칙에서 실제로 참조되는 라벨만 남기고 나머지는 입력에서 제거한 것이다.
⚠ 정본 내용을 다시 확인했다. services/x.md의 안전 파이프라인 라벨 목록은 이전에도 이 11개를 담고 있었다. 이번 커밋이 한 일은 그 사실을 자료구조로 고정한 것이지, 새 라벨을 추가한 것이 아니다. 다만 thrift에 정의된 다른 LabelValue(예: NSFW_TEXT_HIGH_PRECISION, NSFW_TEXT_HIGH_RECALL, NSFW_NEW_USER 등)가 hydrator 층에서 명시적으로 버려진다는 사실은 이 커밋으로 처음 확인된다.
새로 알게 된 것D. VF가 호출자의 시간 예산 안에 응답했는지를 처음으로 기록한다
VF가 요청에 실려 온 gRPC 데드라인 안에 응답했는지를 메트릭으로 남기고, 요청 시간을 두 phase로 나눠 기록한다 확인 —
visibility-filtering/filter_tweets.rs(+42)- 함께 바뀐 파일:
visibility-filtering/filter.rs(+6/−1),visibility-filtering/rules/metrics.rs - 데드라인 메트릭은
filter_tweets_deadline{outcome=within|overrun|absent}이고, 두 phase는 hydration과 post_hydration이다.
- 함께 바뀐 파일:
filter_tweets.rs::FilterTweetsEndpoint::filter_tweets가 요청 metadata의 gRPC 표준grpc-timeout헤더를 파싱한다 확인(같은 커밋).gRPC 스펙의 단위 접미사
H/M/S/m/u/n(시·분·초·밀리·마이크로·나노)을 지원한다. 숫자는 8자리 이하여야 하고, 그 밖의 형식은None이다.파싱 함수:
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)?)) }
성공 응답 시점에
RequestMetricsGuard::record_deadline(grpc_timeout)이 호출된다 확인(같은 커밋). 데드라인 안에 응답했는지, 넘겼는지, 데드라인이 없었는지를 카운터로 세고, 남은 시간과 초과 시간을 히스토그램으로 남긴다.기록 함수:
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)); } }세 카운터는
filter_tweets_deadline{outcome=within|overrun|absent}이고, 두 히스토그램은filter_tweets_deadline_remaining_ms/filter_tweets_deadline_overrun_ms다.
VF는 호출자(홈믹서)가 준 데드라인 대비 자기 지연이 얼마나 여유가 있었는지, 초과했는지, 측정할 수 없었는지를 처음으로 관측한다 확인(같은 커밋).
filter.rs::FilterTweets::run은 요청 시간을 두 phase로 나눠 잰다 확인(같은 커밋). 새 히스토그램filter_tweets_phase_ms{stage="hydration"|"post_hydration"}로, 요청 총지연이 하이드레이션과 rule engine에 어떻게 배분되는지도 처음 관측할 수 있다.두 phase를 재는 코드:
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());
A의 timeout 상향과 이 관측 추가는 함께 배포되는 것이 자연스럽다. timeout을 늘렸으면 실제로 얼마나 늘었는지 봐야 하고, 그 지연이 어느 phase에서 소진되는지 알아야 한다.
또 홈믹서가 VF를 호출할 때 요청에 gRPC 표준 grpc-timeout 헤더를 실어 주는 지금까지의 관례가 이번에 처음 코드에서 확인된다. 이전 커밋에서는 timeout이 caller-side에서만 시행됐고, VF 서비스는 그 값을 읽지 않았다.
새로 알게 된 것E. 샤드로 나눈 새 캐시 primitive가 들어왔지만 아직 쓰는 곳이 없다
VF에 32-way set-associative × 64-shard CLOCK 캐시 primitive가 새로 들어왔지만, 아직 어디에도 연결되지 않았다 확인 —
visibility-filtering/clock_cache.rs- 함께 바뀐 파일:
visibility-filtering/lib.rs visibility-filtering/clock_cache.rs는 신규 파일 446줄이고,visibility-filtering/lib.rs에pub(crate) mod clock_cache;한 줄로 등록됐다. 이번 커밋에서 이 파일을 참조하는 코드는 없다.
- 함께 바뀐 파일:
신규 파일
clock_cache.rs(446줄)로 들어온 32-way × 64-shard 캐시에는dead_code애트리뷰트가 붙어, 컴파일러 경고를 눌러 두었다 확인(같은 커밋). 이것으로 보아 아직 소비자가 없고, 다음 커밋에서 어느 hydrator가clock_cache.rs의 캐시를 소비하는지가 관측 대상이다.파일 첫머리:
#![cfg_attr(not(test), expect(dead_code, reason = "TODO: no consumer wired yet"))]컴파일러에게 "테스트가 아닌 빌드에서는 이 모듈이 죽어 있는 것을 알고 있다, 경고 내지 마라, 사유는 아직 소비자가 붙지 않았기 때문이다"라고 명시한 것이다.
용량
capacity를 인자로 받아set_count = capacity / WAYS개 set을 만들고 64개 shard로 분산한다 확인(같은 커밋). shard 하나가 여러 set을 가지고, set 하나가 32개 slot(way)을 가진다.Slot에 저장하는 값은
(key: u64, generation: u64, value: Option<V>)튜플이다.주요 정의:
const WAYS: usize = 32; const SHARDS: usize = 64; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Lookup<V> { Miss, // 키가 이 캐시에 등록조차 안 됨 NotFound, // 등록됐지만 값이 None(예: 백엔드가 NotFound를 리턴) Found(V), } pub(crate) struct ClockCache<V> { /* shards, set_count, next_generation */ }
CLOCK 알고리즘은 다음처럼 돈다 확인(같은 커밋).
- 삽입(
insert(generation, key, value)): 같은 키가 이미 있고 저장된 generation이 더 크면 건너뛴다. 오래된 값이 새 값을 덮어쓰지 못하게 하려는 것이다. 그렇지 않으면 그 자리에 덮어쓰고Mark::Referenced로 표시한다. 키가 없으면 hand가 회전하면서Referenced → Cold로 감쇠시키고,Cold나Empty를 만나면 그 자리에 삽입한다. - 조회(
get(key)): 같은 키를 찾으면Mark::Referenced로 승격하고Lookup::Found(value)를 리턴한다. 없으면Miss이고, 저장된 값이None이면NotFound다. - Generation: 요청마다
begin_request() -> u64가 monotonic 값을 부여한다. 나중에 오는 요청이 오래된 캐시 값을 덮어쓸 수 있는지를 이 값으로 판정한다.
- 삽입(
이 캐시는 기존
FallbackCache와는 별개의 primitive다 확인(같은 커밋).- 기존
FallbackCache는 4세대에서 정본에 반영된hydration/fallback_cache.rs이고, 뮤텍스 하나로 보호되는moka::sync::Cache기반 TinyLFU다.
- 기존
두 캐시는 잠금 단위, 교체 정책, 슬롯 배치, 값 타입, 동시성에서 다르다 확인(같은 커밋).
| 축 | 기존 FallbackCache (moka와 Mutex) | 새 ClockCache |
|---|---|---|
| 잠금 단위 | 캐시 전체에 Mutex 하나 | shard(64)마다 Mutex |
| 교체 정책 | TinyLFU (확률적 빈도) | CLOCK (Referenced ↔ Cold 감쇠) |
| 슬롯 배치 | 동적 hash map | 고정 32-way set-associative 배열 |
| 값 타입 | 임의 (Clone) | Copy 제약 (u64 정도 크기 값에 적합) |
| 동시성 | 한 mutex에 병목 가능 | 64-way sharding으로 대부분 non-blocking |
즉 쓰기 처리량(write throughput)과 lock contention에 훨씬 강한 새 hot-path 캐시로 읽힌다. 다음 커밋에서 어느 hydrator(TES author_id? Gizmoduck AuthorFeatures? SafetyLabel expiring cache?)의 백엔드로 연결되는지가 주요 관측 대상이다.
새로 알게 된 것F. 랭커 Phoenix의 proto에서 지면 상수 두 개와 행동 집계 유형 하나가 이름을 얻었다
Phoenix(Grok 기반 Transformer 랭커)의 랭킹 wire proto에 Trend·Explore Spotlight 지면 상수와 긴 체류 몰입형 행동의 집계 상수가 새로 생겼다 확인 —
phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto- 함께 바뀐 파일:
phoenix/python/common/xai-proto/proto/recsys.proto - 새 상수는
TREND_PAGE·EXPLORE_SPOTLIGHT두 지면 상수와IMMERSIVE_LONG_DWELLaction aggregation 상수다. - enum 값으로는
ProductSurface두 개(TREND_PAGE=16,EXPLORE_SPOTLIGHT=17)와UserActionAggregationType::IMMERSIVE_LONG_DWELL=29다.
- 함께 바뀐 파일:
ProductSurfaceenum의 16·17번이 각각 Trend 지면과 Explore Spotlight 지면의 이름을 얻었다(이전은PLACEHOLDER_16·PLACEHOLDER_17) 확인(같은 커밋). 4세대+3에서 정본에 반영된 15개 상수 뒤에 두 지면이 추가된 것이다.바뀐 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; }
실제 홈믹서 연결은 아직 없어서, 이 두 상수를 Phoenix에 넣어 랭킹시키는 코드는 이번 커밋에 등장하지 않는다 확인(같은 커밋). 지금은 proto 슬롯이 준비됐다는 신호에 그친다.
UserActionAggregationType에서는 이전PLACEHOLDER_29가IMMERSIVE_LONG_DWELL = 29로 이름을 얻었다 확인(같은 커밋).바뀐 enum:
enum UserActionAggregationType { // ... IMMERSIVE = 26; ADS_CHAD_WITH_CONV_TYPES = 27; ADS_WITH_KV_AD_HISTORY = 28; IMMERSIVE_LONG_DWELL = 29; PLACEHOLDER_30 = 30; // ... }
기존
IMMERSIVE = 26(immersive 지면에서의 상호작용 시퀀스 aggregation)과 별개로, 긴 dwell 시간을 갖는 immersive 상호작용을 별도 aggregation 유형으로 분리할 수 있게 됐다 확인(같은 커밋).이 새 aggregation을 어떤 학습 config가 소비하는지는 이번 커밋에 나오지 않는다 확인(같은 커밋).
새로 알게 된 것G. two-tower 모델의 user tower가 padding 위치에 별도 segment ID를 쓰게 됐다
two-tower(유저 쪽과 후보 쪽을 따로 임베딩하는 retrieval 모델 구조)의 user tower가 padding 위치에 별도 segment ID를 쓰도록 수정됐다 확인 —
phoenix/xrex/models/recsys_two_tower_model.py(+15/−4)- padding 위치에는
PADDING_SEGMENT_ID, 실제 history 위치에는HISTORY_SEGMENT_ID를 쓴다. 기존 all-zero에서 mask를 반영하는 쪽으로 바뀌었다.
- padding 위치에는
이전
user_tower_segment_ids로직은 두 갈래였다 확인(같은 커밋).use_history_segment_ids=True면 배열 전체를HISTORY_SEGMENT_ID로 채웠다. padding까지 history와 같은 segment로 취급하는 부정확한 방식이다.False면jnp.zeros로 모든 위치를 0으로 취급했다. RoPE(회전 위치 인코딩)·attention bias가 padding 위치에서도 계산됐다.이전 코드:
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)
새 로직은 padding_mask가 넘어오면 mask 값에 따라
HISTORY_SEGMENT_ID와PADDING_SEGMENT_ID를 섞는다 확인(같은 커밋).이후 코드:
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)호출부:
user_segment_ids = user_tower_segment_ids( B, T, use_history_segment_ids=self.config.use_history_segment_ids, padding_mask=user_padding_mask, )
이제 user tower의 attention·RoPE·attention bias는 padding 위치에서 실제 history와 구분되어 계산된다 확인(같은 커밋). 4세대+15(2026-09-04, 9b0dc31)에서 정본에 반영된 "FA4 attention 커널 가변길이 히스토리 원생 지원"과 같은 방향의 정확성 보완이다.
새로 알게 된 것H. 광고 삽입 로그에 광고 블렌더 라벨이 붙는다
홈믹서의 광고 삽입(injection) 로그에 광고 블렌더 라벨을 남기는 필드가 생겼다 확인 —
home-mixer/side_effects/ads_injection_logging_side_effect.rs(+5/−1)필드 이름은
ads_blender이고, diff는 짧다:ads_blender: match query.request_type { RequestType::Following => "following".to_string(), _ => query.params.get(AdsBlenderType), },
광고 injection 로그 payload에 붙는
ads_blender값은 요청 유형에 따라 정해진다.RequestType::Following이면 하드코딩된"following"을 쓰고, 그 밖의 지면에서만AdsBlenderTypefeature-switch param을 조회한다 확인(같은 커밋).4세대+10(2026-08-28, 24c6094)이 도입한 광고 블렌더 다섯 개에 이어, 팔로잉 지면의 별도 블렌더 라벨
"following"이 로깅 축에 정식으로 들어온다 확인(같은 커밋).- 다섯 블렌더는
safe_gap·partition_organic·partition_organic_low_risk·time_gap·multi_risk다.
- 다섯 블렌더는
이것이 여섯 번째 블렌더인지, 아니면 팔로잉 지면은 다섯 블렌더 밖에서 별도 파이프라인을 타므로 로깅에만 라벨이 잡히는 것인지는 이번 diff만으로는 확정할 수 없다 추정. 후자가 코드 흐름과 더 어울린다 추정. param 조회를 아예 건너뛴다는 것이 서로 다른 파이프라인이라는 신호로 읽힌다.
새로 알게 된 것I. 라벨 투명성 툴(UTH)에 국가별 계정 takedown 일일 배치가 생기고, 라벨 집계에 국가 필드가 붙었다
Under-The-Hood(UTH, X의 라벨 투명성 툴) 데이터 파이프라인에 국가별 계정 takedown 일일 배치 잡이 새로 생겼다 확인 —
under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala- 잡 이름은
UthPctdAccountTakedownEventsJob이고, 신규 배치 잡은 143줄이다.
- 잡 이름은
UTH 라벨 aggregate에는
countryCodes필드가 붙었다 확인 —under-the-hood/thrift/uth_serving.thriftunder-the-hood/는 X가 x.com/i/under_the_hood로 노출하는 유저 개인 계정·포스트 라벨 투명성 툴의 백엔드다 확인(같은 커밋).배치 잡은 두 소스를 조인한다 확인(같은 커밋).
AuditServicePctdActionsScalaDataset은 PCTD(post·content takedown) action 로그다. 계정 유형이고, takendown에 성공했고, country가 있는 것만 남긴다:.filter { a => a.type == PctdActionType.User && a.takendown && a.countryCode.nonEmpty }.UsersourceScalaDataset은 user 스냅샷의user.takedowns.takedownCountryReasons다. reason은TakedownReason::LegalRequest → "LEGAL_REQUEST",BystanderReport → "BYSTANDER_REPORT",Dmca → "DMCA"(country는"xy"로 하드코딩, 국가 정보 없음),UnspecifiedReason → "UNSPECIFIED"로 바뀐다.
같은 (userId, country)에 reason이 여러 개 잡히면 우선순위 값이 낮은 것을 남긴다 확인(같은 커밋).
우선순위 표:
private val ReasonPriority = Map( "LEGAL_REQUEST" -> 0, "BYSTANDER_REPORT" -> 1, "DMCA" -> 2, "UNSPECIFIED" -> 3 )
결과는
daily_account_takedowns/YYYY/MM/DD파티션 파케이에UthAccountTakedownEvent(userId, day, reason, country)로 쓴다 확인(같은 커밋).- 기본 reducers는 50, 기본 usersourceSnapshotMaxAgeDays는 10, Cross-DC 스냅샷 접근(
AllowCrossDC)은 허용한다. - 테스트 유저 필터(
TestUserFilter.isTestUserId)로 test 계정을 뺀다.
- 기본 reducers는 50, 기본 usersourceSnapshotMaxAgeDays는 10, Cross-DC 스냅샷 접근(
동시에
uth_serving.thrift에서 두 스트럭트가 국가 필드를 얻는다 확인(같은 커밋).바뀐 스트럭트:
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 }
정리하면 UTH 투명성 툴은 두 가지를 새로 갖춘다 확인(같은 커밋).
- 매일 계정별로 어느 나라에서 언제 어떤 reason으로 takedown되었는지의 이벤트 로그를 자체 데이터셋으로 갖게 된다.
- label aggregate에 국가 코드 리스트를 노출할 준비가 됐다.
이 변화는 4세대+12(2026-09-01, 6384ca7)에서 NSFW 게이팅 대상 16개국이 코드에 처음 명시된 것과 짝을 이룬다. 어떤 라벨이 어떤 나라에서 활성이었는지를 유저가 UTH에서 볼 수 있게 하려는 계획으로 읽힌다. UTH 화면에 국가별 필터나 표시가 등장하는지 지켜본다.
새로 알게 된 것그 밖의 변경
- Safety-label expiring cache가 초기화할 때 쓸 capacity를 미리 예약해, 첫 삽입에서 재할당을 피한다 확인 —
visibility-filtering/safety_label_source/expiring_cache.rsExpiringCache::with_clock이 초기화 시cache.reserve(capacity)를 호출한다.
- hydration 메트릭 코드에서 죽은 keyed-result 경로가 제거되고, 저자 라벨 집계 헬퍼가 새로 붙었다 확인 —
visibility-filtering/hydration/metrics.rshydration/metrics.rs+21/−154:timed_keyed_rpc와KeyedResultCounts::from_results·outer_timeout이 제거됐다.- 4세대+15(9b0dc31, 2026-09-04)의 batch 통일 이후 이 keyed-result 경로는 죽은 코드였다.
- 새 헬퍼는
record_author_labels(mapped, unmapped)다.
- grox(Grok 기반 nearline 분류기 서브레포)의 motion-reveal 썸네일 생성이 프레임을 더 많이 받고, 정합·denoise·색채 검증을 단계별로 통과시키도록 정제됐다 확인 —
grox/libs/video_tools/image.py(+288/−25)- 썸네일 생성은 video에서 single frame 하이라이트를 만드는 일이다. 이를 맡는 motion-reveal 이미지 빌더(video → 하이라이트 프레임 생성기)에 ECC affine 정합과 Farneback optical flow refinement 두 단계가 추가됐다.
- 바뀐 점을 모으면 32 → 48 프레임 상한, ECC affine 정합, Farneback optical flow refinement, weighted median, LAB 스킨 색상 plausibility 게이트다.
_MOTION_REVEAL_MAX_INPUT_FRAMES가 32→48로 바뀌었고, 새 헬퍼가 여섯 개 생겼다._ecc_align:cv2.findTransformECC로 affine 변환을 정합한다._is_static:cv2.phaseCorrelate로 정적 프레임을 판정한다. 이동량과 phase correlation response가 둘 다 임계 이하면 정적이다._dense_align_residual: Farneback dense optical flow로 residual을 미세 정합한다._weighted_median: 정합 후 shift한 오버레이의 pixel별 가중 중앙값을 구한다._native_chroma: LAB 색공간에서 밝은 픽셀 상위 30%의 중앙 chroma 좌표를 구한다._skin_plausible: 오버레이의 chroma 중앙이 스킨색 축에서 거리 12 이하인지 보는 게이트다. false면 skin overlay라는 오탐을 막는다.
- 4세대+12(2026-09-01)에서 정본에 반영된
PlanSafetyPtosSpecialVideoDAG가 이 motion-reveal 프레임을 소비하므로, 이번 정제는 그 DAG의 판정 품질에 직접 영향한다 추정.
home-mixer/params/param.rs의 feature-switch defaults 미러는 timestamp가2026-09-09T16:23:17Z→2026-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
정본 반영 services/x.md ## 안전성 절에 ### 4세대+19 소절을 새로 만들어 A~J 열 개 축을 적었고, 문서 머리의 최종 갱신을 2026-09-11에서 2026-09-12로 옮겼다. B(AuthorId fallback)와 D(gRPC deadline 관측)는 안전 파이프라인의 새 데이터 포인트라서 정본에 명시적으로 반영했다. F(TREND_PAGE·EXPLORE_SPOTLIGHT·IMMERSIVE_LONG_DWELL)와 G(padding segment ID)는 랭킹 wire와 학습 설정의 변화라서 함께 반영했다. E(clock_cache primitive)는 아직 연결되지 않았지만, 다음 커밋에서 소비자가 붙을 것으로 예상되어 명시적으로 문서화했다.
의미 이번 커밋은 timeout을 늘려 판정 성공률을 높이는 동시에 부분 판정 경로를 열어, fail-open(판정에 실패하면 통과시키는 방식) 대신 partial-open으로 강건성을 얻는 방향이다. 4세대 공개 이래 이어진 visibility-filtering 강건성 개선 흐름의 연장선에 있고, 이번 4세대+19는 그 위에 timeout 예산 상향, fallback 캐시, 라벨 자료구조 축소, phase 관측을 더했다.
지금까지 이 흐름의 주요 이정표는 다음과 같다.
- 4세대+4(2026-08-19,
dark_traffic_setup.rs)의 shadow deployment 미러링 - 4세대+7(2026-08-24,
reference_compare.rs)의 verdict 대조 canary - 4세대+13(2026-09-01, 7ba7768)의 rules 재구조화
- 4세대+15(2026-09-04, 9b0dc31)의 hydration 800줄 순 감소 단순화
⚠ 갱신할 주장은 없다. 이번 diff가 뒤집는 이전 정본 진술은 없고, VF가 fail-closed라는 canon(이 저장소의 정본 문서) 주의 목록 #1은 여전히 유효하다. hydration 실패로 판정하지 못한 tweet은 여전히 건너뛰며, author fallback은 건너뛰던 것을 부분 판정으로 바꿀 뿐이다.
레이더
Kuaishou의 캐스케이드 추천 논문 한 편을 radar.md의 "이번 달 신규"에 추가했다. 논문이 제안한 UniRec은 캐스케이드 추천의 pre-ranking과 ranking 단계 fusion을 두 stage의 joint learning으로 정렬하는 방법이고, Kuaishou 프로덕션 A/B에서 app usage duration을 +0.616% 높였다 확인 — arXiv 2609.11052, 2026-09-10 제출.
- 다루는 문제는 산업용 추천의 캐스케이드 stage(pre-ranking, ranking)가 각기 다른 objective·feature space·latency 제약 아래에서 따로 최적화되는 것이다 확인(같은 논문). upstream은 downstream이 선호하는 아이템을 걸러 내고, downstream fusion은 upstream 개선을 상쇄한다.
- 두 fusion agent가 input embedding을 일부 공유하고 single computation graph에서 함께 학습되어, gradient가 stage 사이를 흐른다 확인(같은 논문).
- Dual-axis preference alignment는 두 방향으로 작동한다 확인(같은 논문).
- 세로(vertical): downstream pairwise preference를 upstream fusion score에 전이한다.
- 가로(horizontal): 이질적 prior signal에 대한 수십 개 pairwise objective를 bidirectional preference evidence로 재편한다.
- 또 attribute group-relative regularization으로, 제약 없는(unconstrained) end-to-end fusion이 특정 attribute 그룹에 과집중하는 부작용을 억제한다 확인(같은 논문).
- 21일 프로덕션 A/B에서 앱 사용시간이 +0.616% relative gain을 보였고, Kuaishou 플랫폼에 fully deployed됐다 확인(같은 논문).
다음에 볼 것
다음 커밋에서는 새 캐시와 늘어난 timeout이 실제로 어떻게 쓰이는지, 새 proto 상수와 투명성 툴의 국가 필드가 어디에 연결되는지를 본다.
clock_cache를 실제로 쓰는 곳을 확인한다 — TES author_id, Gizmoduck AuthorFeatures, SafetyLabel expiring cache 셋 중 어느 것이 이 새 primitive로 바뀌는지 다음 커밋에서 본다.fallback_cache.rs가 clock_cache로 대체되는지, 별개 hot-path에 붙는지도 관측 대상이다.HYDRATION_TIMEOUT390 ms가 실제로 어떻게 작동하는지 본다 — 새filter_tweets_phase_ms{stage="hydration"}히스토그램과filter_tweets_deadline_overrun_ms가 프로덕션 대시보드에 나타나면 실제 P50·P99 지연 분포와 overrun 발생률을 알 수 있게 된다. 홈믹서가 VF에 실어 주는 gRPC deadline 값 자체가 코드로 확인되면 좋다. 지금은grpc-timeout헤더 파싱 코드만 확인했고, 실제 값은 공개되지 않았다.TREND_PAGE·EXPLORE_SPOTLIGHT지면이 어디에 연결되는지 본다 — proto 슬롯이 열렸으므로 홈믹서에 이 지면들의 gRPC 엔드포인트나 후보 파이프라인이 다음 커밋에서 등장할 가능성이 있다.xrecsys_search(4세대+4에서 신설된 검색 랭킹 config)와의 관계도 관측 대상이다.IMMERSIVE_LONG_DWELL이벤트가 어디서 정의되는지 본다 — grox나 홈믹서에서 immersive 지면의 dwell 시간 임계로 이 aggregation을 만드는 코드가 어디에 나타나는지 확인한다. 학습 config에서 이 aggregation을 소비하는지도 본다.- UTH 화면에 국가별 takedown 정보가 나타나는지 본다 — x.com/i/under_the_hood에 국가별 필터나 라벨의 국가 표시가 등장하는지 지켜본다. 4세대+12의 NSFW 16개국 코드 명시와 이번 UTH 국가 필드 확장이 유저 화면에서 어떻게 합쳐지는지가 다음에 볼 점이다.
Action::Downrank의 실제 사용처를 계속 찾는다 — 4세대+18 커밋이 남긴 열린 질문이고, 이 커밋에도 여전히 등장하지 않는다.- header-image-prompt-ad가 광고 블렌더에 들어가는지 본다 — 4세대+18 커밋이 남긴 열린 질문이다. 이번 커밋의
ads_blender라벨링 확장(H)이 관련 조각이다.following라벨이 명시적으로 특수 경로를 탄다는 사실은 새 광고 포맷도 지면별 블렌더 분기를 탈 가능성을 시사한다 추정.
조회 실패: 없음.