2026-10-20 커밋(74d49a4, 4세대+45) 기준으로 X For You는 인네트워크 최신 포스트를 Thunder에서, OON 후보를 Phoenix retrieval, SimClusters, 2026-10-07부터 기본 on인 인기 포스트 소스에서 모으고, 레거시 tweet-mixer와 SID retrieval 소스는 기본 off다. Grok 기반 트랜스포머 Phoenix가 후보마다 행동별 참여 확률을 예측하고, 2026-09-24부터 공유 crate xai-value-model이 이 확률의 가중합에 작성자 다양성 감쇠와 OON 배수를 적용한다. 필터는 스코어링 전 20개와 선택 뒤 5개이고, 정렬된 후보는 VMRanker DPP 슬레이트 재정렬(θ=0.65, K=150, topic 요청은 생략)과 광고·팔로우 추천 블렌딩을 거쳐 나간다.
완전 재작성. 1·2세대와 코드 중복 0. 총 216파일(초기 79개, e414c17에서 137개 추가와 50개 수정(grox 59개 포함), 0bfc279는 LFS 포인터 1개 교체)
4세대
같은 레포 @ 47c1bcd(.gitattributes 정리는 a389166)
2026-08-13
Rust/Python
+363,246/−11,640 (파일 300+). 가중치 params 미러 신설, visibility-filtering·labeling 서브레포 15개 신설, phoenix/ 학습 코드와 합성 데이터 포함, SimClusters 부활, docs/BIDIRECTIONAL_BOOST_CHANGE.md로 파라미터 diff를 문서화하는 관행 도입
2026-09-10에 git 로그를 직접 확인했을 때 xai-org/x-algorithm의 커밋은 18개(전일 대비 +1: 49815da)였고, twitter/the-algorithm은 커밋 31개로 변화가 없었다 확인.
이 노트들은 4세대 공개(47c1bcd, 2026-08-13) 뒤에 나온 xai-org/x-algorithm 커밋에 4세대+1부터 차례로 번호를 붙여 부른다. 커밋별 상세 기록은 파일 끝 변경 이력에 있다.
네 가지 사실이 특히 중요하다.
1세대와 2세대 사이에 26개월 공백이 있다. 2023-07-07 이후 2025-09-03까지 커밋이 하나도 없다 확인.
2세대는 아무것도 지우지 않았다. 삭제 0건, 리네임 0건이다. 2023 파일 집합 6,906개는 2025 파일 집합 7,667개의 진부분집합이다. 즉 cr-mixer/, navi/, ann/, simclusters-ann/, src/, product-mixer/ 등은 2023-04 이후 한 바이트도 바뀌지 않은 채 그대로 남아 있다. 2025 커밋이 건드린 최상위 경로는 home-mixer/, tweet-mixer/, README.md뿐이고, 파일 수는 각각 603개, 384개, 1개다 확인.
확인에 쓴 명령은 git show --name-only --pretty='' c54bec0 | awk -F/ '{print $1}' | sort | uniq -c다.
레포에 파일이 있다고 해서 지금 쓰이는 코드라는 뜻은 아니다. 폐기는 파일 삭제가 아니라 README에서 그 줄을 빼는 방식으로만 드러난다. cr-mixer가 바로 그런 경우다. 2025 커밋의 유일한 문서 변경은 README의 cr-mixer 행에서 문구는 그대로 두고 서비스명만 tweet-mixer로 바꾼 것이다 확인 — git show c54bec0 -- README.md.
3세대는 별개 레포다.xai-org/x-algorithm의 초기 3커밋(aaa167b→e414c17→0bfc279)은 코드를 다시 쓰고 grox/를 추가해 216파일에 이르렀다 확인.
이 문서가 주로 다루는 피드 지면은 알고리즘이 추천하는 For You다. For You 안의 세로 영상 캐러셀은 2026-10-08 커밋(4세대+38)에서 기본 off로 신설됐다 확인(변경 이력의 4세대+38). 2026-10-15 커밋(4세대+42)에서 캐러셀 정렬 점수에 immersive 지면 모델의 VQV 예측 항이 더해졌지만, 그 가중 기본값이 0.0이라 기본 동작은 그대로다 확인(변경 이력의 4세대+42). 2026-10-17 커밋(4세대+44)부터는 캐러셀 안의 중복 제거 세 가지가 기본 on이고, 같은 응답에 Who-to-Follow 모듈이 있으면 캐러셀을 넣지 않는다 확인(변경 이력의 4세대+44). 2026-10-20 커밋(4세대+45)부터는 메인 피드 상위 35개에 이미 든 영상과 같은 미디어를 캐러셀에서 빼고, 캐러셀 자체는 이 커밋 기준으로도 기본 off다 확인(daily 2026-10-21). Thunder에 구현된 비디오 전용 경로는 공개되지 않은 별도 지면(Reels 상당)이 켜서 쓴다 추정.
For You는 이 문서의 주 대상인 알고리즘 추천 피드이고, gRPC 메서드는 get_for_you_feed와 get_for_you_feed_urt다 확인 2026 — home-mixer/server.rs:273-346
이전에는 For You와 Following이 같은 VFCandidateHydrator를 썼다.
새 hydrator는 Following 파이프라인에만 연결된다.
should_drop_ancillary 헬퍼는 기존 파일에서 pub(crate)로 재사용한다.
새 hydrator는 VF 조회 결과(visibility_reason, drop_ancillary_posts)만 반환하고, 나머지 후보 필드는 ..Default::default()로 재설정한다.
즉 Following 파이프라인은 하이드레이션 산출이 VF 결과 두 개로 줄고, 나머지 필드는 다른 하이드레이터가 채우는 구조로 분리됐다.
VF의 SafetyLevel은 TimelineHome 그대로다. Following과 For You가 안전 레벨은 공유한다.
의미: 이 커밋으로 For You와 Following의 하이드레이션 필드 세트가 명시적으로 갈렸고, 이는 Following에서 쓰지 않는 후보 필드에 로직과 리소스를 들이지 않으려는 최적화 방향이다 추정
토픽 피드는 topic_ids가 붙은 요청이고, 토픽 id가 7개 이상(> 6)이면 "bulk topic request"로 취급해 후보 소스가 달라진다 확인 2026 — home-mixer/models/query.rs:201-203
비디오 지면: Thunder에는 비디오 전용 ring buffer와 is_video_request 경로가 완전히 구현돼 있지만, 공개된 유일한 호출자는 is_video_request: false로 하드코딩한다 확인(코드) — thunder/thunder_service.rs:287
다른 근거 위치는 home-mixer/sources/thunder_source.rs다.
구현이 다 갖춰져 있고 stub이 아니므로, 공개되지 않은 별도 지면(Reels 상당)이 이 플래그를 켜서 쓴다 추정.
후보 생성(retrieval)은 랭킹에 넘길 포스트를 여러 소스에서 모으는 단계다. 2026 파이프라인은 소스들을 병렬로 돌려 결과를 그대로 이어 붙이고, 인네트워크와 OON 사이의 할당 비율 대신 소스별 상한을 둔다 확인(후보 배분 절). 2026-10-07 커밋(4세대+37)부터는 오프라인 잡이 미리 만든 인기 포스트 목록을 내는 소스가 기본 on이다 확인(변경 이력의 4세대+37). 2026-10-02~03 커밋(4세대+34~35)이 공개한 SID generative retrieval 소스는 기본 off다 확인(4세대+34, 4세대+35).
현행 (2026-08 4세대)
4세대 공개 기준으로 For You의 후보 소스는 내부 Phoenix 파이프라인 소스 7개와 외부 For You 파이프라인 소스 5개, 모두 12개다 확인. 근거는 home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs, for_you_candidate_pipeline.rs, 그리고 home-mixer/params/param.rs의 EnableXxxSource 기본값이다.
같은 기준에서 기본 on으로 확인된 retrieval 소스는 인네트워크 최신 포스트를 내는 Thunder와, OON 후보를 내는 Phoenix retrieval·SimClusters다 확인. 표의 SID 소스와 인기 포스트 소스는 그 뒤 2026-10-02~03 커밋(4세대+34~35)에서 추가됐다 확인(아래 세부). 표의 값은 4세대 공개 기준이지만, 이 두 소스의 행과 PhoenixSource의 cold 슬라이스 상한은 그 뒤 커밋에서 추가된 항목이다.
4세대+35에 있던 PopularPostsTopAuthors·PopularPostsMaxPerAuthor·PopularPostsMaxRepliesRepostsPerAuthor는 삭제됐고, 잡 CLI 인자(--popular-posts-per-author=5, --popular-posts-budget=500, --popular-posts-half-life-hours=8)로 옮겨졌다.
저장 포맷 v1은 13바이트 헤더 뒤에 포스트마다 24바이트(post_id·author_id·quality)가 붙는 구조다.
Thunder는 2026에 새로 만든 인네트워크 인덱스다. Earlybird 검색 인덱스를 대체한다 추정. 근거는 역할이 같고 Earlybird 참조가 사라졌다는 점이다.
포스트는 DashMap<post_id, LightPost>에 담고, 작성자마다 ring buffer 3개(원본, 답글·리트윗, 비디오)를 둔다 확인 — thunder/posts/post_store.rs:20-53
들어 있는 필드는 post_id, author_id, created_at, in_reply_to_*, is_retweet, is_reply, source_*, has_video, conversation_id뿐이다.
Thunder 안의 랭킹은 일부러 recency 정렬 한 줄로 뒀다. Phoenix가 어차피 다시 매기기 때문이다 확인 — thunder/thunder_service.rs:334-339
기본 보존 기간은 2일이다 확인 — post_store.rs:521-526
과부하가 걸리면 요청을 큐에 넣지 않고 즉시 거절한다확인 — thunder_service.rs:160-171
try_acquire가 실패하면 resource_exhausted가 된다.
답글 노출 규칙은 코드에 명시된 제품 정책이다 확인 — post_store.rs:291-315
부모가 원본이면 통과한다.
부모가 원본이 아니면, 부모가 대화 루트에 대한 직접 답글이면서 답글 대상이 내 팔로잉일 때만 통과한다.
부모가 스토어에 없으면 그냥 버린다. 그래서 2일 지난 스레드 루트가 만료되면 그 밑 답글이 전부 안 보인다.
자기 자신의 리트윗은 hot path의 한 줄로 막는다 확인 — post_store.rs:287-289
2023 세대에서 살아남은 retrieval은 tweet-mixer와 SimClusters다. tweet-mixer는 기본 off로 미러링돼 있고, SimClusters는 부활했다. ⚠ 주장 변경 (2026-08-13): canon은 오랫동안 "SimClusters, TwHIN, UTEG/GraphJet, FRS, Earlybird, Lists, Communities — 이 이름들은 home-mixer/ 어디에도 안 나온다"고 명시해 왔다. 4세대 공개는 simclusters/ 서브레포를 홈믹서 OON retrieval 소스로 다시 들여왔다(EnableSimclustersSource=true 기본 on). TwHIN/UTEG/GraphJet/FRS/Earlybird/Lists/Communities는 4세대에도 없다 확인 — home-mixer/params/param.rs, README.md #candidate-sources
SimClusters 코어 알고리즘의 세부(SVD 파이프라인, k, 인덱스 형식, 업데이트 주기)는 심층 분석이 필요하다. simclusters/ 서브레포는 파일 목록만 확인했고, 개별 파일은 읽지 않았다. 심층 분석이 필요한 항목은 daily/2026-08-15.md의 목록에 있다.
후보 배분 — 비율이 아니라 소스별 상한
"인네트워크 50% / 아웃오브네트워크 50%"는 할당량이 아니다.
2026 파이프라인에는 allocator도, ratio 파라미터도, 전역 상한도 없다 확인 — candidate-pipeline/candidate_pipeline.rs:257-272
소스들을 join_all로 병렬 실행하고, 결과를 그냥 이어 붙인다.
에러가 난 소스는 조용히 사라진다.
각 소스가 FeatureSwitch 파라미터에서 읽는 max_results 값은 4세대 공개(2026-08-13)에서 프로덕션 크론 미러로 공개됐다확인 — home-mixer/params/param.rs
ThunderMaxResults = 1200
PhoenixMaxResults = 1000
TweetMixerMaxResults = 800. 단 EnableTweetMixerSource = false로 기본 off다.
PhoenixMOEMaxResults는 해당 param을 확인해야 한다.
2026 파이프라인에서 인네트워크와 OON의 비중을 조절하는 수단은 retrieval이 아니라 스코어링의 배수다 확인 — home-mixer/scorers/ranking_scorer.rs:220-239, 272-275
let final_score = match c.in_network {
Some(false) => after_diversity * effective_oon,
_ => after_diversity,
};
4세대 공개(2026-08-13) 기준으로 X For You의 랭킹 모델 Phoenix는 후보 포스트마다 좋아요, 답글, 신고 같은 행동이 일어날 확률을 예측한다 확인(Phoenix 절). 같은 공개 기준으로 점수는 이 확률에 행동별 가중치를 곱해 더한 값에서 출발하고, 오프셋을 거친 뒤 작성자 다양성 감쇠, OON 배수, 새 작성자 부스트를 차례로 적용한다 확인(Phoenix 절, 새-작성자 부스트 절). 2026-09-24 커밋(4세대+28)부터 이 계산은 공유 crate인 xai-value-model이 맡고, 홈믹서의 랭킹 스코어러 파일은 삭제됐다 확인(Phoenix 절). 같은 공개의 param에서는 VMRanker의 DPP 슬레이트(사용자에게 함께 내보내는 후보 묶음) 재정렬도 켜져 있다 확인(Phoenix 절의 VMRanker 표). 가중치 값은 이 공개부터 프로덕션 기본값을 매일 옮겨 오는 크론 미러로 레포에서 읽을 수 있고, 2026-10-14 커밋(4세대+41)까지 일부 미러 값이 바뀌었다 확인(가중치 절, 변경 이력의 4세대+41).
체류 시간을 다루는 click dwell time 헤드와 dwell time 헤드는 공개 뒤에 학습 방식이나 랭킹 항이 바뀌었다 확인(변경 이력의 4세대+22, 4세대+23, 4세대+38).
2026-09-16 커밋(fad2f71, 4세대+22)에서 click dwell time 헤드가 sigmoid BCE(threshold=10s) 이진 분류로 학습되기 시작했다 확인(변경 이력의 4세대+22). 그 전의 공개 학습 코드에는 이 헤드의 손실 설정이 없었다 확인 — 2d4a03c phoenix/xrex grep.
4세대 공개(47c1bcd)부터 있던 EnableClickDwellLowFavRatePenalty가 전부 삭제됐다(param.rs:387-416). 이 heuristic은 baseline·alpha·floor·cap 4개 튜닝 param과 (fav/baseline)^alpha를 써서, fav가 저조할 때 dwell 예측을 낮췄다.
그 자리에 새 파라미터 EnableCdwellOnImpr(기본 off)가 들어왔다. 켜면 click_dwell_term = click_dwell_time × click_score로 계산한다. click dwell 항을 임프레션당 기대 dwell로 재정의한 것이다.
새로 켜진 CREAD 손실은 체류 시간을 로그 간격 임계 30개(2초~상한) 초과 여부의 이진 분류로 학습한 뒤, 기댓값으로 복원한다.
홈 기본 학습 config에서 CREAD 손실 두 개가 각 0.1로 켜졌다. 상한은 일반 지면 120초, 갤러리/몰입형 지면 240초다.
같은 config에서 Tweedie(0.1)·MAE(0.1) 가중치는 0이 됐다.
서빙 헤드 이름과 랭킹 가중치(ContDwellTimeWeight)는 그대로다.
README 카테고리에는 없지만 점수에 기여하는 param이 2개 더 있다. 총 유효 weight param은 26개다.
QuotedVqvWeight는 인용 포스트의 video quality view 가중치다. README 카테고리에는 없으나 param은 존재한다.
PostUnexploredWeight는 새 작성자 부스트다. 자세한 내용은 새-작성자 부스트 절에 있다.
canon이 오래 관찰한 "레포 안에서 헤드 수가 세 가지로 갈린다(공개 체크포인트 19 / 랭킹 스코어러 22 / VMRanker proto 21)"는 3세대까지의 사실이다. 4세대 README가 문서 기준으로 24로 명시하고, home-mixer/params/param.rs의 weight 심볼도 25(prediction head) + 1(post_unexplored heuristic)로 정리됐다. 다만 3세대에서 관찰됐던 candidate_pipeline/candidate.rs의 죽은 19필드 PhoenixScores 구조체가 4세대에서 정리됐는지는 심층 분석이 필요하다. 확인(문서) / 추정(코드 정리 여부)
영상 길이와 쿼리의 유저 팔로워 수에 따라 vqv 가중치를 0으로 바꾸는 heuristic은 4세대 공개부터 레포의 home-mixer/util/candidates_util.rs에 있다 확인 — candidates_util.rs:19-60.
관련 식별자는 vqv_weight, quoted_vqv_weight, MinVideoDurationMs 임계다. vqv_weight는 영상 길이가 MinVideoDurationMs를 넘고 query.user_features.follower_count가 10,000 이상이 아닐 때 가중치를 그대로 쓴다. 팔로워 수가 비어 있어도 가중치를 쓴다(candidates_util.rs:4, 25-38).
xDS 호출이 실패하면 DNS 경로로 다시 시도하지 않는다. 2026-09-24 커밋(4세대+28)부터는 이 값과 상관없이 RPC 실패 시 로컬 가중합을 내보낸다(아래 문단)
VMRankerClusterId
"Experiment1" (2026-10-06, 4세대+36부터)
서빙 클러스터. 크론 미러 기준 값이다. 실험 클러스터 이름만 바뀌었고, 무엇이 다른지는 공개되지 않았다
VMRankerSendHeadWeights
false
VMRanker에 헤드별 가중치를 보내지 않는다
VMRankerClusterId는 "Experiment3"에서 출발해 중간에 여러 번 바뀌었다. 4세대+35에서 "Experiment6", 4세대+36(2026-10-06)에서 "Experiment1"이 됐다 확인.
2026-09-24 커밋(4세대+28)에서 DPP 설정은 홈믹서 param.rs에서 빠져 vm-ranker/params.rs로 옮겨졌다. 2026-10-17 커밋(862a123) 기준으로 그 파일의 값은 DppEnabled=true, DppTheta=0.65, DppMaxSelectedRank=150, TopicDppEnabled=false다 확인 — vm-ranker/params.rs:235-248.
canon이 콜드스타트 절에서 "신규 아이템: 2026 코드에 아무 장치가 없다"고 관찰했던 것은 3세대까지의 사실이다. 4세대에서 새 작성자(계정 단위) 부스트가 문서화됐다. 4세대 공개 기준으로는 개별 새 포스트(아이템 단위)에 붙는 부스트가 없었고, hash embedding 충돌 문제도 그대로였다 확인. 그 뒤의 포스트 단위 장치는 콜드스타트 절에 있다.
가중치 — 4세대 릴리스로 값이 공개됐다 (⚠ 주장 대량 변경, 2026-08-13)
결론 (2026-08-15 갱신): 가중치 값은 프로덕션 크론 미러로 공개됐다. 미러 파일은 home-mixer/params/param.rs다. 이전 판의 결론 "가중치 값은 어느 세대에서도 1차 출처가 없다"는 4세대 공개로 뒤집혔다. 이 절이 이 문서에서 제일 중요하다.
새 결론은 프로덕션 primary value를 매일 sync한 미러가 존재하고, 마지막 sync 시각은 파일 헤더에 명시된다는 것이다.
"To help people understand the production defaults, we run cron scripts that set the defaults in this repository's code to be the primary production values"
엔게이지먼트 헤드 가중치는 아래 표와 같다(파일 등장 순, 2026-08-12 sync) 확인.
"These weights reflect a combination of how much an action is valued in ranking and typical propensities of these actions across the X network (e.g. negative feedback is overall rare)."
즉 report −234의 크기는 "리포트 한 번의 상대 중요도"가 아니라 가치 × 1/propensity의 곱이다. 여기서 propensity는 그 행동이 X 네트워크 전체에서 보통 얼마나 일어나는지를 뜻한다. 리포트가 워낙 드물기 때문에 큰 절대값이 붙어 있다.
"Each weight multiplies the predicted probability of that action (P(favorite), P(repost), …) or a continuous value e.g. watch time -- the weights do not multiply raw engagement counts. One common misinterpretation is that you can read these weight ratios as count equivalences, e.g. the incorrect statement that 'one report cancels 468 likes' -- this is incorrect because the weights apply to the predicted probabilities rather than raw counts. And the baseline probability of a Report is more than 1000x lower than a Like, so it's weighted more to allow the prediction to affect the final ranking at all."
— home-mixer/params/param.rs:283-292
같은 주석은 "mass reporting으로 도달이 균일하게 억눌린다"는 오해도 두 가지 이유로 부정한다 확인 — 같은 주석 블록.
스코어는 당신의 예측 확률에 곱하므로, 봇 계정의 리포트는 그 봇들과 유사한 유저의 추천에 주로 영향을 준다(개인화) 확인 — 같은 주석.
Home Timeline에 서빙된 포스트에서 이뤄진 액션만 랭킹 신호로 반영된다. 그룹채팅으로 링크를 공유해서 나타난 반응은 랭킹에 영향을 주지 않는다 확인 — 같은 주석.
가중치 미러의 caveat 다섯 가지
이 미러를 읽을 때 주의할 점이 다섯 가지 있다.
크론 sync는 하루 단위로 따라가는 미러라 시차가 있다. 헤더에 적힌 sync 시각 이후의 프로덕션 변경은 아직 반영되지 않았을 수 있다. 확인
부호와 크기는 propensity를 감안해 해석해야 한다. 파일 자체 주석이 "값 = 가치 × 1/propensity"라고 명시한다. 절대값 비율(report −234 vs favorite 0.5의 468배)이 그대로 상대 중요도가 아니다.
2026-09-23 커밋(4세대+27)부터 가중치 미러가 두 파일로 나뉘었고, 2026-10-17 커밋 기준으로 두 파일의 가중치 값이 서로 다르다.
4세대+27에서 56개 파라미터를 담은 vm-ranker/params.rs가 신설되면서, 홈믹서 프로세스용인 기존 home-mixer/params/param.rs와 함께 미러가 둘이 됐다 확인.
다음날(4세대+28, 2026-09-24) 홈믹서 param.rs에서 가중치가 아닌 랭킹 파라미터 22개가 삭제됐다확인 — 1b3fec20과 44d37eb의 param.rs 비교.
삭제된 파라미터는 author diversity·OON·new-user·weight perturbation·VMRanker DPP·value model 스위치 계열 등이고, 가중치는 ContActiveSecs5mResidualNormWeight 하나뿐이다.
행동별 가중치는 두 파일에 모두 남았고, 2026-10-17 커밋(862a123) 기준으로 favorite·video_open·cont_dwell_time·post_unexplored·not_interested 다섯 개 값이 서로 다르다 확인 — home-mixer/params/param.rs, vm-ranker/params.rs.
phoenix 학습 hyperparameter와 botmaker 규칙 상수는 여전히 레포 밖에 있다. 3세대 canon이 레포 밖이라고 본 crate::util은 4세대 공개부터 home-mixer/util/로 공개돼 있다 확인 — home-mixer/lib.rs.
Phoenix 학습 코드는 "학습 가능한" 것이지 "프로덕션과 같은" 것이 아니다. README는 규모가 다르다는 점을 이렇게 명시한다: "a small model can be trained and served end-to-end".
일부 botmaker rules는 게이밍 방지 목적으로 공개되지 않는다. README가 이를 명시한다. visibility-filtering이 참조하는 라벨 중 일부는 규칙이 열려 있지 않다.
파라미터 변경을 문서화하는 관행 — 4세대에서 신설
파라미터 변경을 코드 diff로 문서화하는 관행은 4세대에서 생겼다. docs/BIDIRECTIONAL_BOOST_CHANGE.md가 앞으로 파라미터 변경을 코드 diff로 어떻게 문서화할지 보여 주는 견본이다 확인:
"Going forward, you'll see future algorithm updates published to this open source repository, and you should be able to understand what changed by checking the diffs."
같은 문서가 상호팔로우 부스트의 3단계 롤아웃(A/B 시작 → 광범위 롤아웃 → 파라미터 조정)을 코드 diff 3개로 재현해 보여 준다. 다른 회사(Meta·Netflix·YouTube 어디)에서도 지금까지 사례가 없던 문서화 방식이다.
이 저장소가 앞으로 X 커밋 로그를 매일 추적하는 일의 가치가 상당히 커진다. 다음 커밋에서 어떤 param이 어떻게 바뀌는지가 A/B 결과의 지연 지표가 된다.
세대 대비 — 왜 canon 이전 판이 "값 미공개"라고 명시했나
1·2·3세대에서는 실제로 값이 공개되지 않았고, 4세대가 이를 새로 열었다.
1세대 (2023 Scala):ScoredTweetsParam.scala:151의 object ModelWeights에 파라미터 17개가 있다. 기본값은 10개가 1.0, 7개가 0.0이고, 실제 프로덕션 값이 아니다. 범위 경계값(예: ReportParam −20000..0)은 상대 스케일 단서로만 유효하다.
2세대 (2025 Scala):HomeGlobalParams.scala:786으로 옮겨졌고, 파라미터는 17개에서 30개로 늘었다. 30개 전부 default = 0.0으로 의도적으로 삭제됐다. 상대 스케일 단서만 남았다.
3세대 (2026-01 Rust):params:: 모듈 자체가 레포에서 빠졌다. FavoriteWeight 같은 심볼은 호출부만 있고 정의가 없었다.
4세대 (2026-08 Rust): ⚠ home-mixer/params/param.rs로 실 프로덕션 미러를 공개했다.
널리 인용되는 "reply 13.5" 표는 여전히 2023-04-05 스냅샷 그대로다. 4세대 미러와 일치하는 항목은 favorite 0.5·retweet 1.0 두 개뿐이고, 나머지는 3년 사이 대폭 개편됐다. 확인 — twitter/the-algorithm-mlprojects/home/recap/README.md
세대를 건너뛰어 복원되는 상수도 있다. 3세대 offset_score는 2세대 Scala RerankerUtil.weightedScoresSum의 직역이다. 3세대에서 crate::params에 숨었던 NEGATIVE_SCORES_OFFSET의 2세대 대응물은 val Epsilon = 0.001이다. 4세대에서 NEGATIVE_SCORES_OFFSET도 param.rs에 노출됐는지는 grep이 필요하다. 자세한 내용은 multi-task-ranking.md에 있다.
2세대 코드에서 X는 랭킹 가중치에 유저×날짜로 시드 고정된 Beta 분포 곱셈 노이즈를 주입할 수 있다 확인 — HeavyRankerWeightsQueryFeatureHydrator.scala, 시드 Objects.hash(userId, startOfDay). 4세대에서 이 노이즈 주입이 유지됐는지는 심층 분석이 필요하다.
레거시 heavy ranker (2023~2025)
레거시 heavy ranker는 멀티태스크 신경망이 참여 확률들을 예측하고, 그 가중합으로 정렬하는 구조다. 2026 Phoenix와 비교하면 목적함수 형태는 같고 입력이 다르다. 레거시는 수작업 피처를, Phoenix는 ID 임베딩을 입력으로 쓴다.
2025 세대에서는 랭커 두 개가 나란히 돈다확인 — ScoredTweetsRerankingScoringPipelineConfig.scala:35-39.
Navi는 프로덕션 heavy ranker이고, 헤드는 15개다 확인 — home-mixer/.../model/PredictedScoreFeature.scala:294-311.
Phoenix는 트랜스포머 rescorer(앞 랭커가 매긴 점수를 다시 매기는 모델)이고, 헤드는 13개다. ParamGatedScorer(EnablePhoenixScorerParam, …)로 Navi 뒤에 붙고, 가중치는 Navi와 같은 ModelWeights를 공유한다 확인 — PhoenixPredictedScoreFeature.scala:174-189.
트랜스포머 랭커는 기존 heavy ranker를 한 번에 갈아치우지 않았다. param으로 켜는 rescorer로 먼저 옆에 붙었다가, 2026에 전면 교체됐다.확인 — 근거는 파일 8개가 전부 c54bec0에서 새로 생겼다는 것(git log --diff-filter=A)과 위 파이프라인 연결이다.
2025 Phoenix 호출 규격은 아래와 같다 확인 — home-mixer/.../util/PhoenixUtils.scala.
후보를 1400개로 자른다(MaxCandidates = 1400:36). 설정값 TopLogProbsNum = 50도 있다(:35).
재시도는 2회, 시도당 500ms, 총 1초다(:119-121). 매 시도마다 채널을 무작위로 다시 고른다(:108-109).
후보에 실려 가는 boolean은 5개뿐이다: isRetweet, isForYouPage(항상 true), isPromotedTweet(항상 false), isReply, isQuote:48-68.
점수 결합식과 세대별 헤드 목록 전체는 multi-task-ranking.md에 있다. 이 결합식은 음수 헤드를 곱하지 않고 임계값 상수 페널티로 처리하며, 마지막에 Epsilon = 0.001로 압축한다.
2025 코드에서는 주의할 점이 있다.PredictedScoreFeature라는 이름의 파일이 두 개 있고, 헤드 세트가 다르다. model/의 15개가 실제로 쓰이고, product/scored_tweets/scorer/의 17개는 레거시다. 후자를 근거로 헤드 목록을 쓰면 틀린다. 확인 — RerankerUtil.scala:4의 import 경로
Grok은 2025 랭킹 점수에 산술적으로 기여하지 않는다. 필터(GrokGoreFilter 등 4종)와 토픽 태그로만 들어간다. PredictedScoreFeatures 어디에도 Grok 헤드가 없다. 확인 — repo-wide, ScoredTweetsRecommendationPipelineConfig.scala:543-546
인증(verified) 작성자 랭킹 부스트는 없다.AuthorIsBlueVerifiedFeature 등을 참조하는 파일 10개가 전부 하이드레이션·로깅·캐싱·마셜링이다. 단 학습 모델의 입력 피처로는 들어간다(GizmoduckFeaturesAdapter.scala:12-17). 규칙 기반 가점은 없고 모델이 알아서 쓰는 구조다. 확인 — repo-wide grep
2026-10-17 커밋(862a123) 기준으로 X For You의 필터는 25개이고, 20개는 스코어링 전에, 5개는 후보 선택 뒤에 걸린다 확인(필터 절). 4세대 공개 기준으로 다양성 장치는 작성자 다양성 감쇠, VMRanker의 DPP 슬레이트 재정렬, 대화 dedup의 세 겹이다 확인(다양성 절, Phoenix 절). 2026-10-14 커밋(4세대+41)부터는 topic 요청이면 VMRanker가 DPP 다양성 재순위를 기본으로 건너뛴다 확인(변경 이력의 4세대+41). 2026-10-08 커밋(4세대+38) 기준으로 최종 배치는 점수 순이 아니고, 블렌더가 광고, 프롬프트, 팔로우 추천, 기본 off인 영상 캐러셀을 차례로 넣고 push-to-home 포스트를 맨 앞에 고정한다 확인(최종 배치 절).
2026-10-17 커밋(862a123) 기준으로 선택 후에 걸리는 필터는 VFFilter, AncillaryVFFilter, AncestorVideoFilter, DedupConversationFilter, VideoCarouselFilter 5개다 확인 — phoenix_candidate_pipeline.rs:518-524.
근거 자료는 브라질 선거법원 열린 데이터셋과 X의 공지 (@XBR, 2026-08-14)다. README는 이렇게 명시한다: "in accordance with Brazilian electoral law, For You now runs Brazil2026ElectionFilter, which removes posts from accounts reported to Brazil's Electoral Court for the 2026 election, unless the viewer explicitly follows the account."
2026-10-14 커밋 17c96bf(4세대+41)에서 목록이 10월 25일 결선 투표 후보 관련 25개 계정으로 줄었다(해당 diff 구간이 5,654줄에서 63줄이 됐다) 확인. 근거로 TSE 공문 번호를 주석에 적었다 확인 — brazil_2026_election_filter.rs:17-74
코드는 각 계정 ID 옆에 @username을 주석으로 남긴다("obfuscated ID + 투명성 목적 username 병기"). 예는 40053694 // @FlavioBolsonaro, 21069302 // @marcelvanhattem, 48298703 // @eduardopaes다.
팔로우 예외는 유저 개인 팔로잉 그래프 기반이라, 정치적 스펙트럼과 무관하게 대칭적이다. 판정식은 is_excluded_author(user_id, followed_user_ids) = LIST.contains(user_id) && !followed_user_ids.contains(user_id)다.
리스트 사이즈는 코드 상수 665다. 코드에는 이런 이례적 주석이 있다: "OmarAzizSenador deleted his account at the time this code was written."
이 필터는 관할권 필터가 코드 안에서 그대로 관찰되는 첫 사례다. DSA·GDPR·NetzDG 같은 규제 대응은 지금까지 대부분 규제 제출 문서(투명성 리포트)로만 봤다. 이 코드 공개 관행이 유지된다면, 각국 선거 사이클마다 유사한 필터를 코드로 확인할 수 있게 된다는 뜻이다.
필터 가운데 눈여겨볼 점은 다음과 같다.
AuthorSocialgraphFilter가 보는 조건은 6가지다 확인 — filters/author_socialgraph_filter.rs:8-61
뮤트, 차단, 작성자가 나를 차단, 인용된 작성자가 나를 차단, 내가 인용 작성자 차단, 내가 리트윗 원저자 차단
VFFilter는 여전히 fail-closed지만, 4세대+18(2026-09-09, 75d93d9)부터 판정 축이 FilteredReason variant 매칭에서 명시적 Action enum으로 옮겨졌다 확인 — home-mixer/filters/vf_filter.rs:16-21
should_drop_action이 Drop(_)·Tombstone·NotEvaluated를 drop으로, Allow·Interstitial·Avoid·Downrank를 keep으로 분류한다.
상류 StratoVfClient는 non-SafetyResult reason(뮤트·차단·null-cast·exclusive 등)을 client 층에서 Drop으로 바꾸고, SafetyResult 안의 NotEvaluated는 Allow로 바꾼다 확인 — visibility-filtering-client/vf_client.rs:178-187. 그래서 이 경로에서는 판정 없음이 drop되지 않는다.
반대로 Rust VF 클라이언트는 응답에서 빠진 포스트를 NotEvaluated로 채우고, VFFilter는 이것을 drop한다 확인 — vf_client.rs:360-378. fail-closed는 이 경로의 누락 결과에 해당한다.
인네트워크와 OON에는 다른 안전 레벨이 적용된다(TimelineHome vs TimelineHomeRecommendations). 조상 포스트와 인용 포스트에는 항상 더 엄격한 쪽이 적용된다 확인 — vf_candidate_hydrator.rs:62-67, 76-90
TopicIdsFilter는 572줄짜리 파일이고, 그 안에 약 90개 토픽 상수와 손으로 짠 supertopic 계층이 들어 있다. 이 부분은 "수작업 피처 전부 제거"와 정면으로 배치된다.확인 — filters/topic_ids_filter.rs:109-515
PreviouslySeenPostsBackupFilter는 영구 no-op다. 그 입력을 채울 ImpressedPostsQueryHydrator가 생성만 되고, _에 바인딩된 채 파이프라인에 등록되지 않는다. 매 요청마다 돌지만 아무것도 거르지 않는다 확인 — phoenix_candidate_pipeline.rs:234-236
FollowingViewerMutedKeywordFilter는 기존 MutedKeywordFilter(For You 파이프라인용)와 별개 파일로 분리됐다.
TweetTokenizer로 muted_keywords를 토큰화하고, MatchTweetGroup으로 매처를 구성한다.
후보의 tweet_text, quoted_tweet_text, ancestor_texts.values() 세 텍스트 소스 중 하나라도 매치하면 걸러 낸다.
이 매칭은 tokio::task::block_in_place로 감싼 CPU-bound 작업이다.
특이점은 기존 For You 필터가 tweet_text만 봤을 가능성이다. 근거는 2026-08-18 커밋(4세대+3)에 quoted-text 연결이 없었다는 점이다.
이제 인용된 포스트와 ancestor(답글 조상)까지 뮤트 대상이 넓어졌다. 이 확장은 인용이나 답글로 뮤트를 우회하는 길을 좁힌다.
QuotedPostTextHydrator는 이 필터의 입력을 준비하는 hydrator다. 필터가 quoted text를 매치하려면 이 필드를 먼저 채워야 한다.
quoted_tweet_id가 있는 후보에 대해 TESClient.get_tweet_core_datas로 일괄 조회한 뒤 quoted_tweet_text 필드에 채운다.
HashSet으로 중복을 없애고, gRPC를 배치로 호출한다.
의미: 이제 For You와 Following은 필터·hydrator 스택에서 서로 다른 컴포넌트를 쓴다. 2026-08-18 커밋(4세대+3)의 VF hydrator 분기와 짝지어 보면, Following 파이프라인은 For You 파이프라인의 subset이 아니라 병렬 구조로 재구성되고 있다 추정
4세대+5(2026-08-20, d0cef2f)에서 For You에 "AI trend" feedback context 채널이 새로 생겼다.
게이트는 3중이다. 스위치는 EnableAiTrendFeedbackContext=false로 기본 off이고, 나머지 두 조건은 !query.is_topic_request()와 !query.in_network_only다. 즉 topic 지면·Following 지면과 겹치지 않고, For You OON 후보만 대상으로 한다.
후보는 standalone original post 4중 조건으로 거른다. 즉 AI trend 라벨은 답글, 리트윗, 대화 조상이 있는 포스트, 팔로잉이 답글을 단 대상이 아닌 원본 포스트에만 붙는다.
이 id의 임베딩이 있으면 seed가 greedy DPP의 첫 슬롯에 pinning되고 최종 결과에서는 걸러져 나가므로, top_k 예산은 소진하지 않으면서 seed와 유사한 후보만 페널티를 받는다.
즉 다양성 축을 집합 내부에서 참조 tweet 대비로도 확장할 수 있게 됐다.
프로덕션 지면 중 이 필드를 실어 주는 곳이 어디인지는 아직 공개되지 않았다.
대화 dedup은 스레드당 최고점 1개만 남긴다 확인 — filters/dedup_conversation_filter.rs:6-48
대화를 묶는 id는 conversation_id = min(ancestors) 또는 자기 자신이다.
VMRanker 호출이 실패해도 피드는 DPP 없이 나간다. 2026-09-24 커밋(4세대+28)부터는 gRPC가 실패하면 로컬 가중합 점수를 내보내고, 그 실패를 VMRanker.local_score_fallback 메트릭으로 센다 확인 — vm_ranker.rs:107-113.
그 전(3세대~4세대+27)에는 모든 후보에 Err를 반환했고, 파이프라인의 update_all이 Err를 로그도 없이 건너뛰었다. 그래서 DPP 다양성이 빠진 점수순 피드가 아무 표시 없이 나갔다 확인 — candidate-pipeline/scorer.rs(update_all 기본 구현), vm_ranker.rs:44-66
X는 피드가 뷰어에게 새로운 토픽을 얼마나 보여 주는지 재는 장치를 기본 off로 만들었다. 2026-10-20 커밋(4세대+45)에서 뷰어가 최근 참여한 포스트의 SID 앞 1·2단계를 토픽으로 세고, 응답 포스트 가운데 그 이력에 없는 토픽의 비율을 메트릭으로 남기는 경로가 생겼다 확인 — side_effects/serendipity_stats_side_effect.rs.
이력 토픽 계산은 스위치 HistoryTopicsEnabled가 기본 false라 꺼져 있고, 켜지면 메트릭은 28일 이력을 기준으로 잰다 확인 — params/param.rs:508-529.
같은 이력 토픽을 VMRanker 요청에 실어 보내는 스위치도 기본 off로 생겼다 확인 — 같은 파일. VMRanker가 이 값을 어떻게 쓰는지는 공개 코드에 없다 확인.
메트릭만 있고 재순위에 쓰는 코드는 없다 확인. 측정을 먼저 넣은 순서로 보아, 이력 기반 토픽 탐색을 다양성 목표로 시험할 준비로 본다 추정. 세부는 daily 2026-10-21에 있다.
2025 Scala 세대는 방식이 지금과 달랐다. MMR은 거리에 보상을, SimClusters 카테고리 페널티는 같은 것에 벌점을 주는 방식이었고, 둘이 param-gated로 나란히 있었다. dedup 필터는 6종이었다. 네 방식(감쇠, MMR, DPP, dedup)의 비교와 세대별 전체 목록은 feed-diversity.md에 있다.
최종 배치는 점수 순이 아니다
최종 배치를 맡는 블렌더는 프레임워크의 정렬을 의도적으로 무력화한다확인. 근거는 home-mixer/selectors/blender_selector.rs:25-114다. 이 파일의 BlenderSelector에서 score()는 상수 0.0을 반환한다 확인. 블렌더는 그다음 아래 단계를 실행한다.
프롬프트를 0..n번 자리에 삽입한다 확인 — 같은 파일
Who-to-Follow(WTF)를 지정 위치에 삽입한다 확인 — 같은 파일
push-to-home 포스트를 항상 0번에 고정한다 확인 — 같은 파일
광고 블렌더를 실행한다. 2026-10-17 커밋(862a123) 기준으로 AdsBlenderType이 partition_organic_low_risk·safe_gap·multi_risk·time_gap 가운데 하나를 고르고, 기본값은 2026-09-01 커밋(4세대+13)부터 multi_risk다 확인 — blender_selector.rs:55-72, params/param.rs:817
콜드스타트는 이력이나 노출이 아직 쌓이지 않은 유저, 작성자, 포스트를 추천에서 어떻게 다루느냐의 문제다. 4세대 공개 기준으로 X For You 코드에는 신규 유저와 새 작성자를 위한 장치가 있었지만, 개별 새 포스트를 위한 장치는 없었다 확인(신규 유저, 새-작성자 부스트, 신규 아이템 절). 2026-09-30 커밋(4세대+32)부터는 노출과 좋아요가 적은 새 포스트를 따로 모으는 Phoenix retrieval cold 슬라이스가 켜졌고, 콜드스타트 슬롯 선택기는 홈 노출이 200 미만인 포스트만 고른다 확인(신규 아이템 절). 새 작성자의 인네트워크 포스트에 붙는 새-작성자 부스트는 2026-10-07 커밋(4세대+37)부터 home-mixer 미러에서 0.015, VM-Ranker 미러에서 0.02다 확인(변경 이력 4세대+37). 콜드스타트 슬롯 선택기의 Thompson sampling과 Phoenix retrieval의 cold-start 슬라이스는 기본 off로 들어왔다가, 홈믹서 cold-start 파라미터를 프로덕션 값으로 튜닝한 커밋에서 함께 켜졌다 확인.
신규 유저 (계정 단위)
이력 길이가 임계보다 짧은 신규 유저는 랭킹과 retrieval 모두에서 별도 모델 플릿으로 보낸다 확인. 근거는 phoenix_scorer.rs:29-43과 phoenix_source.rs:25-39다. 임계는 Phoenix*NewUserHistoryThreshold이고, 이 유저들이 갈 플릿은 Phoenix*NewUserInferenceClusterId다.
4세대 README는 노출이 임계 미만인 작성자를 "새 작성자"로 설명하고, 그때 임계 param은 ColdStartImpressionThreshold = 1000이었다. 코드는 이 임계를 작성자가 아니라 후보 포스트의 홈 노출 수에 적용한다 확인(새-작성자 부스트 절).
EnableColdStartThompsonSampling은 A/B용으로 붙인 스위치다. 2026-08-14 커밋(4세대+1)의 기본값은 false이고, 기본은 여전히 greedy(현행 유지)다. 기본 off라는 점이 중요하다. 이 릴리즈 시점(2026-08-14)에 실제 유저에게 노출되는 콜드스타트는 여전히 max-score greedy이고, Thompson은 A/B 인프라만 연결돼 있다.
Thompson sampling은 후보마다 posterior에서 샘플을 한 번 뽑아 상위 K개를 고르고, 그 안에서 원래 스코어가 가장 높은 후보를 고른다.
각 콜드스타트 후보 i에 대해 n_i = scale × view_count_i, x_i = min(fav_count_i, n_i)로 둔다.
posterior는 Beta(α₀ + x_i, β₀ + max(n_i − x_i, 0))이고, 여기서 한 번 샘플한다.
즉 Bayesian exploration(어떤 후보가 좋을지에 대한 불확실성)을 하되, 최종 선택은 원래 스코어 tie-break로 정한다.
α₀=0.75, β₀=49.25는 "50 impression 중 대략 0.75 like"라는 사전분포를 둔다. 실측 like rate가 이보다 높은 새 작성자가 후하게 뽑히도록 한 값이다.
2026-08-14 커밋(4세대+1) 기준으로 계측 훅 record_tracked_ids는 A/B에서 특정 계정을 손으로 관찰할 수 있게 하는 개별 관찰 훅이다.
이 훅은 ColdStartTrackedIds에 등록된 tweet_id/author_id가 콜드스타트 파이프라인의 어느 소스에서 몇 번 나왔는지 센다.
apply()는 실행 순서를 바꿔 viewer_arm(query)과 author_corpus(...)를 enabled 게이팅보다 먼저 호출한다.
두 arm은 각각 moe_codivert_viewer bucket impression을 3회, author 대상인 moe_exp bucket impression을 2회 정확히 남긴다. 테스트 both_arms_log_the_same_codivert_impressions와 both_viewer_arms_log_the_same_author_impressions가 이를 고정한다.
triggered-analysis는 파이프라인에 들어온 사용자만 비교하고, intent-to-treat는 할당된 모든 사용자를 비교한다. 두 정의의 불일치를 회귀 테스트로 막는 방법론이 코드로 들어왔다.
같은 2026-09-04 커밋(4세대+16)에서 Treatment arm의 후보 필터가 Phoenix MoE 후보에만 걸리도록 좁혀졌다 확인 — 같은 파일.
cold_start_corpus_eligible(Treatment)에 && is_phoenix_moe(c) 조건이 추가됐다.
이 커밋 기준으로 Treatment 뷰어의 cold-start 후보는 treatment corpus author의 포스트이면서 served_type == ForYouPhoenixRetrievalMoe인 후보만이었다.
테스트 이름도 treatment_viewer_cold_starts_treatment_moe_only로 바뀌었다.
Cold-start와 Phoenix MoE가 하나의 codivert 실험 안에 묶여, 두 효과가 서로 뒤섞이지(confounded) 않게 격리된다.
이 MoE 조건은 2026-09-17 커밋(4세대+23)에서 Treatment 갈래에서 빠졌고, 2026-10-17 커밋(862a123)의 cold_start_corpus_eligible에는 MoE 조건이 없다 확인 — 42266f3, 862a123의 author_cold_start.rs.
enable()의 !query.has_cached_posts 게이팅도 없앴다. 후보마다 조회 여부를 정하는 쪽의 has_cached_posts 분기는 2026-10-01 커밋(4세대+33)까지 남았고 2026-10-02 커밋(4세대+34)에서 없어졌다 확인 — engagement_counts_hydrator.rs:123, 76843a5의 같은 파일.
4세대 공개(2026-08-13)는 X가 안전 라벨을 만들고 그 라벨로 포스트 노출을 판정하는 경로를 전부 공개했다. 4세대 README 기준으로 이 경로는 콘텐츠 이해, 라벨링 규칙, 저장, 가시성 필터링(VF)의 네 단계다 확인(4단계 Labeling Path). 2026-10-15 커밋 기준으로 VF가 쓰는 SafetyLevel(안전 레벨)은 다섯 개다 확인(변경 이력의 2026-10-15 커밋).
3세대까지 home-mixer/에는 VFFilter와 AncillaryVFFilter가 fail-closed로 걸려 있었다. 하지만 그 필터가 참고하는 라벨을 누가 언제 어떻게 붙이는지는 완전한 블랙박스였다. 4세대 공개 때 visibility-filtering과 labeling 서브레포 15개가 새로 들어왔다 확인(공개 소스 4세대).
1단계(Content Understanding)는 요청 경로 밖에서 도는 nearline 단계다. README 그림의 1단계에는 grox, media-model-proxy, clip, agatha, bdsm, user-cred-v2 여섯 개가 나온다 확인. 관련 서브레포는 아래 표와 같다.
표시 쪽에는 media·engagement 슬롯이 있고, notice 슬롯은 2026-10-06 커밋(4세대+36)에서 더해졌다 확인 — verdict.rs(변경 이력의 4세대+36).
그 앞 단계로 2026-09-09 커밋(4세대+18)은 판정 축을 FilteredReason에서 평평한 Action enum으로 바꿨다(변경 이력의 4세대+18).
4세대 공개 시점(2026-08-13)에는 SafetyLevel 정책이 세 개였다 확인 — 4세대 registry.rs.
FilterAll은 모든 것을 드롭한다 확인.
TimelineHome은 팔로잉 대상에 쓰는 정책이고, base 28 규칙으로 이뤄진다 확인.
base_home_rules()의 28개 규칙은 계정 상태 5개, 뷰어 관계 3개, 트윗 라벨 8개, 트윗 상태 4개, NSFW 뷰어 게이팅 3개, 배제 트윗 1개, NSFW 인터스티셜 4개로 이뤄진다 확인.
TimelineHomeRecommendations는 추천 대상에 쓰는 정책이고, base 28개에 OON 전용 26개를 더한 54 규칙으로 이뤄진다 확인.
OON 전용 규칙 26개는 DMCA/지리 제한 미디어 2개, NSFW 계정 4개, NSFW/폭력/DoNotAmplify/스팸 라벨 트윗 9개, NSFW/스팸/impersonation/deactivated/adult 유저 라벨 11개로 이뤄진다 확인.
4세대 registry.rs에서는 첫 번째 드롭 판정이 나오면 평가가 끝난다(short-circuit) 확인. OON 전용 규칙군은 드롭만 할 수 있고, 인터스티셜이나 알로우 판정은 내지 않는다 확인. 판정은 Allow, Interstitial(reason), Drop(FilteredReason) 세 가지다 확인. 인터스티셜은 UI 처리로 넘기며, 이 레포에는 인터스티셜을 그리는(draw) 코드가 없다 확인.
canon 주의 목록 #1·#2의 실체 확인 (부분)
두 항목 모두 실체가 부분적으로 확인됐고, 항목마다 심층 분석이 필요한 질문이 하나씩 남았다.
VFFilter의 fail-closed 동작: VFFilter가 판정 없는 모든 FilteredReason 변종을 무조건 drop한다는 3세대 관찰은 유효하다. visibility-filtering/ 서브레포가 판정 없이 리턴하는 경우(에러·타임아웃)를 어떻게 처리하는지는 심층 분석이 필요하다.
인네트워크와 OON에 다른 safety level을 쓰는 동작: 4세대 Policies 구조체는 FilterAll·TimelineHome·TimelineHomeRecommendations 세 레벨을 그대로 노출한다. 조상·인용 포스트에 더 엄격한 쪽을 적용하는 3세대 규칙(vf_candidate_hydrator.rs)이 4세대에서도 유지됐는지는 심층 분석이 필요하다.
Under the Hood 투명성 툴 — 4세대와 짝지어 신설 확인
Under the Hood는 유저가 자기 계정과 포스트에 붙은 visibility-impacting 라벨을 조회하는 투명성 툴이다 확인. 라벨은 일일 배치가 수집하고, 서빙 층이 집계한다 확인. X가 밝힌 목적은 유저가 이 레포 코드와 맞춰 보며 "왜 내 포스트가 이렇게 취급됐나"를 직접 감사할 수 있게 하는 것이다 확인.
X 추천 코드는 좋아요, 답글, 리포스트 같은 긍정 행동과 관심 없음, 차단, 뮤트, 신고 같은 부정 행동을 피드백 신호로 쓰고, 프로덕션은 포스트에 머물지 않은 것까지 부정 신호로 센다 확인. Phoenix의 이력 인코딩은 하지 않은 행동을 0이 아니라 −1로 넣고, 행동이 하나도 없는 이력 항목은 마스킹한다 확인. 이미 본 포스트는 6겹으로 처리하며, 유저 쪽 피처에는 나이와 추론된 성별 같은 인구통계가 들어간다 확인. 세로 영상 캐러셀 모듈을 기본 off로 신설한 커밋에서, 이력의 체류 시간을 버킷 임베딩으로 입력하는 Phoenix 피처는 기본 on이 됐다 확인(변경 이력).
긍정 신호는 좋아요, 답글, 리포스트, 인용, 클릭, 체류 같은 행동이다 확인 — phoenix/runners.py:233-253
전체 목록은 favorite, reply, repost, quote, click, profile_click, photo_expand, video quality view, share(DM·링크 복사 포함), dwell, dwell_time, follow_author다.
부정 신호는 관심 없음, 작성자 차단, 작성자 뮤트, 신고이고, 프로덕션은 not_dwelled(안 머물렀음)까지 부정 신호로 센다 확인 — ranking_scorer.rs:83
네 행동의 식별자는 not_interested, block_author, mute_author, report다.
이력 인코딩에서 행동 없음은 0이 아니라 −1이다확인 — phoenix/recsys_model.py:404-432
식은 actions_signed = 2*actions - 1이다.
즉 "이 포스트에 좋아요를 안 눌렀다"가 명시적인 음의 신호로 들어간다.
다만 행동이 하나도 없는 이력 항목은 항목 전체가 마스킹된다 확인 — 같은 파일.
이미 본 포스트 처리는 6겹이다 확인 — query_hydrators/, side_effects/의 각 파일
6겹을 이루는 것은 클라이언트가 보낸 seen_ids, impression bloom filter, 서버 쪽 served history(최근 N분/M개), Redis 후보 캐시, Phoenix 요청 캐시, URT 커서다.
클라이언트가 보고한 노출은 Kafka로 다시 내보낸다 확인 — side_effects/publish_seen_ids_to_kafka_side_effect.rs
유저 쪽 피처에는 나이, 추론된 성별, IP 위치 같은 인구통계가 들어간다 확인 — home-mixer/models/query.rs:24-95
들어가는 필드로 user_age_in_years, user_inferred_gender, ip_location, subscription_level이 있다.
토픽과 스타터 팩 쪽 필드로는 followed_grok_topics[32], inferred_grok_topics[32], followed_starter_packs[20]가 있다.
user_inferred_gender에는 전용 GenderPredictionGrpcClient가 있다.
하이드레이터 쪽 근거는 phoenix_candidate_pipeline.rs:210-231이다.
공개 코드 기준으로 X For You는 유기 포스트 사이에 광고를 끼워 넣고, 광고 옆에 오는 포스트의 브랜드 안전 등급을 보고 광고 자리를 정한다 확인. 기본 광고 블렌더는 4세대+13 커밋(2026-09-01, 7ba7768)부터 multi-risk 블렌더다 확인(변경 이력의 4세대+13). 이 블렌더는 HighRisk 포스트 옆에는 어떤 광고도 놓지 않고, MediumRisk 포스트 옆에는 광고주가 그 맥락을 허용한 광고만 놓고, 그런 광고에는 MediumRisk 자리를 먼저 준다 확인(변경 이력의 4세대+10). 브랜드 안전 판정의 V2 판정 스위치와, 포스트 단위 Grok 안전 라벨이 없을 때 작성자 단위 라벨을 대신 넣는 fallback 스위치는 2026-10-15 커밋 기준으로 둘 다 기본 on이다 확인(변경 이력의 2026-10-15 커밋).
광고 블렌딩과 브랜드 안전 판정
공개 코드에는 광고 블렌딩이 전부 들어 있다. 이 점이 이번 공개에서 의외인 부분이다.
광고를 붙이려면 유기적 포스트가 최소 5개 있어야 한다 확인 — home-mixer/ads/util.rs:10
광고의 기본 간격은 {requested: 3, min: 2}이고, 최소 요청 간격은 3이다 확인 — ads/util.rs:12-17
"안전 갭"의 절반까지만 광고를 넣는다 확인 — ads/partition_organic_blender.rs:35
식은 max_from_safe = safe_count / 2다.
마지막이 광고면 잘라낸다 확인 — ads/util.rs:164-198
브랜드 안전성은 인접성 판정이다. 2026-09-01 커밋(4세대+13) 전까지 기본이던 partition_organic_low_risk 블렌더는 MediumRisk·HighRisk 포스트 옆에 광고를 붙이지 않는다(has_avoid()가 두 tier 모두 avoid로 본다). 4세대+13부터 기본인 multi_risk 블렌더는 BSR-high 광고를 MediumRisk 포스트 두 개 사이에 먼저 놓아 보고, 겹침 검사에 걸리면 Safe 자리로 넘긴다 확인 — multi_risk_blender.rs:68-179. 판정은 손으로 짠 규칙 캐스케이드에 Grok 채점 여부와 PTOS 심사 여부를 더해 내린다 확인 — home-mixer/models/brand_safety.rs:5-13, 15-22, 45-48
즉 V2가 판정 신호(GROK_SFA_V2 하나)를 보내면 V1의 전통 heuristic까지 다 무시하고 V2 결정이 지배한다.
해석: dual-write 마이그레이션 전략이 "V2 판정 시 특정 rule id를 필터"에서 "V2가 판정했으면 V2 결정, 아니면 V1 폴백"으로 옮겨 갔다. 후자가 훨씬 깔끔한 dual-write 시맨틱이다. V2 라벨러가 아직 도달하지 못한 포스트는 V1 안전망이 계속 잡고, 도달한 포스트는 V2가 완전히 대체한다.
미공개: V1 판정 함수의 최종 제거 시점, V2 라벨러 커버리지, V2_WRITTEN_LABELS 4개 라벨의 유래(Grok 프롬프트 변경? 파이프라인 재훈련?)
BrandSafetyVerdict::HighRisk = 4라는 네 번째 등급이 기존 Unspecified/Safe/LowRisk/MediumRisk 위에 신설됐다. enum discriminant를 4로 배정해서 worst_verdict()의 >= 비교가 자동으로 확장된다.
5종이 medium에서 high로 승격됐다. 대상은 NSFW_HIGH_PRECISION, NSFW_HIGH_RECALL, GORE_AND_VIOLENCE_HIGH_PRECISION, PDNA, EGREGIOUS_NSFW다. 신규 1종SOFT_NSFW도 HighRisk다. V1(compute_verdict)과 V2(compute_verdict_v2) 모두 HighRisk 체크가 최상단으로 이동했다.
V2도 대칭으로 바뀌었다.MEDIUM_RISK_LABELS_V2에서도 위 5종이 제거됐다. V2에는 원래 NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW가 있었다.
광고 유틸은 두 tier를 모두 avoid로 대한다.home-mixer/ads/util.rs의 has_avoid()가 MediumRisk | HighRisk matches로 확장됐다(기존은 MediumRisk만).
즉 인접성 판정에서 HighRisk 포스트에도 MediumRisk와 똑같이 광고를 붙이지 않는다.
ads_conversion_engagement_to_action_types에 신규 MACT 매핑 12개와 view-through 매핑 8개를 추가했다.
새 metric group ads_slim은 긍정 신호 3개와 부정 신호 4개, primary가 아닌 conversion click들만 학습한다. 광고 랭킹 라인의 slim signal 조합이다.
긍정 신호는 open link, external link long dwell, video quality view다.
부정 신호는 not interested, block, report, mute다.
NEGATIVE_FEEDBACK_HEAD_INDICES에 CLIENT_TWEET_SEARCH_QUERY_REFORMULATED가 추가됐다. 유저가 검색 결과가 마음에 안 들어 쿼리를 재작성하면 부정 피드백으로 취급한다. xrecsys_search config와 방향이 같다.
conversion_keep_mask (bool per-candidate)는 kafka conversionKeepMask 컬럼이다. False면 이 후보를 conversion 학습에서 제외한다.
sequence packing에서는 all(True)만 허용한다(연결되지 않은 경우).
새 메트릭 슬라이스는 fresh (delayed=0), delayed_clicked (delayed=1 & clicked=1), delayed_non_clicked (delayed=1 & clicked=0)다. condition_conversion_on_click이 delayed 학습에도 유지되므로 delayed 샘플이라도 "click 있었나"라는 조건 아래에서 conversion을 학습한다.
2026-09-18 커밋(4세대+24)에서 세 옵션이 단일 ads_head_masking: bool = False로 통합됐다. 세 옵션은 split_head_training_by_source·train_view_through_heads·condition_conversion_on_click이다. 세 슬라이스 이름(fresh·delayed_clicked·delayed_non_clicked)은 유지되고, 새 슬라이스 late_split_no_early가 추가됐다. Sample source는 3-값 카테고리컬(FRESH=0·EARLY_RELABEL=1·LATE_SPLIT=2)이다. twin 헤드 매핑은 변경 이력의 4세대+24에 정리했다.
의미: 4세대+6 커밋으로 광고 conversion 예측에 두 가지가 동시에 붙었다. 하나는 헤드 수의 대폭 증가(20개 신설)이고, 다른 하나는 delayed feedback 학습을 정식으로 분기 처리하는 인프라다. 이는 X 랭커 학습이 광고 delayed conversion prediction 문헌(Chapelle 2014 이후)의 표준 처리 방식과 명시적으로 정렬됐다는 신호다. 4세대~+5까지 canon이 반영해 온 랭킹 signal 채널·SID·SlateContext 확장 축과 별개로, 광고 매출 예측 스택이 새 축으로 붙었다.
proto 쪽에는 아직 반영되지 않았다. proto에는 13번 reconCosMilli 필드만 있다.
home-mixer 로컬 연결이 proto 이관을 기다리는 상태다.
의미:4세대+6이 광고 conversion 예측 헤드를 20개 대폭 확장한 것의 후속이다. 4세대+9는 픽셀 발화와 long dwell을 합성한 3종을 추가하고, asset 이력을 서빙 요청에 실을 채널을 만든다. constants.py나 models/recsys_model.py에는 아직 반영이 없다. proto 신설만 있으므로 학습·서빙 연결은 다음 커밋에서 볼 관측 포인트다.
Dynamic Product Ads(DPA)는 광고주가 상품 카탈로그를 업로드하면 X가 유저 맞춤으로 자동 생성해서 노출하는 광고 포맷이다. 이 커밋에서 DPA의 DpaProductInfo에 repeated int32 sid_codes = 8이 추가됐다. 즉 광고 DPA 제품에 대해 SID 코드가 요청 페이로드에 실려서 랭커로 들어간다. 이는 유기 post의 SID와는 별개 파이프라인이고, 유기 post SID는 이미 history_seq·candidate_seq의 post_sids로 실려 있다.
같은 커밋에서 DiscreteActionName enum에 reserved 214 to 252가 추가됐는데, 39개 자리를 미리 예약해 놓은 것으로 광고 conversion 액션이 앞으로 더 확장될 자리를 확보한 것이다 추정.
의미: 광고 DPA 랭킹이 SID 계열 시그널에 편입되기 시작했다. Phoenix 랭커의 4세대+13 SID recon 모드와 짝을 이루면, 광고 DPA 제품과 유기 post가 같은 SID 어휘 밑에서 표현될 가능성이 열린다. 다만 이 커밋만으로는 다음 세 가지를 알 수 없다.
의미: 2026-09-01 커밋(4세대+12)이 넣은 *_DELAYED(204-213) 10종이 16일 만에 tier 2·3으로 확장됐다 확인 — 6384ca7의 recsys.proto:446-455. 이제 delayed 창은 하나가 아니라 시차별 3개 구간으로 나뉜다. Chapelle(2014)·Yasui&Sakata(2020) 계열의 delayed feedback 문헌이 시차별 mask로 propensity 편향을 완화하는 접근을 취해 왔는데, 이번 변경은 그와 방향이 일치한다 추정. 각 tier가 관측 창의 어느 구간을 커버하는지와 tier 간 loss weight 조합은 이번 커밋에 명시되지 않았다. 학습 config에서 mparams["*_tier_2_loss_weight"]류 값이 등장하는지가 관측 포인트다.
Phoenix는 4세대 공개(2026-08-13)부터 작은 모델을 end-to-end로 학습하고 서빙할 수 있지만, 프로덕션 checkpoint·데이터·infra는 공개되지 않았다 확인(학습 코드). 랭커의 dense-parameter optimizer는 2026-08-19 커밋(4세대+4)에서 Adam에서 Muon으로 바뀌었다 확인(학습 코드). home-mixer가 Phoenix 서빙이 계산한 slate context를 쓰는 경로는 2026-08-20 커밋(4세대+5)에 들어왔고, 그 게이트의 기본값은 false였다 확인(예측 출력). SID 재구성 입력과 StableHLO 번들 export는 4세대+13 커밋(2026-09-01, 7ba7768)에 들어왔고, 둘 다 기본 off였다 확인(입력 피처, 인프라).
학습 코드, optimizer, 학습 config
2026-08-21 커밋(4세대+6) 기준으로, 공개된 flagship 랭킹 config와 nano config는 프로덕션 홈 랭커와 같은 Muon optimizer 레시피를 쓴다 확인. 검색 랭킹용 학습 config는 2026-08-19 커밋(4세대+4)에서 For You 피드용 config 옆에 나란히 추가됐다 확인.
Muon은 Newton-Schulz orthogonalization으로 gradient momentum을 근사 직교화하는 optimizer다. 이 코드의 Muon은 optax.contrib._muon을 감싸 쓰고, Phoenix ranker의 dense-parameter 기본 optimizer가 됐다. 적용 방식은 하이브리드다. matrix-shaped 파라미터만 Muon을 쓰고, 이름이 아래 패턴에 걸리는 파라미터는 여전히 Adam을 쓴다.
다시 말해 임베딩 테이블, 출력 logits 헤드, vocab, norm(layernorm/rmsnorm)은 Adam에 남는다. Muon으로 가는 것은 트랜스포머 body의 attention·FFN weight matrix뿐이다. 이 구성은 Muon의 표준 하이브리드 패턴(Keller Jordan 2024와 Kimi/Moonshot K2 계열 라인)과 같다.
_home_direct_packed_base()의 기본 config는 아래 표처럼 바뀌었다.
파라미터
값 (4세대+4부터)
이전 값 (3세대~4세대+3)
optim
"muon"
Adam
learning_rate (dense)
7.1e-4
1e-3
emb_learning_rate
(embedding config로 이동)
0.2
muon_consistent_rms
0.2
—
muon_matrix_weight_decay
0.014
—
muon_split_fused
"qkv:128" (fused QKV projection을 128-dim block 단위로 분할해 각각 직교화)
GB300 override도 같은 방향으로 갱신됐다. 값은 Muon, LR 5e-4, half_life_steps=5000, weight_decay=0.01이다.
이 저장소가 반영해 온 대규모 프로덕션 recsys 학습 파이프라인 가운데 Muon 채택을 코드로 확인한 것은 이번이 처음이다. LLM 학습에서 시작된 optimizer가 랭커·retrieval 학습으로 넘어왔다는 관찰 축이 새로 생긴다. topics/sequence-transformer-ranking.md에 optimizer 축을 추가해야 한다.
2026-08-21 커밋(4세대+6)에서 Muon 학습 레시피가 export에 정식으로 포함됐다확인 — phoenix/TRAINING.md.
canon의 이전 서술은 이랬다: "Phoenix export의 dense optimizer는 standard Optax AdamW, 프로덕션의 tuned RMS-normalized-Adam derivative는 미공개." Muon을 도입한 4세대+4에서 muon.py·dense_optim.py가 export된 뒤에도, TRAINING.md는 여전히 AdamW만 export한다고 적고 있었다. 이 커밋에서 TRAINING.md는 아래처럼 바뀌었다.
Two dense-parameter optimizer families ship in this export:
Muon (xrex/optimizers/recsys/muon.py): the production home-ranker recipe — consistent-RMS scaling with decoupled weight decay on the matrix and embedding partitions. The flagship ranking configs and home_direct_packed_nano select it (optim="muon"), so the nano trains with the same dense-optimizer recipe production runs.
Standard Optax AdamW: the slot used by the remaining shipped configs (two-tower retrieval, gen-recs, and the legacy ranking presets). ...
4세대+4에서 들어온 emb_optim_config(LR 0.28, half-life 2500 step) 파라미터가 정본으로 굳었다.
reference/README.md의 train_step.py 항목도 "dense optimizer + rowwise-AdaGrad"로 일반화됐다 확인 — reference README. reference 학습 스크립트는 AdamW arm을 구성하고, flagship·nano config는 Muon 레시피를 쓴다.
의미: 이 커밋으로 export되는 학습 파이썬 코드를 그대로 돌리면 프로덕션 Muon 레시피와 같은 학습이 되는 상태가 됐다. 4세대+4(2026-08-19)에 Muon이 코드로 들어왔을 때 canon의 이해는 "코드는 export됐지만 학습 레시피는 여전히 프로덕션 특화"였다. 재현성 문서가 좁혀졌지만 데이터와 infra는 여전히 공개되지 않았다(README caveat 유지).
_home_direct_packed_nano는 이 오버라이드의 LR, optim_config, emb_optim_config를 상속한다. 이전에는 nano가 따로 learning_rate=2e-3이었지만, 이 커밋부터 GB300 기준으로 통일됐다.
의미: X가 하드웨어 세대 전환을 코드에 드러낸 첫 사례다. NVIDIA GB300(Grace Blackwell 300)은 2025년 하반기에 출하가 시작된 최신 데이터센터 GPU로, Blackwell B200의 후속이다. LLM 회사들이 대량으로 확보하는 세대인데, xAI는 이 세대를 랭커 학습·서빙 파이프라인에도 그대로 적용했다. canon이 이전에 몰랐던 사실은 Phoenix 프로덕션 학습이 GB300에서 돈다는 것이다. nano조차 GB300 config로 튜닝됐다는 것은 export 자체가 GB300을 상정한 재현 시나리오라는 뜻이다.
xrecsys_search는 For You 피드 학습 config xrecsys 옆에 나란히 추가됐다. For You config와 비교한 주요 차이는 다음과 같다.
vocab이 크게 늘었다. 검색 쿼리·클릭 코퍼스는 사용자·아이템 다양성이 커서, For You config의 훨씬 작은 vocab보다 vocab을 키워야 한다.
값은 user_vocab_size=100_000_000, item_vocab_size=100_000_000, author_vocab_size=30_000_000, ip_vocab_size=10_000_000이다.
아키텍처 값은 num_layers=8, emb_size=2560, emb_table_width=1024, query_heads=20, kv_heads=4(GQA), history_seq_len=1022, candidate_seq_len=64다.
분산 설정은 ep=512, dp=2, fsdp=1, tp=1이다. expert-parallel 512로 For You base config보다 EP 축이 훨씬 크고, MoE 라우팅도 검색 config에서 훨씬 크다.
검색에 특화된 플래그가 있다. use_product_surface=True는 제품 지면 컨디셔닝을 켜고, output_vocab_size=128은 action을 128종으로 늘린다.
나머지는 condition_search_relevance_on_prompt=True, use_dense_action_table=True, metric_group="search"다.
학습 신호는 group_id="user_action_sequence_xrecsys"라는 별도 그룹을 쓰고, log_q_correction=True다.
학습 규모는 total_samples=1e11로 For You base와 같다.
의미: Phoenix가 For You 피드 랭커에서 검색 지면 랭커로 확장되고 있다. 이 config는 proto에서 161/162에서 105/106으로 번호를 옮긴 action 두 개(CLIENT_TWEET_RELEVANT_TO_SEARCH = 105, CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106)와 짝을 이룬다(예측 출력 스키마). 이전에 canon은 각 지면(For You, Following, 검색)이 별개 코드베이스라고 이해했다. 이 커밋으로 같은 Phoenix 트랜스포머 아키텍처가 지면별 config만 갈아 끼워 검색과 피드를 모두 서빙하는 방향으로 통합이 진행 중이라는 새 사실이 확인됐다. Netflix UniCoRn·GenRec의 "검색·추천 단일 랭커" 흐름과 방향이 같다(topics/llm-in-recsys.md).
maybe_build_retrieval_post_embeddings는 이전에 shard 전체를 candidate_tower_forward_jit 한 번으로 계산했다. 이 커밋부터는 total_samples % data_world_size == 0인 균등 shard에서 _chunk_rows = min(_shard_rows, 65536) 단위로 나눠 계산한다. Muon 승격과 GB300 config로 학습 규모가 커지는 흐름에서 이 서브루틴을 안정화한 것이다.
예측 출력 스키마와 slate context
2026-08-19 커밋(4세대+4)에서 Phoenix 예측 출력 스키마에 reward 출력과 링크 열림 확률의 5분위 헤드가 생겼다 확인. home-mixer가 Phoenix 서빙이 계산한 slate context를 받아 쓰는 경로는 2026-08-20 커밋(4세대+5)에 들어왔고, 그 게이트의 기본값은 false였다 확인.
이 컬럼이 서빙 응답의 rewardRerankSlotProb 필드를 모은 것이라는 연결은 이름이 같다는 점에 기댄 것이다 추정.
링크 열림 확률을 단일 스칼라가 아니라 분위 5개(P10/P25/P50/P75/P90)의 분포로 예측하는 헤드가 생겼다 확인 — recsys.proto.
PLACE_HOLDER_167/168/169/170/253 다섯 자리가 각각 P_OPEN_LINK_P10·P_OPEN_LINK_P25·P_OPEN_LINK_P50·P_OPEN_LINK_P75·P_OPEN_LINK_P90으로 바뀌었다.
이 커밋 전까지 X의 예측 헤드는 단일 확률·스칼라 계열이었다. 랭커에 분포 형태의 예측이 들어온 것은 이 헤드가 처음이다.
같은 proto 변경에는 이런 것도 있다.
검색 관련성 action이 CLIENT_TWEET_RELEVANT_TO_SEARCH = 105와 CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106으로 다시 번호를 받았다 확인 — recsys.proto. 기존 161/162는 *_DEPRECATED로 남았다. 이 action은 xrecsys_search config와 짝을 이룬다.
SlateContext에 SID 필드 7개가 생겼다 확인 — recsys.proto. 필드는 sidKnown, sidK1/2/3, sidGap1/2/3이다. 2026-08-18 커밋(4세대+3)이 VMRanker에 SID를 연결한 것과 proto 수준에서 맞춘 변경이다.
다음 질문은 심층 분석이 필요하다.
RewardOutputs가 실제로 어느 학습 루프에서 채워지는가. 이 커밋에서 recsys_model.py가 +265/−25로 크게 바뀌었다. actor-critic 헤드를 더했는지, reward model을 따로 학습하는지가 관건이다.
4세대+4에서 Phoenix proto에 생긴 SlateContext.sid* 필드가 이 커밋에서 실제 파이프라인의 소비 경로에 연결됐다. 세 파일(candidate.rs, phoenix_scorer.rs, ranking_scorer.rs)에서 네 군데가 동시에 바뀌었다.
PostCandidate에 served_slate_context: Option<SlateContext> 필드가 새로 생겼다. 기존 slate_context(home-mixer가 로컬에서 계산한 뒤 캐시하는 값)와 나란한 두 번째 저장 슬롯이고, Phoenix 응답에서 온 slate context만 담는다.
proto와 Rust 사이의 변환 impl From<xai_recsys_proto::SlateContext>이 생겼다. 이 impl은 proto의 sid_k1/2/3·sid_gap1/2/3을 home-mixer 명명인 sid_k_l1/l2/l3·sid_gap_l1/l2/l3로 바꾼다. "l"은 level을 뜻한다.
PhoenixScorer가 예측 응답에서 slate context를 꺼내 이 필드에 넣는다. Phoenix 예측 응답이 후보 스코어 24개와 함께 SlateContext를 담아 오면 그대로 쓴다.
이때 SlateContext에는 pool_rank, pool_rank_gap, fatigue, pre_diversity_score, sid_known, sid_k_l1/2/3, sid_gap_l1/2/3이 들어 있다.
코드는 predictions.candidate_slate_context(&c.get_original_tweet_id()).map(Into::into)다.
RankingScorer는 세 갈래 폴백으로 어느 slate context를 쓸지 정한다. 새 게이트는 UseServedSlateContext이고 기본값은 false다.
let persisted_contexts: Option<Vec<SlateContext>> =
match Self::served_slate_contexts(query, candidates) {
Some(served) => Some(served),
None if query.has_cached_posts => Self::stored_slate_contexts(candidates),
None => Some(Self::compute_slate_contexts(candidates, &weighted_scores)),
};
폴백은 위에서부터 차례로 적용되고, 두 랭킹 브랜치(mpn_scoring, diversity)가 모두 같은 폴백을 쓴다.
게이트가 켜져 있고 Phoenix가 모든 후보의 slate context를 보내 왔으면 그대로 쓴다.
게이트가 꺼져 있고 Redis 캐시가 히트하면 저장된 컨텍스트를 쓴다.
그 밖의 경우에는 로컬에서 계산한다.
의미: slate context를 계산하는 곳을 home-mixer 로컬에서 Phoenix 서빙으로 옮기는 마이그레이션의 첫 단계다. Phoenix는 예측을 만드는 순간 이미 각 후보의 pool 랭크·fatigue·pre-diversity·SID 3계층 정보를 알고 있다. 그러므로 home-mixer가 다시 계산하는 대신 서빙 응답에 실어 보내면 두 가지를 얻는다.
(a) 랭커 CPU를 아낀다.
(b) Phoenix가 가진 더 풍부한 컨텍스트를 반영할 수 있다. SID 3계층은 home-mixer가 로컬에서 계산할 수 없다.
2026-08-18 커밋(4세대+3)에서 SID가 VMRanker에 연결된 것과 방향이 같다. SID는 정의상 Phoenix 학습 산출물이라 로컬에서 계산할 수 없다. 이 커밋은 UseServedSlateContext를 기본 false로 두어 게이트만 열어 두었고, 실제 롤아웃은 후속 파라미터 변경으로 예정돼 있다. 앞으로 지켜볼 것은 이 게이트를 true로 올리는 커밋이다. 그 커밋이 Phoenix 서빙의 slate context가 프로덕션 트래픽에 반영되는 시점이다.
SlateContext는 4세대+4에서 ScoreInfo의 필드 slateContext = 4로 처음 등장했다. 재순위 슬레이트 결정에 붙는 컨텍스트다. 4세대+5에서는 PostCandidate.served_slate_context로 연결돼 home-mixer가 쓰기 시작했다. 이 커밋에서는 각 후보의 action별 예측 분포인 NextActionDistribution에도 SlateContext slateContext = 9 필드가 추가됐다. 각 액션 분포마다도 slate context가 붙는 것이다. 이 커밋부터 slate context는 ScoreInfo와 NextActionDistribution 두 자리에 실릴 수 있다. 소비 경로는 아직 연결되지 않았고 스캐폴딩 단계다 추정.
랭커 입력 피처와 SID 재구성
Phoenix 랭커의 bool 입력 피처 세 개는 2026-08-14 커밋(4세대+1)부터 실제 값으로 채워진다 확인. 이 셋은 2026-08-19 커밋(4세대+4)에서 학습 기본 입력이 됐다 확인. SID 코드를 dense 벡터로 되돌려 랭커 입력에 더하는 recon 모드는 4세대+13 커밋(2026-09-01, 7ba7768)에서 공개됐지만, 공개 당시 기본값은 기존 learned 모드였다 확인.
enable_stale_post = true이고 now - creation_ts > STALE_POST_14D_TTL_SEC이면, 이 후보(와 이력)의 fav/reply/retweet/quote/view count를 전부 0으로 stamp하고 이 bool을 true로 둔다. 14일 지난 포스트의 engagement 신호는 모두 무시하고 랭커에게 "stale"만 알려 주는 셈이다.
IS_AUTHOR_FOLLOWED_BY_VIEWER_SEQ
뷰어가 이 후보의 작성자를 팔로우하는가(후보·이력 시퀀스별)
IS_AUTHOR_FOLLOWING_VIEWER_SEQ
후보의 작성자가 뷰어를 팔로우하는가(bidirectional 감지)
3세대부터 4세대 초까지의 코드에서는 bool_features가 vec![false; ...]로 전부 stamp돼 있어, 입력 자체가 실효가 없었다(canon 주의 목록의 "죽은 필드" 계열). 4세대+1에서 세 개가 처음 채워졌다 확인.
이 커밋은 X가 RQ-VAE 계열 SID를 랭커 입력에 붙이는 경로를 코드로 처음 공개한 것이다. retrieval 모델을 SID로 바꾼 것이 아니라, SID 코드를 랭커 post tower의 입력 임베딩으로 재구성해 넣는 경로다.
_load_sid_decoder(path)가 오프라인 .safetensors에서 아래 텐서를 읽는다.
stages는 형태가 (num_levels, codebook_size, code_dim)인 stacked residual codebook(RQ-VAE style)이다. 레벨마다 stages[l, code_l, :]를 골라 더한다. codebook 크기와 레벨 수는 아티팩트가 정하며, 코드에는 하드코딩되지 않았다.
(b) 4세대+4(aad7179, 2026-08-19)에서 Phoenix proto에 SlateContext.sid* 필드가 생겼다.
(c) 4세대+5(d0cef2f, 2026-08-20)에서 서빙 응답의 slate context에 SID가 실려 오는 경로가 생겼다.
decoder는 완전히 frozen이다. stop_gradient와 오프라인 아티팩트를 쓰므로 서빙에는 codebook과 MLP 매트릭스만 배포하면 되고, 학습되는 것은 recon_dim → target_dim project 하나뿐이다. codebook 튜닝은 오프라인에서만 이뤄지고, 랭커 학습이 codebook을 흔들지 않는다.
"learned"와 "recon"이 A/B 축이 된다. 기본값이 그대로 learned인 것은 recon 모드의 프로덕션 유효성이 아직 검증되지 않았거나 dial-up 이전이라는 뜻으로 읽힌다 추정. 다음 커밋에서 sid_embedding_mode="recon"이 프로덕션 config에 나오면, X SID 파이프라인이 "학습 테이블"에서 "오프라인 codebook + 서빙 시 recon"으로 옮겨 갔다는 신호가 된다.
이 경로는 retrieval 모델이 아니다. codebook에서 벡터를 복원해 랭커 입력에 붙이는 것은 후보 표현의 밀도를 높이는 일이지, 후보 생성 자체를 SID로 대체하는 일은 아니다. Kuaishou OneRec, Tencent TGR(radar.md 생성형 retrieval 스레드)의 "SID를 autoregressive 생성 대상으로 삼는" 접근과는 계보가 다르다. 이 커밋 기준으로 X는 아직 캐스케이드 안에서 SID를 입력 신호로 쓴다. SID를 후보 생성에 쓰는 generative retrieval 소스는 그 뒤 기본 off로 공개됐다 확인(후보 생성 절).
이 커밋에서도 공개되지 않은 것이 있다.
아티팩트를 만드는 SID 학습 파이프라인(encoder와 VQ 학습)의 코드
codebook 크기와 레벨 수의 실측 값
프로덕션에서 sid_embedding_mode="recon"이 켜져 있는지 여부
learned와 recon 두 모드의 A/B 지표
Phoenix retrieval 인덱스
Phoenix retrieval이 조회하는 포스트 인덱스와, 포스트가 어느 인덱스에 들어갈지 정하는 이벤트 층이 4세대에서 공개됐다 확인. 포스트에는 인덱스에 들어가기 전에 visibility 판단이 붙는다 확인. 2026-08-17 커밋(4세대+2)부터는 좋아요가 없는 후보도 인덱싱 이벤트로 발행된다 확인.
기존 video_48h·video_96h·nsfw_video_48h/168h/14day와 짝을 이룬다.
2주 시간창의 비디오 인기도 신호가 retrieval SID에 들어갔다.
2026-10-20 커밋(4세대+45)에서 시간 단위 영상 window video_6h와 video_6hto2day가 더해져, SID 인덱스의 video window 가운데 가장 짧은 것이 48시간에서 6시간이 됐다 확인 — phoenix-rankall/src/config/mod.rs:212-213.
인덱싱 파이프라인은 랭킹 쪽 visibility 규칙과 이미 맞춰져 있다. 포스트에는 인덱스에 들어가기 전에 이미 visibility 판단이 붙는다. 3세대까지 canon이 완전히 몰랐던 아키텍처 사실이다.
build1FavIndex는 이 커밋부터 모든 후보를 발행한다. 다시 말해 "1fav" 인덱스 이름은 backward compat 라벨이 됐고, 실제 인덱스는 유입 후보 전체를 담는다.
변경 전에는 favoriteCount >= 1인 후보만 "1fav" 인덱싱 이벤트를 Kafka로 발행했다.
변경 후에는 favoriteCount == 0인 후보만 새 카운터 1fav_uec_count_race로 계측한다.
buildMMEmbMetadataDump도 게이트가 사라졌고, 모든 후보에 대해 멀티모달 임베딩 세 버전을 fetch해 덤프한다.
변경 전에는 같은 favoriteCount >= 1 게이트로 fav가 있는 후보만 세 버전(v3·v5_1·v6_dev)을 조회해 Kafka 메타데이터로 덤프했다.
새로 생긴 주의점이다. canon이 3세대~4세대에서 반영한 "phoenix-rankall이 인기 신호로 인덱스를 갱신한다"는 이해는 이 커밋부터 정확하지 않다. 인덱스 이름이 필터 조건을 뜻하지는 않는다.
의미: Phoenix retrieval이 롱테일 포스트에도 임베딩 기반 매칭을 시도할 수 있게 하는 인프라 확장 방향이다 추정. 다음 커밋에서는 두 가지를 본다. 1fav_uec_count_race 카운터의 실제 관측치가 어느 비율로 나오는지, 인덱스 커버리지 확대가 어떤 param 게이트(Phoenix retrieval의 top-k, SID window)와 짝지어 켜지는지다.
Op.execute[(Long, Event), Unit] (튜플에서 key로 온 postId를 우선 사용)
Op.execute[Event, Unit] (ctx.arg로 postId를 event 안에서 파싱)
이 마이그레이션은 콘텐츠 이해(content understanding) 도메인의 공유 이벤트 스트림을 v1에서 v3로 올린다. 바뀐 방향은 두 가지다.
(a) postId를 Kafka key로 올려, 파티셔닝·중복 제거·역인덱스 조회를 순수하게 key 기반으로 할 수 있게 했다.
(b) Phoenix retrieval용 Kafka 클러스터를 X 메인에서 떼어 내, 격리와 튜닝을 따로 할 수 있게 했다.
hydra/sharedConfig로 import를 옮긴 것도 Phoenix 계열이 hydra 인프라 아래로 편입되고 있다는 신호다.
이 변경으로 Phoenix retrieval 인덱스 유지(build1FavIndex·buildMMEmbMetadataDump)의 upstream 이벤트 경로가 X 메인 Kafka에서 Phoenix 격리 Kafka로 완전히 옮겨 갔다. 4세대+2의 "인덱싱 게이트 제거"와 방향이 같다. 인덱싱 파이프라인의 처리량이 커지므로 별도 클러스터가 필요해졌다고 보면 방향이 들어맞는다 추정.
공개되지 않은 것도 있다. v2가 있었는지(v1에서 v3로 바로 뛰었는지, v2에서 v3로 가는 세 번째 마이그레이션인지), Phoenix Kafka 클러스터의 규모와 격리 이유, v1 forwarder의 최종 shutdown 시점이다.
학습·서빙 인프라
2026-08-20 커밋(4세대+5)은 임베딩 테이블 로더의 core를 pyo3에 묶이지 않은 함수로 따로 뽑았다 확인. 2026-08-21 커밋(4세대+6)은 hotswap 뒤 메모리를 돌려주는 옵션과 내부 오브젝트 스토리지 xai-o2의 인터페이스를 공개했다 확인. 학습한 랭킹 모델을 CUDA 서빙용 StableHLO 번들로 내보내는 경로는 4세대+13 커밋(2026-09-01, 7ba7768)에서 기본 off로 들어왔다 확인.
새 CLI 옵션 --hotswap_malloc_trim의 기본값은 True다. _malloc_trim_after_swap() 메소드가 hotswap cycle마다 마지막에 libc malloc_trim(0)을 호출한다. 관측 지표는 checkpoint_reload_step_seconds.labels(step="malloc_trim")이다.
CLI 도움말은 이렇게 쓴다.
glibc malloc_trim(0) on the coordinator thread after each applied hotswap; returns the cycle's freed pages (glibc slots otherwise ratchet ~196 MiB/cycle). No-op under jemalloc.
의미: Phoenix 서빙에서 embedding table hotswap이 glibc 아래에서 사이클마다 ~196MiB씩 RSS를 누수해 왔다는 사실이 처음 문서화됐다. hotswap은 프로덕션에서 오래 도는 인프라라, 이 패치가 필요할 만큼 누적 부담이 있었다는 관찰이다.
진입점 build_bundle(trainer)는 랭킹 모델의 forward를 jax.export로 낮춘다. 대상은 RecsysAggregatedModelConfig뿐이고, RecsysGenRecsModelConfig는 명시적으로 unsupported다. export 결과는 세 가지다.
jit(...).lower().as_text(dialect="stablehlo")로 만든 StableHLO MLIR bytecode(.mlirbc)
jax_export.export(platforms=("cuda",)).serialize()로 만든 .jax_export
MANIFEST.json
번들 구조에서 서빙 아키텍처의 특징이 드러난다.
임베딩 조회는 export된 그래프 밖에 있다. forward의 입력 merged_embeddings는 이미 룩업된 임베딩 로우들을 concat한 텐서다.
MANIFEST.embedding이 {rows, rows_padded, num_shards, width, dtype, merged_slices, hash_table}를 담고 있어, 서빙 측은 임베딩 서버 결과를 어떻게 concat할지 안다.
X 랭커의 서빙 아키텍처가 "임베딩 서버가 조회 → GPU 서버가 StableHLO 실행"이라는 분리 구조임이 코드에서 드러났다.
batch bucket이 여럿이다. export_bundle_bs_per_device의 기본값은 "1,2,4"다. 버킷마다 별도 프로그램을 lower하고, 서빙할 때 요청 배치 크기에 맞는 버킷을 고른다.
seqpack은 pallas_ranker_varlen_attn만 지원한다. FA4(cutedsl_ranker_varlen_attn)의 block-sparse 레이아웃은 명시적으로 NotImplementedError를 낸다.
4세대+6의 GB300 config가 attn_impl="cutedsl_ranker_varlen_attn"을 쓰므로, GB300 학습 config를 그대로 export하려면 attn kernel을 pallas로 바꿔야 한다 관찰. 이 제한이 GB300 학습에서 GB300 서빙으로 가는 파이프라인의 단절점이다 관찰.
custom-call target이 매니페스트에 실린다. ranker 전용 pallas/CUDA 커널이 StableHLO에 baked-in돼 있어, 서빙 로더가 커스텀 콜을 허용해야 한다. 허용 범위는 jax_export.DisabledSafetyCheck.custom_call(...)로 넓힌다.
sid_num_levels가 매니페스트 prep_spec에 echo된다. 서빙 측은 이 값으로 SID 레벨을 몇 개 배치에 실을지 안다. 이 필드는 SID recon 모드와 짝을 이룬다.
번들 export를 켜고 조정하는 것은 Trainer에 새로 생긴 필드 4개다.
export_stablehlo_bundle: bool = False
export_bundle_bs_per_device: str = "1,2,4"
export_bundle_history_seq_len: int = 0
export_bundle_candidate_seq_len: int = 0
export_stablehlo_bundle이 켜졌는데 checkpoint_config.copy_port가 꺼져 있으면 __post_init__에서 ValueError가 난다. 다시 말해 export는 checkpoint 배포 파이프라인의 copy_port(hot checkpoint 배포)에 붙어서만 켜진다. copy_port 배포 때마다 {OUT_PATH}/.{prefix}/export/에 번들이 함께 나간다.
의미
X가 JAX 학습에서 CUDA 서빙으로 넘기는 핸드오프를 StableHLO로 표준화한 코드를 처음 공개했다. 이전까지 canon의 이해는 "Phoenix가 JAX/Haiku로 학습되고 서빙은 별도 스택"이었다. 이 커밋으로 서빙 스택이 컴파일된 StableHLO를 받아 실행하는 형태임을 코드에서 확인할 수 있다.
임베딩과 dense forward의 명시적 분리가 아키텍처 사실로 확정됐다. 이는 X가 임베딩 테이블을 GPU 하나에 담지 않고 별도 서버(임베딩 서버와 mm_embedding_client.rs)에서 조회한다는 canon의 이해를 정확하게 뒷받침한다. 테이블 규모의 예는 4세대+4 xrecsys_search의 vocab 100M/100M/30M이다.
기본은 off다. 이 커밋만으로는 프로덕션 서빙 파이프라인이 실제로 StableHLO 번들을 소비하는지 확정할 수 없다. 다음 커밋에서 export_stablehlo_bundle=True가 프로덕션 config에 나오면, 서빙이 이 경로에 붙었다는 신호가 된다.
아직 공개되지 않은 것도 있다.
서빙 로더(StableHLO를 소비하는 런타임)의 코드
임베딩 서버가 merged_embeddings 텐서를 구성하는 세부 규약. MANIFEST.embedding의 merged_slices 스키마는 코드에 있지만, 소비자 측 코드는 공개되지 않았다.
링크한 orbax_encrypted.py는 새로 생긴 288줄짜리 파일이고, phoenix/xrex/train/misc.py(+5/0)와 phoenix/xrex/train/checkpoint_write.py(+6/0)도 함께 바뀌었다 확인 — 같은 커밋.
envelope encryption은 데이터를 데이터 키(DEK)로 암호화하고, 그 데이터 키를 다른 키로 다시 암호화해 두는 방식이다.
이 writer는 Orbax PyTreeCheckpointHandler를 감싸 다음을 강제한다.
체크포인트의 모든 파일은 매직 헤더로 시작해야 한다. 헤더가 없는 파일이 하나라도 있으면 커밋을 거부한다 확인 — 같은 커밋.
매직 헤더 값은 b"XAIENC01"다.
finalize 뒤에 assert_all_enveloped(path)가 checkpoint 디렉터리를 walk한다. 헤더 없는 파일을 찾으면 RuntimeError("encrypted save left plaintext at rest; not committing: ...")를 낸다.
_DEK와 _DEK.claim(KMS가 발급한 DEK 파일과 그 claim)은 예외다.
리프는 jax.Array만 허용하고, 문자열·dict·Python scalar 리프는 명시적으로 거부한다 확인 — 같은 커밋.
가드는 _ArraysOnlyRegistry와 _require_array_leaves다.
이 가드는 서빙 측에서 이 checkpoint를 로드할 때의 임의 코드 실행 리스크를 없앤다.
tensorstore KvStore도 암호화된 wrapper로 교체한다 확인 — 같은 커밋.
해당 옵션은 use_encrypted_kvstore다.
메타데이터도 암호화한다 확인 — 같은 커밋.
_METADATA, _CHECKPOINT_METADATA, sharding metadata를 모두 encrypt_write로 쓴다.
암호화된 checkpoint에서 restore한 프로세스는 평문으로 저장할 수 없다(downgrade 금지) 확인 — 같은 커밋.
새 가드는 checkpoint_write.py에 있다.
평문 저장을 시도하면 ValueError("restored from an ENCRYPTED checkpoint but the orbax writer cannot produce encrypted saves — refusing to downgrade to plaintext saves")를 낸다.
encryption_context 필드는 AWS-KMS의 encryption-context 패턴을 그대로 따른다.
청크 크기의 기본값은 8 MiB다.
의미
학습 체크포인트를 저장 상태(at rest)에서 암호화하는 writer가 코드로 들어왔다 확인. X가 프로덕션에서 이 writer를 쓴다고 본다 추정. 근거는 downgrade 금지 가드처럼 운영 사고를 겨냥한 장치까지 들어 있다는 점이다.
이전에는 인프라 층에서 처리하는 것으로만 짐작할 수 있었다(canon 미확인).
이제는 xrex 학습 파이썬 코드에 envelope encryption, KMS, 매직 헤더 강제가 in-tree로 들어 있다.
소스는 규제 프레임워크(SOC2·FedRAMP·GDPR 등)를 명시하지 않았다. 일반적인 "data at rest" 보호로 읽힌다.
CheckpointConfig.encrypt는 기본 False이고, 2026-10-17 커밋(862a123)까지 공개 학습 config 가운데 이 값을 켜는 곳은 없다 확인 — phoenix/xrex/train/misc.py:56. 그래서 프로덕션이 실제로 이 writer를 쓰는지는 코드만으로 확정할 수 없다.
downgrade 금지 가드가 특히 눈에 띈다. 실수로 암호화 상태를 잃는 일을 코드 수준에서 원천 차단한다.
4세대+7의 VF reference_compare 하니스가 APP_ENV=prod에서 부팅하면 panic을 일으켜 스테이징 전용을 강제했던 것과 같은 방향이다.
두 경우 모두 정책을 config 값이 아니라 fail-close 어설션으로 코드에 굳힌다.
Grox는 Grok 기반의 nearline 주석 파이프라인이다. 답글 스팸 판정과 답글 랭킹 채점 대상을 가르는 팔로워 임계는 2026-09-24 커밋(4세대+28)부터 250k다. 스레드 root와 답글 대상이 모두 이 값 이하인 답글은 스팸 판정을, 하나라도 넘는 답글은 답글 랭킹 채점을 받는다 확인. 이 임계는 4세대 공개의 15k에서 시작해 열한 번 올랐고, 올릴 때마다 스팸 판정 대상은 넓어지고 답글 랭킹 채점 대상은 좁아졌다 확인. 별도였던 스팸 감지 흐름은 2026-08-18 커밋(4세대+3)에서 답글 랭킹 흐름에 흡수됐다 확인. 두 임계는 그 뒤에도 바뀌었고, 바뀐 값은 변경 이력에서 coordinated spam 임계를 10k에서 30k로 올린 커밋의 임계 이력 정리와 답글 스팸 모델을 스레드 팔로워 기준으로 나눈 커밋에 있다 확인.
grox/는 slop score, 안전 라벨, 토픽, 답글 점수, 멀티모달 임베딩을 만든다. 4세대에서는 grox/가 상위 labeling 파이프라인의 Content Understanding 1단계로 명시적으로 자리 잡았다(안전성 절). 자세한 내용은 llm-in-recsys.md에 있다.
의미: 스레드 root와 답글 대상이 모두 팔로워 30k 이하인 답글은 grok 스팸 판정을, 하나라도 30k를 넘는 답글은 답글 랭킹 채점을 받는다 확인 — task_filter.py:77-92, 214-225. 임계를 두 배로 올려 스팸 판정 대상은 넓히고 답글 랭킹 채점 대상은 좁혔다. 원 코드 상수 이름에 "spam detection"·"reply ranking"이 붙어 있어, 이 조정은 어느 답글에 grok 리소스를 쓸지 정하는 리소스 배분 조정이다.
2026-08-18 커밋(4세대+3, 11a71f8)은 두 임계를 다시 올리고, 별도였던 스팸 감지 흐름을 답글 랭킹 흐름에 합쳤다 확인 — grox/flows/reply_spam/.
두 상수 모두 40,000 → 60,000으로 올랐다 확인 — 같은 커밋. 2026-08-17 커밋(4세대+2)의 30,000 → 40,000에 이은 세 번째 상향이다(b089ce6의 task_filter.py:17, 185).
root와 답글 대상이 모두 60k 이하인 답글만 grok 스팸 판정 대상이 되고, 나머지 답글은 답글 랭킹 채점을 받는다.
스팸 판정과 답글 랭킹 채점을 가르는 경계가 40k에서 60k로 옮겨 갔다.
post 전체 스트림에서 스팸을 감지하던 별도 task generator가 삭제되고, spam detection이 답글 랭킹 흐름으로 흡수됐다 확인 — 같은 커밋.
삭제된 generator는 PostStreamTaskGenerator다. TOPIC_UNIFIED_POSTS(v3 아닌 원본) 상수도 삭제됐다.
남은 ReplyRankingTaskGenerator가 아래 세 플랜을 함께 주입하고, TOPIC_UNIFIED_POSTS_V3 단일 소스에서 소비한다.
PlanReplyRanking(기존)
PlanSpamComment(신규 흡수)
PlanCoordinatedSpam(신규 흡수)
의미: grox 스팸 판정 파이프라인은 판정 대상 계정 확대(60k 이하)와 흐름 단일화로 정리됐다 확인 — 같은 커밋. 단일화는 reply ranking과의 통합을 가리킨다.
이 변경은 Phoenix unified_posts_v3 topic 마이그레이션(Phoenix 학습·인덱스 절)과 같은 커밋에서 나왔다. 그래서 grox와 Phoenix 양쪽이 v3 콘텐츠 이해 스트림으로 동시에 옮겨 갔다는 관찰이 붙는다.
grox 안전 판정의 심판(judge)은 1차 판정을 Grok 모델에게 한 번 더 판단하게 하는 계층이다. 성인 콘텐츠 심판은 2026-08-17 커밋(4세대+2)에서, 아동 안전·폭력 미디어·불법·규제 행위를 함께 맡는 심판 클래스(cross-validator)는 2026-08-24 커밋(4세대+7)에서 생겼다 확인(판정 흐름, 모델 변천). 두 심판은 각각 생긴 커밋(4세대+2와 4세대+7) 기준으로 1차 판정을 유지하거나 강등할 뿐, 더 엄격하게 바꾸지 않는다 확인(판정 흐름, 모델 변천). 그 뒤 고인기 영상 경로와 leading-frames 재스캔 경로에도 cross-validation 단계가 들어갔고, 불법·규제 행위 심판은 모델이 바뀌었다 확인(고인기 영상 경로를 만든 커밋, 4세대+15 B, 4세대+17).
표의 심판 모델 칸은 칸에 적은 커밋 기준이고, 나머지 칸은 심판 클래스가 생긴 커밋 기준이다.
판정 대상
심판 클래스
클래스가 생긴 커밋
심판 모델 (기준 커밋)
심판 결과에 따른 처리
성인 콘텐츠: deluxe 플로우에서 safemodel과 PtOS 판정이 어긋났고, 아직 NSFW 플래그가 없는 포스트
SafetyPtosAdultContentCrossValidationJudge
4세대+2 (2026-08-17)
Grok 4.5 (4세대+2 기준)
심판 판정이 AdultContentSexualHard가 아니면 AdultContentSexualSoft로 강등
ChildSafety
SafetyPtosPolicyCrossValidator
4세대+7 (2026-08-24)
EAPI_GROK_4_5_X_ALGO (4세대+15 기준)
불일치, 파싱 실패, 에러는 모두 NoViolation
ViolentMedia
SafetyPtosPolicyCrossValidator
4세대+7 (2026-08-24)
EAPI_GROK_4_5_X_ALGO (4세대+15 기준)
심판이 NoViolation으로 보면 강등, 유형이 다르면 ViolentMediaGraphicMedia로 강등
IllegalAndRegulatedBehaviors
SafetyPtosPolicyCrossValidator
4세대+7 (2026-08-24)
EAPI_GROK_4_1_FAST_X_ALGO (4세대+15부터)
불일치하면 NoViolation으로 강등
불법·규제 행위 심판은 심판 모델을 카테고리별로 나눈 커밋(4세대+15)부터 EAPI_GROK_4_5_X_ALGO 대신 표의 모델을 쓰고, 이 모델은 서킷브레이커 없이 직접 호출한다 확인(변경 이력의 4세대+15 B).
motion-reveal 기반 특수 영상 파이프라인을 만든 커밋의 고인기 영상 전용 경로에도 성인 콘텐츠 cross-validation 단계가 있다 확인(이 경로를 만든 커밋).
그 커밋 기준으로 이 경로만, 심판 호출이 예외로 실패하면 원 판정을 유지하지 않고 Soft로 강제 강등한다(다른 경로는 원 판정 유지가 기본) 확인 — 같은 소절.
4세대+17 커밋에서 생긴 성인 콘텐츠 leading-frames 재스캔 경로에도 cross-validation 단계가 있다 확인(변경 이력의 4세대+17).
4세대+17 기준으로 이 경로는 30초 이상 영상의 앞 10초만 잘라 다시 판정한다 확인 — 같은 소절.
4세대+17 기준으로 진입 대상은 fav 수가 deluxe 임계 이상이고, 이미 PTOS 판정을 받았지만 NSFW로 판정되지 않은 영상 가운데 일부다 확인 — 같은 소절.
broadcast를 PTOS 안전 파이프라인에 편입한 커밋(4세대+18)에서 이 경로는 deluxe fav 게이트가 빠졌고, 30초 이상 영상이 없어도 broadcast가 있으면 진입하게 됐다 확인(변경 이력의 4세대+18).
4세대+17 소절은 이 경로를 강등된 판정을 다시 뒤집을 수 있는 두 번째 심판으로 본다.
성인 콘텐츠 심판의 판정 흐름
성인 콘텐츠 판정에 판정 불일치에 대비한 심판(judge) 계층이 붙었다. 4세대에서 처음 공개된 grox 안전 파이프라인은 두 판정을 병렬로 돌리고, 결과가 다를 때는 로그만 남겼다.
두 판정은 TaskSafetyPtosSafemodelSexNudity(자체 비전 모델 기반 sex/nudity 판정)와 PtOS(정책 기반 위반 카테고리 판정)다.
두 판정을 비교한다. enum CompareOutcome이 BOTH_POSITIVE·BOTH_NEGATIVE·SAFEMODEL_ONLY_POSITIVE·PTOS_ONLY_POSITIVE를 구분한다. is_disagreement는 SAFEMODEL_ONLY 또는 PTOS_ONLY일 때 true다.
이미 NSFW 플래그된 포스트면 건너뛴다. 플래그는 StratoSafetyPostAnnotationsResultDirectMh로 조회한다.
Grok 4.5 심판을 부른다. 새 클래스 SafetyPtosAdultContentCrossValidationJudge가 ModelName.EAPI_GROK_4_5_X_ALGO를 쓴다.
전용 서킷브레이커는 실패율 50%, 600초 창, 회복 600초, half-open 5회다.
system 프롬프트는 _strip_thinking_restrictions(adult_content_policy_prompt())다. user 메시지는 UserRenderer.render(user)와 PostRenderer.render(post, include_reply_to=True)로 만든다.
응답은 re.compile(r"(.*)<json>(.*)</json>", re.DOTALL)로 파싱해, 자유 텍스트 사고 과정과 JSON 판정을 나눈다.
심판 결과를 적용한다. 심판이 AdultContentSexualHard이면 원래 판정을 유지한다. 그 밖의 결과이면 세 가지를 한다.
모든 AdultContent 위반의 safetyPolicy.policyType을 AdultContentSexualSoft로 강등한다.
심판이 hard/soft 이외의 policyType(예: 다른 성인 카테고리 세분화)을 반환해도 손실 없이 관측할 수 있게 됐다.
계측 세분화 조정이지 판정 로직 자체의 변경은 아니다.
심판 모델과 심판 대상 카테고리의 변천
4세대+7까지의 변화로 X는 primary와 judge를 서로 다른 모델 세대·튜닝으로 유지하는 이중 스택을 굳혔다. PTOS 분류기에서 Grok 4.5 internal을 부르던 두 호출 위치(아동 안전 심판과 고-fav deluxe 재분류)는 2026-08-19부터 08-21까지 세 커밋에 걸쳐 Grok 4.6 internal로 넘어갔다 확인(28e414f의 분류기). 사흘 뒤인 2026-08-24 커밋(4세대+7)에서는 4세대+2의 성인 콘텐츠 심판이 쓰던 4.5 X_ALGO 엔드포인트를 새 심판 클래스도 쓰게 됐고, 범위도 3개 카테고리로 넓어졌다 확인(d011592의 분류기).
4세대+4부터 +6까지 4.5 internal을 쓰던 두 호출 위치가 4.6 internal로 옮겨 갔고, 4세대+7에서 아동 안전 심판은 새 cross-validator 클래스로 옮겨 4.5 X_ALGO를 쓰게 됐다. 성인 콘텐츠 심판은 4세대+2부터 4.5 X_ALGO를 그대로 쓴다 확인. 커밋별 흐름은 다음과 같다.
4세대+2(b089ce6, 08-17): 성인 콘텐츠 전용 심판 SafetyPtosAdultContentCrossValidationJudge가 생겼다(safemodel↔PtOS disagreement 시). 모델은 EAPI_GROK_4_5_X_ALGO이고, 이 엔드포인트와 전용 서킷브레이커도 이 커밋에서 생겼다 확인 — classifier.py:85, 100, 561.
4세대+3(11a71f8, 08-18): 위 심판의 metric outcome을 세분화했다.
4세대+4(aad7179, 08-19): Grok 4.6 internal을 10% dial로 붙였다(고-fav deluxe 재분류와 아동 안전 심판 두 위치).
4세대+5(d0cef2f, 08-20): 4.6 internal dial을 10% → 30%로 올렸다.
4세대+7(d011592, 08-24): 4세대+2부터 성인 콘텐츠 심판이 쓰던 EAPI_GROK_4_5_X_ALGO를 새 cross-validator 클래스도 쓰게 됐고, 범위가 3개 카테고리(child safety, violent media, illegal)로 넓어졌다.
성인 콘텐츠 심판은 별개 클래스(SafetyPtosAdultContentCrossValidationJudge)로 여전히 유지된다.
심판이 primary와 같은 모델이면 같은 실수를 확대 재생산하므로, 별개 모델의 관점이 강등만 허용하는 것이 이 설계의 합리다.
_GROK_4_6_INTERNAL_DIAL = 0.1
...
if random.random() < _GROK_4_6_INTERNAL_DIAL:
async with _eapi_4_6_internal_breaker.guard():
raw = await self.eapi_4_6_internal.sample(...)
else:
async with _eapi_4_5_internal_breaker.guard():
raw = await self.eapi_4_5_internal.sample(...)
첫 위치는 아동 안전 카테고리 cross-model validation을 맡는 기존 함수 _cross_model_validate_with_4_5다. 메트릭명이 safety_ptos.cross_model_validate_with_grok_4_5에서 safety_ptos.child_safety_cross_model_validate_with_grok_4_5로 바뀌었다.
둘째 위치는 classify_policy_for_violation 안의 deluxe 4.3 카테고리 재분류다. fav_count >= 1024일 때 deluxe-4.5-internal 대신 10% 확률로 deluxe-4.6-internal을 쓴다.
이 위치는 심판이 아니라, fav가 높은 포스트의 1차 분류를 일반 sampler 대신 이 모델로 하는 경로다 확인 — classifier.py:461-489.
요청마다 10% 확률로 4.6을, 나머지는 4.5를 부르는 배타적 트래픽 분할이다 확인 — 위 코드. 같은 요청을 두 모델에 함께 보내는 dual-write나 shadow는 아니다.
4세대+5의 관전 포인트("다음 커밋에서 50%/100%로 뛰면 4.6 완전 전환 근접")가 하루 만에 100%로 해소됐다. 삭제된 항목은 일곱 개다.
_EAPI_4_5_INTERNAL_BREAKER_CONFIG
_eapi_4_5_internal_breaker (breaker instance)
_GROK_4_6_INTERNAL_DIAL 상수(하루 전 0.3)
self.eapi_4_5_internal (두 클래스에서 초기화 제거)
_sample_4_5_internal method
_cross_model_validate_with_4_5의 확률 분기 (이제 항상 4.6 internal 직접 호출)
classify_policy_for_violation의 deluxe 4.3 재분류 확률 분기 (이제 항상 deluxe-4.6-internal)
의미: 2026-08-19 커밋(4세대+4)에 시작한 4.6 internal dial-up(10%→30%→100%)이 이틀 만에 끝났다. Grok 4.5 internal은 분류기 코드와 grox config의 ModelName 목록에서 모두 빠져 롤백할 수 없다 확인 — grox/config/config.py:82-93. 아동 안전 심판과 deluxe 재분류는 Grok 4.6 internal로 단일화됐고, 성인 콘텐츠 심판은 계속 EAPI_GROK_4_5_X_ALGO를 쓴다. 관전 포인트는 4.5→4.6에서 4.6→4.7 전환 시점으로 옮겨간다.
하루 앞선 2026-08-20 커밋(4세대+5)에서는 grox의 다른 두 곳이 바뀌었다 확인 — aad7179와 d0cef2f 사이의 diff.
Grok classifier가 프로필 이미지를 판정 컨텍스트로 참조할 수 있게 됐다. 예를 들어 광고 브랜드 안전이나 성인 콘텐츠 심판에서 프로필 이미지도 판단 재료가 된다.
upstream 데이터 연결을 준비하는 단계다 추정.
2026-08-24 커밋(4세대+7)은 SafetyPtosPolicyCrossValidator를 새로 만들고, 4세대+2부터 있던 Grok 4.5 X_ALGO 엔드포인트를 이 judge에도 썼다.확인 — grox/flows/ptos/classifier.py (+223/−52)
4세대+6이 Grok 4.5 internal을 완전히 제거한 지 사흘 만에, 4.5 X_ALGO 엔드포인트가 새 판정 심판(cross-validator)에도 쓰였다. 범위는 3개 카테고리로 넓어졌다.
엔드포인트는 ModelName.EAPI_GROK_4_5_X_ALGO이고, 서킷브레이커 _eapi_4_5_x_algo_breaker(window 600초, 회복 600초, half-open 5회)를 쓴다 확인 — 같은 커밋. 둘 다 4세대+2(b089ce6)에서 성인 콘텐츠 심판용으로 이미 생겼다.
4세대+6에서 완전히 삭제된 EAPI_GROK_4_5_INTERNAL과는 별개 모델이다.
이름의 X_ALGO 접미사는 x-algorithm 전용 튜닝 변형을 시사한다 추정.
새 클래스 SafetyPtosPolicyCrossValidator의 진입점 validate(category, post, policy)는 세 카테고리를 각 전용 메서드로 보낸다 확인 — 같은 커밋.
ChildSafety(_validate_child_safety)는 4.5로 재확인한다. policy type이 일치하면 유지하고, 불일치하면 NoViolation으로 강등만 한다. 파싱 실패와 에러도 NoViolation으로 fallback한다. 4세대+2의 아동 안전 심판을 이 클래스로 흡수한 것이다.
이유는 근본적이다. "죽고 싶어" 같은 문장은 스레드 문맥 없이는 판정할 수 없다. 이전 발화가 게임에서 진 얘기면 무해하고, 자해 지원 스레드면 위중하다.
마찬가지로 폭력 발언도 인용과 응답 맥락이 판정을 결정한다.
다른 카테고리(성인·NSFW 미디어·불법)는 대상 post 자체가 신호를 담고 있어서 스레드 확장이 필요 없다.
4세대+7 커밋 기준으로 공개되지 않은 것은 네 가지다.
EAPI_GROK_4_5_X_ALGO가 4.5 base에 x-algorithm 도메인 파인튜닝을 더한 것인지, 완전 별개 모델인지
세 카테고리 확장의 A/B 결과
다음 확장 후보(스팸·혐오 발화)
SafetyPtosAdultContentCrossValidationJudge와 새 SafetyPtosPolicyCrossValidator의 관계(합쳐질지 병렬 유지될지)
두 클래스는 같은 함수 시그니처(judge vs validate)를 갖지만 라우팅 방식이 다르다.
grox 답글 스팸 경로와 LLM 입력의 변화
grox 답글 스팸 파이프라인은 4세대 공개(2026-08-13)부터 답글 랭킹 점수를 Strato에 되쓰는 경로를 갖고 있었고, 2026-08-24 커밋(4세대+7)은 이 경로에 지역 캐시 팬아웃을 추가했다 확인(47c1bcd의 저장 모듈, d011592의 저장 모듈). 2026-08-28 커밋(4세대+10) 기준으로, grox는 새 답글 랭킹 점수가 기존 점수보다 낮지 않으면 Manhattan 쓰기를 건너뛴다 확인(24c6094의 쓰기 태스크). 즉 이 경로로 나가는 답글 랭킹 점수는 시간이 지나도 낮아지기만 하고 오르지 않는다 확인(45b48ba의 쓰기 태스크).
이전 PostRenderer.render는 유저 메타를 Metadata: <name> @<handle> 한 줄로 뽑았다. 이제 include_follower_count, include_bio 두 옵션을 받아 다음처럼 나눠 렌더링한다.
User Handle: @<handle>
[User Follower Count: <N>] # include_follower_count=True
User Name: "<name>"
[User Bio: "<bio>"] # include_bio=True
ThreadRenderer._render_thread가 include_follower_count 인자를 받으면, 스레드 모든 포스트의 팔로워 카운트를 렌더링한다. bio는 post 0(원본, 스레드 root)에만 렌더링한다(include_bio=include_follower_count and post_idx == 0).
답글 대상(evaluate 대상)에도 팔로워 카운트를 포함한다.
메시지 앞에는 # Reply Author Info\n\nThe user profile and any signals below describe the author of the reply being evaluated (the final post of the thread).\n 헤더가 붙는다. 이 헤더는 LLM에게 "이 정보는 evaluate하려는 reply 작성자에 관한 것"이라고 명시한다.
헤더 아래에서 UserRenderer.render(post.user)가 답글 작성자의 이름, 핸들, bio를 따로 렌더링한다 확인 — thread.py:25-27, user.py:18-24. 그래서 bio는 원 포스트(post 0) 작성자와 평가 대상 답글 작성자 두 사람 것이 들어간다.
의미:
팔로워 카운트는 이미 grox reply_spam 게이팅 임계(4세대+7에서 100k)로 쓰이고 있었는데, 이제 LM 판정 자체에도 이 신호가 직접 참조된다.
Bio는 스팸/봇 탐지에 강한 근거라서, 원 post와 reply-under-eval 두 저자의 profile을 함께 노출하면 신뢰도가 올라갈 것으로 기대된다.
근거가 되는 bio 내용은 "링크 in 프로필" 같은 스팸 힌트, 정치·상업 조직 소속, 관심사다.
다른 스레드 참여자의 bio는 넣지 않고(context 폭주 방지) 두 저자만 profile을 확장한다.
동점도 쓰지 않는다. 비교 연산자가 >에서 >=로 바뀌어, score >= existing.score이면 건너뛴다.
이전에는 새 스코어가 기존과 같으면 그대로 썼다(idempotent write).
정확히 같은 스코어가 다시 계산됐을 때 Manhattan put을 아끼고, Kafka 이벤트도 나가지 않는다.
카운터 이름도 higher_than_existing → not_lower_than_existing으로 바꿔 시맨틱을 분명히 했다.
Kafka 이벤트를 Manhattan 쓰기 앞으로 옮겼다. 이전 순서는 save_reply_ranking_score(Manhattan) → save_reply_ranking_kafka_v2(Kafka)였고, 새 순서는 그 반대인 Kafka publish first, Manhattan write second다.
이유 추정: Kafka는 append-only라 실패 시 재시도할 수 있다. 반면 Manhattan은 monotone-only 로직에 걸려 not_lower_than_existing 판정이 나와 재시도 자체가 생략될 수 있다. 또 Manhattan은 side-effect가 있는 upsert라 Kafka publish보다 무겁다. Kafka를 먼저 성공시키면 오프라인 재처리 파이프라인이 Manhattan write 실패를 나중에 복구할 수 있다. 반대로 Manhattan을 먼저 성공시키면, Kafka publish가 실패했을 때 downstream(서빙 캐시 warm-back, 다른 소비자)이 Manhattan에 이미 반영된 새 스코어를 보지 못한다. 즉 Kafka-first가 시스템 전체의 관측 가능성 정합성을 보장하는 durability 순서다.
fetch_reply_ranking_score의 try/except를 걷어냈다. 이전에는 fetch에 실패하면 warning 로그를 남기고 existing = None으로 진행해 항상 썼다. 이제는 예외를 그대로 위로 던져, flow task가 실패로 표시되고 재시도된다.
fetch가 조용히 실패해 monotone 비교가 무효화되고 write가 튀는 사고를 원천 차단한다.
classifier_coordinated_spam.py의 _to_convo에 인자가 추가됐다. 이제 PostRenderer.render(p, max_media=MAX_MEDIA_PER_POST)를 include_follower_count=True로 호출한다.
coordinated spam 판정은 스레드 각 참여 계정의 팔로워 카운트를 프롬프트로 받는다. bio는 include_bio=False 기본을 유지하고, 팔로워 카운트만 넣는다.
의미: 4세대+9가 도입한 두 축은 4세대+10에서 이렇게 바뀌었다. LM 프롬프트 유저 메타 확장은 상시 활성화됐다. Manhattan monotone-only는 idempotency 강화와 Kafka-first 순서로 정합성이 강화됐다. reply_spam 판정 스택의 durability 계약이 훨씬 명시적이 됐다.
이 목록은 X 공개 소스를 읽을 때 "코드에 있으니 사실이다"라고 판단하지 않도록 주의할 점을 모은 것이다. 2026-08-15 갱신 기준으로 4세대 xai-org/x-algorithm에는 가중치와 상수 값이 채워졌고, Phoenix도 완전히 빌드할 수 있게 됐지만 프로덕션 규모는 아니다 확인(1번 항목). 3세대 canon이 레포 밖이라고 본 점수 후처리 유틸과 외부 서비스 클라이언트 모듈도 4세대 공개에는 들어 있다 확인(1번 항목). 3세대 코드에서는 어떤 모듈에도 연결되지 않은 파일과, 리터럴 치환으로 문법이 깨진 파일도 관찰됐다 확인(2·3번 항목).
xai-org/x-algorithm은 3세대까지 빌드되지 않았고, 4세대에서 일부가 해소됐다. ⚠ 이 항목은 2026-08-15에 갱신했다.
3세대에 없던 crate::params는 4세대에서 home-mixer/params/param.rs로 채워졌다 확인.
이 파일은 1084줄이고, 가중치와 상수의 프로덕션 크론 미러다.
FavoriteWeight 같은 심볼이 호출부만 있고 정의가 없다는 canon의 3세대 관찰은 해소됐다 확인.
3세대에 없던 crate::util은 4세대 공개에서 home-mixer/util/로 채워졌다 확인 — home-mixer/lib.rs.
3세대 코드가 부르던 score_normalizer는 4세대에 없고, 정규화 단계 자체가 빠졌다(Phoenix 절).
crate::clients도 4세대 공개에서 home-mixer/clients/(파일 19개)로 채워졌다 확인 — home-mixer/clients/.
Phoenix는 4세대에서 완전히 빌드할 수 있게 됐다 확인.
Cargo workspace, pyproject.toml, QUICKSTART, 합성 데이터(synthetic data)가 함께 들어 있다.
다만 README는 작은 모델을 학습하고 서빙할 수 있다고 명시한다: "a small model can be trained and served". 프로덕션 규모는 아니다 확인.
옆 크레이트(component_library)가 4세대에서도 공개되지 않은 상태인지는 심층 분석이 필요하다.
위 변화를 반영해 레포의 성격을 다시 정의한다. 3세대까지는 "로직의 형태만 공개, 값·배선 빠짐"이었고, 4세대는 "값 공개 + 안전 규칙 공개 + Phoenix end-to-end 실행 가능, 단 서비스 접착층은 여전히 미공개"로 옮겨 갔다. 빠진 부분은 우연한 누락이 아니라 여전히 일관된 성격을 띤다.
3세대(0bfc279) 코드에서는 README가 꼽는 스코어러 3개가 모듈 트리에서 끊겨 있었다. README는 "Weighted Scorer / Author Diversity Scorer / OON Scorer"라고 썼지만, 실제 파이프라인은 vec![phoenix_scorer, ranking_scorer, vm_ranker]였다 확인 — phoenix_candidate_pipeline.rs:299-300 vs README.md:251-255
세 파일은 scorers/mod.rs가 선언하지 않고, 외부 참조도 0건이다 확인.
세 파일은 weighted_scorer.rs, author_diversity_scorer.rs, oon_scorer.rs이고, 구조체 이름은 WeightedScorer/AuthorDiversityScorer/OONScorer다.
그런데 그 파일들에는 하드코딩 상수가 있어서, README를 따라간 독자는 도달할 수 없는 상수를 프로덕션 값으로 착각하게 된다 확인.
하드코딩 상수의 예는 p::AUTHOR_DIVERSITY_DECAY다.
연결되지 않은 파일은 이 셋만이 아니다. home-mixer/ 안에는 어떤 mod 선언에도 걸리지 않은 .rs 파일이 11개 있다 확인.
판별법(3세대 기준): 죽은 파일은 crate::candidate_pipeline::{candidate, query}를 import하고, 살아 있는 ranking_scorer.rs는 crate::models::{candidate, query}를 import한다. 같은 이름의 타입이 두 디렉토리에 있으며, models/가 현행이고 candidate_pipeline/이 구세대다 추정. 연결 방향으로만 추론했고, 커밋 이력에는 단서가 없다.
pub(crate) mod도 선언이다. ads/util.rs는 ads/mod.rs:3에 pub(crate) mod util;로 걸려 있어 살아 있다 확인.
^\s*(?:pub )?mod 같은 정규식으로 세면 이 파일을 죽은 파일로 잘못 판단한다 확인.
4세대 공개(47c1bcd)에서는 이 11개 가운데 10개가 레포에서 사라졌고, engagement_counts_hydrator.rs는 candidate_hydrators/mod.rs:6에 선언돼 연결됐다 확인. 같은 공개의 README도 이 세 스코어러를 더 이상 나열하지 않는다 확인 — README.md.
3세대(0bfc279) 코드에서는 문자열과 숫자 리터럴이 ""로 기계적으로 치환된 곳이 있었다. 문법 검사를 하지 않아서 컴파일이나 파싱이 깨진 파일이 있었다 확인.
예를 들어 grox/classifiers/content/safety_ptos.py:241-244는 and가 피연산자 없이 남아 SyntaxError가 나고, 그래서 PTOS plan 자체를 import할 수 없다 확인.
grox/tasks/task_summarizer_for_post_embedding.py:14는 import 시점에 FileNotFoundError를 던져서, Grox가 아예 기동하지 못한다확인.
두 파일 모두 4세대 공개(47c1bcd)에는 없다 확인.
git log --diff-filter=D <commit>은 그 커밋의 삭제가 아니라 조상 전체의 삭제를 보여 준다. 이 명령의 결과로 "2025 리프레시가 DiversityScorer.scala를 지웠다"고 결론 내리면 틀린다. 그 파일은 2023-07-07에 지워졌다 확인.
the-algorithm-ml 클론이 shallow clone이면 모든 파일이 "new file"로 보인다. 그런 클론으로는 가중치 히스토리를 읽으면 안 된다 확인.
has_cached_posts가 가장 영향이 큰 스위치인데, 모델이 아니라 캐시 임계값이다. Redis 워밍 캐시에 500개 이상 쌓여 있으면 이 불리언이 켜지고, 그러면 retrieval 소스와 상당수 하이드레이터가 꺼진다 확인 — cached_posts_query_hydrator.rs:11, 69
2026-10-17 커밋(862a123) 기준으로 home-mixer 안에서 !query.has_cached_posts 게이트가 있는 파일은 25개다. retrieval 소스 8개와 후보 하이드레이터 12개가 여기에 든다 확인 — 862a123 home-mixer/ grep. 3세대 코드(0bfc279)에서는 18개 파일이었다.
같은 기준에서 Phoenix 스코어러에도 has_cached_posts 분기가 있다 확인 — phoenix_scorer.rs:70. EngagementCountsHydrator는 2026-09-04 커밋(4세대+16)에서 이 게이트를 뺐다(콜드스타트 슬롯 선택기).
스크롤 중의 "더 보기"는 상당 부분 모델 추론 없이 이전 요청의 잔여 후보로 응답된다 확인.
그 비중이 얼마인지는 추정조차 할 수 없다. 레포에 단서가 없다.
3세대(0bfc279) 코드 기준으로 CoreDataHydrationFilter는 이름과 달리 코어 데이터 조회에 실패한 후보를 실제로는 거르지 못한다. 하이드레이터가 author_id를 쓰지 않기 때문이다 확인(코드) — core_data_candidate_hydrator.rs:108-113, filters/core_data_hydration_filter.rs:13
README는 이 필터를 "코어 메타데이터 hydration 실패한 포스트 제거"라고 설명한다.
버그로 본다 추정. 코드 흐름상 다른 설명이 어렵다.
3세대(0bfc279) 코드 기준으로 실패가 전부 조용하다. 프레임워크 레벨의 타임아웃도, 서킷브레이커도, 폴백도 없다 확인 — candidate-pipeline/candidate_pipeline.rs:202-218, 266, 419-428
에러가 난 소스와 쿼리 하이드레이터는 사라진다 확인.
후보 하이드레이터에서 에러가 나면 해당 필드가 설정되지 않은 채로 남는다 확인.
사이드이펙트는 detached로 실행된다 확인.
3세대(0bfc279) 코드 기준으로 로깅이 샘플링돼 있다. 가장 풍부한 두 로그 스트림이 대표성 없는 슬라이스다 확인 — side_effects/reranking_kafka_side_effect.rs:14,29
리랭킹 Kafka 로그는 프로덕션에서 5%를 샘플링하고, 상위 50개만 남긴다 확인.
나머지 하나는 shadow traffic에서만 남는다 확인.
2025 Scala 코드에는 주석이 코드와 어긋난 곳이 있다. 저신호 유저 게이트의 주석 두 곳은 "< 25 users"라고 쓰지만, 실제 상수는 SmallFollowGraphSize = 5다. 주석을 근거로 임계값을 인용하면 5배 틀린다 확인 — SignalUtils.scala:17-23 vs AllowLowSignalUserGate.scala:12, DenyLowSignalUserGate.scala:12
"랭커 점수"라는 단일 값은 없다. 2025 MMR과 2026 VMRanker는 둘 다 score 필드를 덮어쓴다. 로그나 코드에서 점수를 볼 때는 어느 단계의 값인지 확인해야 한다. 2026 코드가 weighted_score와 score를 별도 필드로 남기는 이유가 이것이다 확인 — DiversityRescoringFeatureHydrator.scala:70, vm_ranker.rs:47-52, ranking_scorer.rs:277-281(둘 다 3세대 코드). 2026-10-17 커밋(862a123)에서도 VMRanker가 두 필드를 따로 채운다(vm_ranker.rs:93-131).
4세대 공개(2026-08-13) 뒤에도 X 공개 코드로 답하지 못한 질문 가운데 가장 최근 것은 2026-10-16·10-17 커밋(4세대+43·+44)이 남긴 질문이다(변경 이력의 4세대+43, 4세대+44). 그 앞은 2026-10-14 커밋(4세대+41)이 남긴 심층 분석 과제다(변경 이력의 4세대+41). 랭킹 요청에 추가된 피드 요청 맥락 값은 2026-10-13 커밋(4세대+40)부터 광고 인덱스 요청에만 채워진다 확인(변경 이력의 4세대+40). 같은 커밋 기준으로 Phoenix 랭킹 요청 쪽은 아직 답이 없다.
미해소·심층 분석 필요 (4세대 공개 이후)
4세대 공개 뒤에도 공개 코드로 답하지 못했거나 심층 분석이 필요한 질문이다. 취소선을 그은 항목은 2026-09-17 커밋(4세대+23)에서 풀렸다.
has_cached_posts 경로가 실제 노출의 몇 %인가. 외부에서 추정할 방법이 있는가. 레포에 단서가 여전히 없어 추정조차 못 한다.
SimClusters 코어 알고리즘의 세부를 아직 확인하지 못했다. SVD 파이프라인, k, 인덱스 형식, 업데이트 주기가 질문이다. simclusters/ 서브레포를 심층 분석해야 한다.
SimclusterColdPostsCandidateSource에 대응하는 4세대 구성 요소가 있는지도 묻는다.
agatha/bdsm/user-cred-v2 계정 스코어러의 세부는 무엇인가. 각 스코어러의 실제 모델, 업데이트 주기, 라벨 임계가 질문이다.
pnsfwmedia는 어떻게 캘리브레이션되는가. CLIP 임베딩과 agatha 스코어를 결합하는 방식이 질문이다.
phoenix/ 학습 파이프라인의 세부는 무엇인가. loss, negative sampling, synthetic data 형식이 질문이다. optimizer는 2026-08-19 커밋(4세대+4)에서 공개됐다(학습 코드).
phoenix-rankall/·phoenix-rankall-strato/ 인덱싱 파이프라인은 어떻게 동작하는가. visibility 사전 참조 로직과 이벤트 층의 세부가 질문이다.
abuse-enforcement-service/에서 어떤 스코어가 어떤 조치(라벨/챌린지/서스펜션)로 매핑되는가.
✅ 성인 컨텐츠 판정이 엇갈릴 때의 처리: safemodel과 PtOS가 엇갈리면 Grok 4.5 심판이 다시 판정하고, hard→soft 강등만 허용한다.
safemodel은 자체 비전 모델이고, PtOS는 정책 판정이다. 격상은 없다.
판정 계층이 3층(safemodel, PtOS, Grok 4.5 심판)으로 확장됐다.
EAPI_GROK_4_5_X_ALGO 전용 서킷브레이커를 두고, 감사 로그에 "Grok 4.5 Cross Validation disagreed" 텍스트를 명시한다.
✅ Phoenix retrieval의 인덱싱 게이트: build1FavIndex·buildMMEmbMetadataDump의 favoriteCount >= 1 조건이 제거됐다. 실제 인덱스는 유입 후보 전체다.
인덱스 이름 "1fav"는 backward compat 라벨이 됐다.
1fav_uec_count_race 카운터로 fav 없이 도달한 케이스만 계측한다.
심층 분석 필요 (신규):
Grok 4.5 심판이 다른 카테고리로 확장되는지
1fav_uec_count_race의 실제 관측 비율(외부에서는 관측할 수 없다)
4세대+3 공개로 해소된 것 / 뒤집힌 것 (2026-08-20)
이 공개는 하루 전 4세대+2에서 확정됐다고 본 브랜드 안전 V2의 dual-write 판단을 뒤집었다. VMRanker의 SID 연결, For You와 Following의 하이드레이션 분기, Phoenix 이벤트 스트림의 v3 이동, grox reply_spam 흐름 통합도 확인됐다.
⚠ 주장 변경 (하루 만에): V2 브랜드 안전 dual-write 로직이 완전 재설계됐다.
4세대+2의 방식은 strip_v1_grok_written과 PROMPT_OWNED_RULES 7개 rule id였다. 4세대+3의 방식은 V2_WRITTEN_LABELS 4개 게이트와 V1 폴백 시맨틱이다.
PROMPT_OWNED_RULES 7개는 코드에서 완전히 사라졌다. 그 rule id 매핑을 묻던 심층 분석 항목은 자동 해소됐다(질문 자체가 없어졌다).
새 시맨틱은 훨씬 깔끔하다: "V2 라벨이 하나라도 있으면 V2가 결정하고 V1 heuristic까지 완전 무시, 없으면 V1 폴백".
이 사례는 X가 "매일 커밋"의 dual-write 마이그레이션 실체를 그대로 노출한다는 관찰의 강한 근거다.
✅ VMRanker의 SID 연결: SID 필드 7개가 VMRanker 요청에 들어갔다. Phoenix retrieval의 SID window와 짝을 이루는 재순위 쪽 SID 소비자가 있음을 확인했다.
필드는 sid_known·sid_k_l1/2/3·sid_gap_l1/2/3이다.
SID가 3-level 계층 코드북이라는 아키텍처 사실이 새로 드러났다.
✅ For You와 Following의 하이드레이션 분기: For You와 Following이 파이프라인 단계에서 명시적으로 갈리기 시작했다.
Follower 임계가 40k → 60k로 올랐다. 2026-08-17 커밋(4세대+2)의 30k → 40k에 이은 세 번째 상향이다.
심층 분석 필요 (신규):
V2_WRITTEN_LABELS 4개 라벨의 유래(Grok 프롬프트 변경? 재훈련?)
SID 코드북 정의(각 level의 vocabulary 크기·훈련 방식)
sid_gap의 계산식
Phoenix Kafka unified_posts_v2가 있었는지(v1 → v3 직접 점프인지)
ColdStartTrackedIds param과 단순화된 count_tracked_ids 계측이 실제로 어떻게 연결되는지
4세대+4 공개로 해소된 것 / 신규 (2026-08-21)
이 공개로 Phoenix의 optimizer가 코드로 확정됐고, Phoenix가 검색 지면으로 넓어지는 첫 신호가 나왔다. reward head와 분포 예측 헤드, Following 쪽 필터, PTOS의 Grok 4.6 shadow, VF shadow 배포 기반도 새로 드러났다.
✅ Phoenix dense-param optimizer의 정체가 4세대+4에서 코드로 확정됐다. 오랜 미공개 항목이었던 이 optimizer는 Muon (Newton-Schulz orthogonalization)과 Adam의 하이브리드다.
이 공개로 Phoenix 서빙이 만든 slate context를 home-mixer가 받아 쓰는 경로가 연결됐다(게이트는 기본 false). For You 후보에는 AI trend 맥락이 붙기 시작했다. Grok 4.6 shadow 비율은 하루 만에 세 배가 됐다.
✅ Phoenix 서빙이 만든 slate context를 소비하는 경로가 생겼다. 4세대+4에서 proto에 신설된 SlateContext.sid*가 실제 파이프라인까지 연결됐다. 게이트는 기본 false로 연결됐다.
구성 요소는 PostCandidate.served_slate_context, From<xai_recsys_proto::SlateContext> 변환 impl, PhoenixScorer의 candidate_slate_context() 추출, RankingScorer의 3갈래 폴백이다.
폴백 순서는 UseServedSlateContext 게이트 우선 → 캐시 → 로컬 계산이다.
slate context 계산 지점이 home-mixer 로컬에서 Phoenix 서빙으로 옮겨 가는 첫 단계가 확정됐다.
✅ AI trend feedback context 채널이 새로 생겼다. For You 후보에 "AI trend" 라벨을 topic feedback과 나란한 두 번째 컨텍스트 채널로 붙인다. topic feedback이 없을 때만 폴백으로 진입해 겹치지 않는다.
TOP_TREND_COUNT = 2다(페이지당 최대 2개).
trend당 후보 1개를 랜덤으로 고른다.
standalone original post, non-topic, non-in-network의 3중 게이트를 둔다.
랜딩 URL은 https://x.com/i/trending/{trend_id}다.
✅ emb_table 로더의 core가 pyo3-free로 분리됐다. 큰 임베딩 테이블을 non-Python 컨텍스트에서 로드할 수 있게 준비했다. xrecsys_search의 vocab 100M/100M/30M 흐름과 방향이 같다.
core는 load_tensor_into(&str, &str, &[(String, String, usize, usize)], &mut [u8], usize, usize) -> Result<(), String>이고, Python wrapper가 따로 있다.
✅ Grok 4.6 트래픽 분할 dial이 하루 만에 10% → 30%로 3배 올랐다. 4.6 트래픽이 회로차단 트립 없이 disagreement rate 허용 범위 안에 있었음을 뒷받침한다 추정. 4세대+2→+3→+4→+5로 매일 정교해지는 흐름이 이어진다.
✅ grok_sampler에 스트리밍 응답을 일찍 끝내는 콜백이 들어갔다. 4.6 트래픽 증가에 맞춘 sampling 인프라 최적화다 추정.
stop_predicate와 aclosing이 연결됐고, llm.sample.early_stop.count 카운터가 생겼다.
✅ grox User에 profile_image 필드가 새로 생겼다. Grok classifier가 프로필 이미지를 판정 컨텍스트로 참조할 수 있게 스키마를 확장했다.
심층 분석 필요 (신규):
AI trend의 upstream 정의(Grok 클러스터링? 사람 큐레이션?)와 batch_get_tweet_ai_trend 파이프라인 소스
UseServedSlateContext 게이트가 true로 올라가는 시점. 이 시점이 곧 Phoenix 서빙 slate context가 프로덕션 트래픽에 반영되는 시점이다(다음 커밋에서 볼 관측 포인트).
Grok 4.6 dial의 다음 이동(30% → 50%/100% 또는 하락)
stop_predicate의 실제 사용 위치(안전 판정 JSON tag 매칭 콜백으로 쓰는지)
4세대+6 공개로 해소된 것 / 신규 (2026-08-23)
이 공개로 광고 conversion 예측 헤드 20개와 delayed feedback 학습 분기가 생겼고, export nano config가 GB300 오버라이드의 학습 레시피를 물려받게 됐다. PTOS의 아동 안전 cross-validation과 deluxe 재분류는 Grok 4.6 internal로 완전히 넘어갔다.
✅ 광고 conversion 예측 헤드 20개가 새로 생겼다. 이전에는 앱 설치만 예측했지만, 이제 앱 설치 이후의 참여 이벤트 6개를 click·view 각각으로 예측한다(광고 conversion 소절).
proto ActionName 171–190에 들어갔다. MACT 6종 인앱 이벤트(purchase·add_to_cart·level_achieved·tutorial_complete·sign_up·custom) × click/view 2계열 = 12개 + view-through conversion 8개(웹 컨버전)다.
이전에는 ADS_ATTRIBUTED_MACT_CLICK_INSTALL(=140) 하나뿐이었다.
임베딩 rowwise AdaGrad에는 accumulator half-life decay, lazy per-row decay, decoupled weight decay의 세 옵션이 있다.
✅ home_direct_packed_nano가 NVIDIA GB300 하드웨어 오버라이드의 LR과 optimizer 설정을 상속하게 됐다. 오버라이드 자체는 4세대 공개부터 있었다 확인 — xrecsys.py:305-313. 같은 파일에는 4세대 공개부터 _H100_OVERRIDES도 있다(같은 파일 297행).
_GB300_OVERRIDES는 bs_per_device=512·ep=64·attn_impl="cutedsl_ranker_varlen_attn"·remat_policy=RematType.SAVE_GB300_RECSYS·unroll_layer_stack=True·LR 5e-4·checkpoint_every_n=300을 정의한다.
이 attention 커널은 NVIDIA CUTLASS/CUTe DSL 기반 varlen attention 커널이고, xAI 자체 커널일 가능성이 있다.
✅ xAI 내부에 xai-o2 스토리지 시스템이 있음이 처음 확정됐다. S3 호환 오브젝트 스토리지 클라이언트인 xai-o2 crate의 인터페이스가 공개됐다.
다만 build()가 오픈소스 빌드에서 항상 "the xai-o2 backend is not included in this build" 에러를 반환한다.
✅ PTOS의 아동 안전 cross-validation과 deluxe 재분류에서 Grok 4.5 internal이 완전히 빠지고 4.6 internal로 100% 전환됐다. 2026-08-19 커밋(4세대+4)에 시작한 dial-up(10%→30%→100%)이 이틀 만에 끝났다 확인 — aad7179, d0cef2f, 28e414f의 classifier.py.
성인 콘텐츠 심판(4세대+2)은 이 dial과 무관하게 EAPI_GROK_4_5_X_ALGO를 계속 쓴다 확인 — classifier.py:563.
4.5 관련 breaker·sampler·dial 상수가 모두 삭제됐다. 롤백할 수 없다.
✅ SlateContext가 NextActionDistribution의 필드 #9로 추가됐다. 이제 action distribution마다도 slate context가 붙는다. 소비 경로는 아직 뼈대 단계다.
앞서 4세대+4에서는 ScoreInfo.slateContext(재순위 슬레이트 결정), 4세대+5에서는 PostCandidate.served_slate_context(home-mixer 소비)가 생겼다.
✅ grox reply_spam의 follower 임계가 60k → 80k로 올랐다. 4세대 공개 뒤 8일 사이 네 번째 상승(15k→30k→40k→60k→80k)이고, 이번 상승은 1.33배다.
EVERGREEN retrieval dataset의 재정의 방향(이름 변경 vs 원상 복구)
Grok 4.6이 정착한 뒤 4.7 shadow가 시작되는 시점
cutedsl_ranker_varlen_attn 커널이 xAI 자체 커널인지
4세대+7 공개로 해소된 것 / 신규 (2026-08-26)
이 공개로 가시성 필터링 재작성의 정합성을 실측하는 회귀 검증 채널이 생겼고, 브랜드 안전 판정이 4단계로 늘었다. PTOS 심판은 3개 카테고리로 넓어졌다.
✅ VF 재작성의 회귀 검증 채널이 연결됐다. 4세대 헤드라인이었던 VF 서브레포 재작성의 정합성을 이제 프로덕션 트래픽 규모로 감사할 수 있게 됐다. 적용 리스크가 크게 줄었다(변경 이력의 4세대+7).
visibility-filtering/reference_compare.rs(812줄 신규)가 dark_traffic 스택(4세대+4/+6) 위에 더해져, 실제 verdict 대조를 실측할 수 있게 했다.
VF_DUAL_CALL_HARNESS_ENABLED와, APP_ENV=prod이면 panic하도록 한 강제 장치로 staging 전용이다.
✅ BrandSafetyVerdict가 4단계로 늘었다. 이전에는 브랜드 안전 판정이 3단계(Safe/Low/Medium)로만 광고 인접성을 제어했고, 위험 정도가 다른 라벨들을 같은 tier에 뭉쳐 두었다. 이 한계가 해소됐다.
새 tier는 HighRisk = 4다.
기존 medium에 있던 5종 라벨(NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW)과 신규 SOFT_NSFW가 HighRisk로 승격됐다. V1과 V2가 대칭으로 이동했다.
광고 유틸 has_avoid()가 Medium과 High를 둘 다 다루고, 인용 인접성 escalation도 Medium → High로 바뀌었다.
✅ V2 라벨 5종이 다운스트림 proto에 노출됐다. 이전에는 V2 라벨이 home-mixer 내부에만 있었다. 이제 feed 응답을 소비하는 다른 서비스에서도 V2 판정을 확인할 수 있다.
✅ PTOS 심판 계층이 3개 카테고리로 넓어졌다. 이전에는 성인 콘텐츠 심판 클래스와, 4세대 공개부터 분류기 안에 있던 아동 안전 cross-model validation이 따로 있었다. 이제 ChildSafety, ViolentMedia, IllegalAndRegulatedBehaviors의 3개가 한 심판 클래스로 묶였다(Grox → PtOS 절).
새로 생긴 심판은 SafetyPtosPolicyCrossValidator다. 이전의 성인 컨텐츠 심판은 SafetyPtosAdultContentCrossValidationJudge(4세대+2)다.
새 심판 클래스는 4세대+2부터 성인 콘텐츠 심판이 쓰던 EAPI_GROK_4_5_X_ALGO 엔드포인트를 쓴다. 4세대+6에서 완전 제거된 EAPI_GROK_4_5_INTERNAL과는 별개다 확인 — classifier.py:100, 561.
X가 primary(4.6)와 judge(4.5 X_ALGO)를 서로 다른 모델·튜닝으로 유지하는 이중 스택을 굳혔다.
✅ PTOS의 ViolentSpeech·SuicideOrSelfHarm 카테고리에서 부모 스레드 렌더링이 켜졌다. 이제 grox가 post와 ancestors를 함께 LLM 프롬프트에 넣는다.
이 두 카테고리는 대상 post의 문장만으로는 위해를 판정할 수 없다는 근본 이유가 있다.
예를 들어 "죽고 싶어"는 게임 얘기냐 자해냐가 스레드 문맥으로만 판정된다.
USE_THREAD_RENDERER_CATEGORIES가 새로 생겼다.
USE_REASONING_CATEGORIES도 이 두 개에만 켜졌다(thinking 억제 해제).
✅ Phoenix 체크포인트 로더가 다중 노드 환경에 맞게 보강됐다. Muon과 GB300 세대의 restore path OOM 이슈에 대응한다(체크포인트 스택 소절).
4세대+6이 flagship Muon 학습을 export에 정식 포함한 뒤 나온 후속 검증 결과다 추정.
xai_checkpointing/load.py에 domains(per-tensor slice), tag(checkpoint subdir), concurrent_gb(동시 디코딩 스로틀) 인자가 새로 생겼다.
오디오 컨텐츠 자체를 LLM이 듣는 파이프라인(스페이스 제목 텍스트만 vs 실 오디오 스트림 STT)
concurrent_gb의 프로덕션 튜닝 값과 restore 시간 증가 폭
4세대+8 공개로 해소된 것 / 신규 (2026-08-27)
이 공개로 스코어러가 요청한 조치를 abuse-enforcement 서비스가 실행하는 일반 경로가 생겼다. VMRanker 경로는 크게 단순해졌고, Following 파이프라인은 인용·리트윗 체인의 원 저자까지 블록 관계를 따지게 됐다.
✅ 스코어러가 요청한 액션을 AES가 실행하는 일반 파이프라인이 연결됐다. 이제 하드코딩 YAML 규칙 밖에서도 스코어러가 requested_actions를 직접 추가해 보낼 수 있다(변경 이력의 4세대+8).
새 파일은 abuse-enforcement-service/service-lib/src/generic_actions.rs(663줄 신규)이고, 규칙 짝은 platform_row_without_requested_actions skip과 act_requested_actions act다.
검증은 두 겹의 fail-closed다.
(a) 하드코딩 kind allowlist(user 5종·post 2종, MAX 16개/msg)
(b) GrowthBook 동적 allowlist(kinds·suspend_policies·labels)
거부된 요청은 버리지 않고 outcome.info["requested_actions_skipped"] JSON과 GENERIC_ACTION_TOTAL{outcome=skipped, reason} 메트릭으로 남긴다.
첫 클라이언트로 panda_reports_embedding_v10_rough_spam 라벨이 임시 PlatformManipulation suspend 규칙에 등장했다. embedding 기반 spam 검출기가 v10까지 재학습되는 지속 파이프라인이라는 방증이다.
✅ VMRanker 경로가 크게 단순해졌다. VMRanker는 rank server가 반환한 점수를 그대로 통과시키고, DPP 다양성은 서버 쪽 자체 로직이 담당한다.
AUTHOR_DIVERSITY_VALUE_MODEL_ID, MPN 폴드백 128줄, AuthorColdStart 후처리, ranking_scorer::effective_head_weights 88줄이 모두 삭제됐다.
새 상수는 DPP_VALUE_MODEL_ID = "dpp" 하나뿐이다.
4세대+5의 UseServedSlateContext(slate context를 Phoenix 서빙으로 이관)와 짝을 이루는 아키텍처 리팩터링이다. home-mixer는 얇게 두고 gRPC 서버들이 계산을 맡는다.
✅ Following 파이프라인이 quote/retweet 체인의 원 저자까지 블록 관계를 존중한다. X가 quote/retweet을 통한 블록 우회를 명시적으로 차단한다.
새 hydrator FollowingBlockedByHydrator(65줄)가 각 candidate의 quoted/retweeted 저자 리스트로 SocialGraphClient에 batch check_blocked_by 조회를 한다.
VqvWeight가 0.05에서 0.0으로, DwellWeight가 0.0에서 0.05로 바뀌었다.
EnableAdsBrandSafetyVerdictV2가 false에서 true로 바뀌었다. 이 값은 2026-09-16 커밋(4세대+22)에서 false로 돌아갔다가 2026-10-03 커밋(4세대+35)에서 다시 true가 됐다.
심층 분석 필요 (신규):
generic_actions allowlist의 첫 프로덕션 GrowthBook 설정(어떤 스코어러들에게 최초로 열리는지)
panda_reports_embedding_v10의 이전 v1~v9 진화 흐름과 v11 시점
DPP_VALUE_MODEL_ID의 실제 값 모델 아키텍처(재순위 절의 DPP(θ=0.65, K=150)와 같은 것인지 다른 것인지)
AuthorColdStart의 새 소비자 지점(vm_ranker에서 빠진 뒤 어디로 갔는지)
AuthorSocialgraphFilter가 For You 파이프라인에도 연결되는지
prefloor 스냅샷의 첫 활성 window
grox/flows/upa/ 서브플로우의 역할
URT client_event.rs +27줄의 새 event 유형
4세대+9 공개로 해소된 것 / 신규 (2026-08-28)
이 공개로 Phoenix 체크포인트의 저장과 복원이 모두 배치 파이프라인이 됐고, 학습 스텝의 유효성 게이팅이 dense와 embedding 사이에서 통일됐다. VF는 media fallback cache를 걷어 냈고, 광고 conversion 헤드 3종이 proto에 새로 생겼다.
✅ Phoenix 체크포인트 스택이 저장과 복원 양쪽에서 RSS 상한을 갖도록 다시 정리됐다.Muon+GB300 체크포인트 라운드트립 전체가 이제 batched pipeline이 됐다(체크포인트 스택 소절).
4세대+7의 복원 쪽 보강(concurrent_gb throttle과 node-lock)에 대칭으로, 이번에는 저장 쪽이 정리됐다.
저장 쪽에는 ThrottledD2HArrayHandler가 새로 생겼다. Orbax ArrayHandler.serialize를 오버라이드해 배치별로 D2H와 write를 하고, 예산은 save_concurrent_gb로 정한다.
XAI_SAVE_NODE_SERIALIZE env와 /dev/shm/xai_save_node_lock flock으로 저장을 노드 단위로 직렬화한다.
배치 사이에 gc.collect()와 malloc_trim(0)을 부른다.
복원 쪽의 새 함수 load_checkpoint_streamed는 stage→copy→evict를 tensor 단위로 밀어 가는 restore 변형이다. auto-window = min(max(max_leaf, total/16), 32GB)다.
이 함수는 on_replaced 콜백으로 old/new array 쌍을 넘기고, copy_aliased_arrays로 aliased 참조를 미리 분리한다.
Trainer.warm_start_staging_spec 훅은 params-only 복원 때 pinned-host에 opt_state 사본을 만들지 않는다(keep_emb_opt_state=True 옵션).
MultiRisk.enforcement·MultiRisk.slot_outcome·MultiRisk.serving_limitation 3종 metric으로 두 가지를 슬라이스해 관측한다.
어느 안전 필터가 slot을 비우게 했는지
ads_supply/spacing/safe_posts 중 어느 것이 binding constraint였는지
같은 커밋에서 blender_selector에 "partition_organic_low_risk" 앨리어스도 추가돼 옵션이 총 5개가 됐다.
experiment 게이트 뒤에 배치됐고, AdsBlenderType param 기본값은 아직 승격되지 않았다.
✅ safety-label twemcache에서 miss가 나면 그 일부를 표본으로 골라 비동기로 캐시를 채우는 워머가 생겼다.
visibility/baseTweetSafetyLabelMap 컬럼 조회에서 twemcache가 Miss(cache가 이 키를 모름)를 반환하면, SampledWarmer가 VF_CACHE_WARM_SAMPLE_PCT env로 확률 샘플링한다.
샘플된 키는 tokio bounded mpsc(용량 1024)에 fire-and-forget으로 publish된다.
백그라운드 drain이 500ms linger·최대 50 ID로 배치를 묶어 Strato batch fetch를 한다. 결과는 twemcache에 write-back된다.
NotFound(정의적 미존재)는 워밍 대상이 아니고, FallThrough(에러/타임아웃)도 워밍 대상이 아니다. 워밍은 캐시 미채워짐만 해소한다는 원칙이다.
bounded 채널이 포화하면 요청 처리를 절대 막지 않고 DroppedChannelFull 카운터만 올린다.
VF_GIZMODUCK_CLIENT_ID·VF_TWEMCACHE_CLIENT_NAME·APP_ENV 3개 env도 새로 생겼다(staging/prod 자동 분리).
✅ Phoenix trainer가 restore_streamed를 실제로 쓰게 됐다.
4세대+9의 load_checkpoint_streamed 스캐폴딩이 이 커밋에서 maybe_load_checkpoint에 연결됐다.
활성 조건은 restore_streamed=True AND format == "orbax" AND save_method != "tensorstore"다.
warm_start(do_not_load_opt_state) 경로에서 특히 이득이다. 이 경로에서는 opt state pinned-host 사본이 아예 생성되지 않는다.
_graft_replaced 콜백이 loaded tensor를 원래 state tree 자리에 in-place로 grafting한다. aliased state leaves는 조기 assertion으로 차단한다.
✅ Phoenix gRPC 압축 경로가 응답 트레일러를 잃던 버그가 고쳐졌다.
GrpcCompressionService는 zstd 압축 시 응답 body를 새 axum::body::Body::from(data)로 감싸면서 gRPC trailers(grpc-status·grpc-message)를 전부 잃었다.
클라이언트가 status trailer 없이 EOF로 처리했을 가능성이 컸다.
이제 collected.trailers().cloned()와 rebuild_response 헬퍼로 trailer를 복원한다. 압축이 성공할 때와 실패할 때 모두 같은 방식으로 통일됐다.
회귀 테스트가 두 개 붙었다.
같은 파일에 새 히스토그램 recsys_engine_response_compress_ms{method}가 생겼다(zstd encode 큐잉·CPU 시간).
✅ Phoenix copy_port가 shard 다운로드 순서를 pod마다 섞어 peer 부하를 고르게 했다.
이전에는 각 pod가 sharded tensor를 다운로드할 때 모든 pod가 index 0부터 순서대로 요청했고, 첫 shard peer에 스파이크가 생겼다.
새 shuffle_sharded_schedule(n, tensor_name)은 POD_NAME/HOSTNAME과 tensor name을 hash로 섞고, StdRng::seed_from_u64(hash)로 결정론적으로 셔플한다. 그 결과 각 pod가 다른 순서로 요청한다.
완료된 결과는 restore_piece_order로 원래 순서로 되돌린다.
두 소비 지점 emb_table.rs·copy_port_client.rs가 같은 방식으로 통일됐다.
✅ grox reply_spam이 점수를 Kafka에 먼저 저장하게 됐고, >= idempotency와 fetch 실패 처리도 바뀌었다.
4세대+9의 monotone-only 규칙이 >에서 >=로 바뀌어 동점일 때도 쓰기를 건너뛴다. 카운터 이름도 higher_than_existing에서 not_lower_than_existing으로 바뀌었다.
저장 순서가 Manhattan-first에서 Kafka-first로 뒤집혔다. Kafka append-only durability를 우선하고, Manhattan write 실패는 오프라인으로 복구할 수 있다.
fetch_reply_ranking_score의 방어적 try/except가 제거됐다. 이제 fetch 실패가 flow task 실패로 전파된다.
같은 커밋에서 classifier_coordinated_spam.py의 PostRenderer.render 호출에 include_follower_count=True가 상시 켜졌다. coordinated spam 판정이 스레드 각 참여자의 팔로워 카운트를 프롬프트로 받는다.
✅ abuse-enforcement에 AIS thrift 스키마 conformance 테스트가 생겼다. AES의 payload 스키마가 다운스트림 thrift 서비스와 drift하지 않도록 CI로 보장한다.
새 테스트 every_action_shape_encodes_against_the_ais_schema는 액션 형태를 실제 AIS thrift 스키마로 인코드해 회귀를 막는다.
인코드 대상은 생성자 6종(suspend_user·add_user_label·add_post_label·bounce_arkose·bounce_captcha·spam_liveness_check)으로 만든 EnforcementAction 형태 8개다. suspend_user와 add_user_label은 두 가지씩 들어간다 확인 — service.rs:2104-2139.
대상 스키마는 ActionIntakeService.intakeAction이다.
스코어러를 새로 만들거나 payload 필드를 추가할 때 이 테스트가 조기 실패로 감지한다.
✅ home-mixer의 CJK muted keyword가 whole-token으로만 매칭되는지 확인하는 회귀 테스트가 생겼다.
새 테스트는 viewer_muted_keyword_filter.rs의 test_cjk_keyword_whole_token_only다.
京都를 뮤트하면 I visited 京都 last week는 걸러내야 하지만, 東京都に行くのが楽しみ는 걸러내지 않아야 한다(京都가 東京都의 부분 문자열).
필터 본체는 이미 CJK word boundary를 다루고 있다. 이번에는 테스트만 추가됐고, 목적은 시맨틱을 잠그는 것이다.
medium-medium 배치가 revenue/브랜드 안전 metric에서 만드는 uplift
VF_CACHE_WARM_SAMPLE_PCT의 초기 프로덕션 값과 twemcache miss rate 변화
gizmoduck_client_id() staging 분리가 어떤 서비스 quota를 나누는지
restore_streamed의 첫 활성 config(현재 기본 false로 추정 추정)
recsys_engine_response_compress_ms의 P99 목표치와 gRPC trailer 유실 버그의 프로덕션 관측 증거
copy_port shard 셔플 도입 전후 shard 0 peer의 request rate 분포
home-mixer polling 요청 비율의 실제 분포(feed·client별)
reply_spam이 Manhattan write를 건너뛰는 비율의 시간축 변화(monotone-only와 >= 이후 몇 %를 건너뛰는지)
AIS conformance 테스트가 스코어러 개발 사이클에서 실제로 잡아내는 회귀 빈도
4세대+11 공개로 해소된 것 / 신규 (2026-08-28)
2026-08-28 커밋(4세대+11)에서 home-mixer가 Phoenix에 보내는 요청에 리랭커 헤드 태그 파라미터가 연결됐고, Phoenix의 임베딩 테이블 수신 처리가 blocking pool로 옮겨졌다 확인(변경 이력). 같은 커밋의 VF 규칙 계층 리팩터는 정책을 바꾸지 않았다 확인(변경 이력).
✅ visibility-filtering 규칙 계층이 리팩터됐다. 정책은 바뀌지 않았고, 회귀를 막는 자산이 쌓였다.
24개 rule 파일이 +1,522/−1,151로 얇아졌다.
RuleContext가 새 파일 context.rs로 분리됐고, 25여 개 도메인 메서드(is_stale_tweet, viewer_country_in, has_tweet_nsfw_user_flag 등)를 노출한다.
개별 rule의 조건 검사는 도메인 메서드 호출로 대체됐고, 시맨틱은 유지됐다.
90건 골든 코퍼스(golden_corpus.rs, +1039)가 13개 카테고리로 판정 결과를 pin한다.
CI에는 every_wired_rule_decides_a_corpus_case 어설션이 걸린다. 연결된 rule마다 최소 한 건의 결정자 케이스가 있어야 한다는 검사다.
4세대+7reference_compare, 4세대+9 media fallback cache 제거로 이어진 흐름의 다음 단계다.
✅ home-mixer가 Phoenix에 보내는 요청에 RerankerHeadTag 파라미터가 연결됐다. 서버 소비 로직은 이 커밋에 없다.
새 파라미터는 param!(RerankerHeadTag, i64, "rust_home_mixer_reranker_head_tag", 0)다.
PostCandidate.reranker_head_tag: Option<u32> 필드가 생겼다. PhoenixScorer가 이 값을 요청과 scoring_sequence에 실어 보내고, 응답에서 라운드트립한다.
proto 두 자리에 대칭으로 추가됐다: ScoreInfo.rerankerHeadTag = 6, UserActionSequence.rerankerHeadTag = 9(Rust/Python proto 동시).
헤드 라우팅을 켜기 전의 wiring 단계다 추정.
모델 안 여러 리랭커 헤드 중 하나를 A/B 실험으로 고르는 스위치로 읽힌다 추정.
✅ Phoenix emb_table의 gRPC 수신 처리가 blocking pool로 분리됐다.
이전에는 대용량 임베딩 테이블을 다운로드할 때 non-temporal 복사와 Adler-32 계산이 gRPC 콜백에서 직접 실행돼 tokio 런타임 스레드를 붙잡았다.
이제는 데이터를 sync_channel(2)에 4 MiB 청크로 밀어 넣고, tokio::task::spawn_blocking으로 따로 실행한다.
RDMA 경로에서도 최종 합산까지 spawn_blocking으로 소비한다.
새 헬퍼 JoinOnDrop<T>는 blocking 태스크를 반드시 종료해 핸들 누수를 막는다. 정상 경로에서는 join().await를 쓰고, drop될 때는 block_in_place와 block_on(handle)을 쓴다.
4 MiB 청크와 sync_channel(2) 백프레셔가 수신 속도를 조절한다.
✅ copy_port_client에 join_transfers 헬퍼가 생겼고, proto_parser에 Bytes 콜백 타입이 생겼다.
이전 join_all(...spawn) → collect → map_err 4행 관용구를 join_transfers 헬퍼 하나로 뽑았다(동작 변화 없음).
proto_parser.rs의 새 Bytes 콜백 타입(FnMut(usize, Bytes))은 청크를 소유권 이동으로 넘길 수 있다.
emb_table은 아직 Str 시그니처를 유지한다. 진짜 zero-copy는 후속 관측 포인트다.
이 커밋으로 새로 생긴 심층 분석 질문은 다음과 같다.
RerankerHeadTag의 서버 쪽 소비 로직(Phoenix Python 서빙 코드에서 헤드 라우팅)
UserActionSequence와 ScoreInfo 두 자리에 붙은 이유(요청·응답 대칭 vs 다른 목적)
RerankerHeadTag의 첫 experiment 승격 시점(0 → 1+로 바뀌는 커밋)
Bytes 콜백을 emb_table이 언제 소비하는지(진짜 zero-copy 경로)
gRPC 콜백 분리 도입 전후 tokio 런타임의 tail latency 개선폭
골든 코퍼스 90건이 이전 프로덕션 판정과 일치하는지 검증(리팩터가 시맨틱 변경을 숨겼을 가능성)
4세대+15 공개로 해소된 것 / 신규 (2026-09-04)
2026-09-04 커밋(4세대+15)은 grox 답글 스팸 판정, PTOS 심판 모델 배정, Phoenix 서빙과 학습, VF hydration을 한꺼번에 바꿨다 확인(변경 이력). 새 질문에는 새 판정 경로의 활성 비율, SID를 요청에 채우는 지점, H100 학습 config가 있다.
✅ grox에 스레드 chain 단위로 판정하는 두 번째 답글 스팸 경로가 생겼다. 이름은 PlanMultiStepReplySpam이다.
Gemma 2에 스레드 전체를 프롬프트로 넣고 "spam_post_indexes" 집합을 뽑아낸다.
진입 조건은 8개다: 답글이며, ancestors 있음, root 팔로워 ≥ 1,000, root 저자 ≠ 답글 저자, 직계 부모 저자 = 답글 저자, high page-rank/grey badge 아님, Grok/Gork 아님, 깊이 ≥ 2.
이 조건들이 잡으려는 패턴은 "한 저자가 자기 답글에 자기가 답한 chain으로 root 노출을 훔치는 시간축 담합 스팸"이다.
줄어든 파일은 registry.rs −378, safety_label_source/codec.rs −206, hydration/mod.rs −90, hydration/fallback_cache.rs −87, config.rs −118 등이다.
"재작성 안정화 뒤 안전망 걷어냄" 흐름이 이어진다. 앞선 단계는 4세대+9의 media fallback cache 제거, +8의 VMRanker 128줄 삭제, +11의 rules 리팩터, +14의 Box<dyn Rule> 소거다.
같은 커밋에서 fixtures.rs +81, golden_corpus.rs +102로 회귀 방어망은 더 두꺼워졌다.
✅ Brazil2026ElectionFilter가 정기 갱신됐다. 4세대+8의 정기 갱신 관행이 이어진다. 정치인 X 계정 매핑을 매주 계속 검증한다는 신호다.
계정 ID 166개가 하드코딩으로 추가돼, 집합이 2,388개에서 2,554개가 됐다 확인 — brazil_2026_election_filter.rs. 추가된 계정의 예는 +@nanacachen·+@AecioNeves·+@lgmbrasilia·+@crismonteirosp·+@rodrigolimamdh·+@samiabomfim·+@gabrielmagno_13이다.
"not the candidate's actual account" 판정도 몇 건 추가됐다.
✅ 외부 기여자의 PR #88 (e4dcedd)이 머지됐다. 4세대의 오픈소스 태도가 실제로 외부 PR을 받는다는 증거다.
vf_candidate_hydrator.rs에서 VF lookup 전에 in_network_ids를 dedup하는 두 줄이 추가됐다.
이 커밋으로 새로 생긴 심층 분석 질문은 다음과 같다.
PlanMultiStepReplySpam의 초기 활성 트래픽 비율과 판정 metric(experiment 게이팅이 이번 diff에 없음)
home-mixer/filters/vf_filter.rs — 2026-09-09, +27/−69. should_drop_action은 Drop(_)·Tombstone·NotEvaluated만 버리고 Allow·Interstitial·Avoid·Downrank는 남긴다. 테스트는 partition 매트릭스 8-case로 다시 썼다.
phoenix/README.md — 2026-09-09, +3/−3. nano config 표의 Multimodal post embedding 행을 고쳤다.
v5 on xrecsys_seqpack; off on home_direct_packed에서 off (home_direct_packed and xrecsys_seqpack)로 바뀌었다.
phoenix/xrex/utils/gpu.py — 2026-09-09, +32/−3. _parse_cpulist와 schedulable_cpus(*nodes)를 새로 만들었다. pin_process_to_local_numa는 os.sched_setaffinity(0, schedulable_cpus(node))로 isolated CPU를 뺀다.
home-mixer/scorers/ranking_scorer.rs — 2026-09-08, +93/−29. ScoringWeights::perturbed(), perturbation_sign(), weights_mut()를 새로 만들었다. 참여 헤드 26개의 가중치를 요청마다 exp(σ×md5-sign)으로 곱해 스케일링하고, recompute_sums()로 다시 계산한다.
grox/flows/mm_emb/task_backfill_gate.py — 2026-09-08, 60줄 신규. TaskBackfillFavGate와 TaskBackfillPreloadGate가 들어 있다. 뒤의 게이트는 이미 V8_SEARCH_MODEL_VERSION="v8_search_1"로 임베딩된 포스트를 건너뛴다.
home-mixer/models/content_features.rs — 2026-09-08, 185줄 신규. build·build_quoted·TextMetrics::of가 들어 있다. Twitter 텍스트 길이 규칙(URL=23, CJK=2, ZWJ/skin-tone modifier=0)을 회귀 테스트 5개로 고정했다.
phoenix/NOTICE — 2026-09-08, +198줄. flash-attention 서브구성요소의 저작권 표기를 추가했다.
phoenix/xrex/utils/aot.py — 2026-09-08, +54/−1. _sanitize_cutlass_calls와 cutlass_module_device_digest가 들어 있다. 뒤의 함수는 ELF 헤더를 파싱한 뒤 device blob의 SHA256을 구한다. AOT_CACHE_VERSION은 "20260705"→"20260904"로 바뀌었다.
phoenix/xrex/data/recsys/constants.py — 2026-08-21, MACT_IN_APP_LOSS_ACTION_INDICES·VIEW_THROUGH_ACTION_INDICES·SOURCE_SPLIT_CONVERSION_HEAD_INDICES·ads_slim_engagement_to_action_types를 새로 만들었다.
phoenix/xrex/models/recsys_model.py — 2026-08-21, split_head_training_by_source·train_view_through_heads·mact_in_app_loss_weight·conversion_keep_mask와 delayed feedback loss mask 분기가 들어 있다.
home-mixer/models/brand_safety.rs — 2026-08-17, 광고 브랜드 안전 V2 판정 함수·라벨 세트·strip_v1_grok_written 헬퍼·v2_mirrors_v1_across_tier_matrix 검증 테스트 (하루 만에 재설계됨).
For You 원본 포스트에 트렌드 맥락을 붙이는 채널이 생기고, 랭킹이 새 게이트로 서빙 쪽 slate context를 우선 쓰게 됐다(daily). +479/−200 (파일 14). home-mixer/candidate_hydrators/ai_trend_feedback_context_hydrator.rs(157줄 신규)와 post_marshaller.rs +29줄로 AI trend feedback context 채널 신설: For You standalone original post에 top-2 trend를 붙여 URT ContextType::TOPIC으로 렌더(랜딩 URL https://x.com/i/trending/{trend_id} 포함, topic feedback 없을 때만 폴백으로 진입). phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs(+189/−169) load_tensor를 pyo3-free core(load_tensor_into)와 Python wrapper로 분리, tokio runtime unwrap() → map_err, checkpoint path too short 방어. PostCandidate에 served_slate_context 필드와 From<xai_recsys_proto::SlateContext> 변환 impl(sid_k_l1/l2/l3, sid_gap_l1/l2/l3) 연결, PhoenixScorer가 예측 응답에서 candidate_slate_context()를 뽑아 세팅, RankingScorer가 새 게이트 UseServedSlateContext로 로컬 계산·캐시 대신 서빙 산출을 우선 사용(세 갈래 폴백). Grok 4.6 internal dial _GROK_4_6_INTERNAL_DIAL = 0.1 → 0.3 (하루 만에 3배). grox/libs/grok_sampler/llm.py(+27/−13) stop_predicate callback과 async with aclosing로 스트리밍 조기 종료, llm.sample.early_stop.count 카운터. grox/core/data_loaders/data_types.py·post_mapper.py에 profile_image 필드 신설
광고 브랜드 안전 판정에 HighRisk 등급이 생기고, VF에 레퍼런스 VF와 판정을 대조하는 스테이징 전용 경로가 추가됐다(daily). +1,418/−117 (파일 19). visibility-filtering/reference_compare.rs(812줄 신규): Strato 기반 레퍼런스 VF(StratoVfClient)를 매 요청 병렬 호출해 verdict를 엄격 비교(action과 reason 완전 일치), 다르면 JSON 라인(12KiB 예산)으로만 로그, 메트릭 6종(vf_reference_compared/exact_match/differed/error/skipped/enabled). VF_DUAL_CALL_HARNESS_ENABLED·APP_ENV 게이트, APP_ENV=prod면 부팅 시 panic으로 스테이징 전용 강제. dark_traffic과 별개로 실제 verdict를 대조하는 canary가 추가됐다. home-mixer/models/brand_safety.rs (+20/−13): BrandSafetyVerdict::HighRisk = 4 신설, 기존 medium에서 5종(NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW) → HighRisk 승격, 신규 SOFT_NSFW도 HighRisk (v1/v2 대칭 이동), 광고 유틸 has_avoid()가 Medium/High 둘 다 취급, ads_brand_safety_vf_hydrator.rs의 NSFW ancestor 승격이 Medium → High, scored_posts_server.rs에 V2 라벨 5종(GROK_SFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_V2·GROK_NSFA_EXPANDED_V2·PTOS_REVIEWED)의 proto HM enum 매핑 신설. grox/flows/ptos/classifier.py (+223/−52): SafetyPtosPolicyCrossValidator 신규 클래스. 4세대+2부터 있던 EAPI_GROK_4_5_X_ALGO 엔드포인트를 새 judge도 쓴다. primary가 아니라 judge 전용이고, ChildSafety·ViolentMedia·IllegalAndRegulatedBehaviors 3개 카테고리에 대해 primary verdict를 재확인 후 강등만 허용(disagreement → NoViolation 또는 lighter). 4세대+2의 성인 콘텐츠 judge와 나란히 있다. USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm}·USE_THREAD_RENDERER_CATEGORIES 신설(부모 스레드까지 포함해서 렌더). phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+178/−36): domains(per-tensor ts.IndexDomain 슬라이스), tag(체크포인트 서브디렉토리, 기본 "orbax-ckpt"), concurrent_gb(동시 디코딩 바이트 스로틀) 인자 신설. XAI_RESTORE_NODE_SERIALIZE(기본 on)로 노드당 fcntl.flock()(/dev/shm/xai_restore_node_lock) restore 직렬화, 배치 사이 gc.collect()와 malloc_trim(0). grox/flows/reply_spam/strato_loader.py (+17/−0): 4세대부터 Strato put과 Kafka v2 emit을 하던 ReplyRankingScoreStratoLoader에 score ≤ 1.0이면 지역 캐시(Atla, Pdxa)로 쓰는 팬아웃 추가. grox/flows/reply_spam/task_filter.py follower 임계 80k → 100k(11일간 다섯 번: 15k→30k→40k→60k→80k→100k). grox/core/data_loaders/data_types.py·post_mapper.py·lm/post.py: SpaceMetadata 클래스 신설(현재 title만), Post에 space_metadata 필드, 렌더러가 오디오 스페이스 카드를 LLM 입력에 포함. ads_injection_logging_side_effect.rs가 EnableAdsBrandSafetyVerdictV2 experiment 버킷을 ddg_experiment_bucket으로 로깅 노출
VF의 NSFW 게이팅 대상 16개국이 코드 기본값으로 들어가고, fav 수가 문턱 이상인 영상만 따로 판정하는 PtOS 흐름이 생겼다(daily). +2,247/−735 (파일 70). visibility-filtering/params.rs(216줄 신규): NSFW 게이팅 대상 16개국이 기본값으로 코드에 명시(ar au br ca de es fr gb id it kr mx nl ph pt th). ArcSwap으로 30초마다 feature switch rust_vf_nsfw_gating_countries에서 refresh, sibling YAML country_specific_nsfw_content_gating.yml(Scala VF)과 매 refresh마다 drift 비교해 nsfw_gating_countries_drift 카운터로 관측. 배열 안에 잘못된 원소가 섞이면 부분 리스트가 아니라 리스트 전체가 default로 fallback(malformed_value_falls_back_whole_not_partial 테스트로 잠금). visibility-filtering/rules/nsfw_age_gating.rs(+46/−30): SensitiveViewerNoStatedAgeDropRule이 이 국가 리스트를 참조해 나이 미확인 뷰어에게 16개국에서만 age-gating을 드롭 액션으로 강제. grox/flows/ptos/plan_safety_ptos_special_video.py(55줄 신규), task_safety_ptos_special_video_filter.py(40줄 신규), task_special_video_screen.py(49줄 신규), grox/libs/video_tools/image.py(+125): PlanSafetyPtosSpecialVideo 8단 DAG 신설. fav count가 deluxe_fav_count_threshold 이상인 영상만 진입(비용 관리). motion_reveal_frames(정적 층을 뺀 뒤 미약한 움직임 층을 증폭한 프레임으로, 정적 오버레이로 가려진 회피 콘텐츠를 드러낸다)가 있으면 스크리닝에 걸림 → 통과분만 category/policy/safemodel 판정과 Grok 4.5 심판(EAPI_GROK_4_5_X_ALGO) cross-validation. MOTION_REVEAL_DESCRIPTION 상수가 Grok 프롬프트에 "옷이 벗겨 보일 수 있으니 원본 프레임과 대조하라"는 rubric까지 포함. special video 경로만 심판 실패 시 fail-close로 Soft 강등(다른 경로는 원 판정 유지가 기본). home-mixer/filters/brazil_2026_election_filter.rs(+222/−74): 지속 유지 보수. phoenix/crates/common/xai-recsys/src/model_config.rs(+96/−2), phoenix/xrex/configs/xrecsys_two_tower.py(+1/−7), phoenix/crates/serving/xai-recsys-mm-server/src/mm_embedding_client.rs(+128/−55) 등 Phoenix serving/config 리팩터. phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py·encrypted_kvstore.py 신규(49줄+49줄): 암호화 체크포인트 kvstore(feed 랭킹과 직결 관계 미확인)
응답의 다양성을 세 단계에서 재는 관측 경로가 생기고, Phoenix proto에 광고 구매 금액을 회귀하는 연속 헤드가 추가됐다(daily). +592/−9 (파일 17). home-mixer/side_effects/response_diversity_stats_side_effect.rs(186줄 신규): 응답 다양성 3단(pre_heuristic/top10/final) 병렬 관측 side effect가 Phoenix candidate pipeline에 6번째로 연결. 기본 sampling은 5%이고, 새 param EnableResponseDiversityStatsExperimentBucket이 실험 버킷을 잡으면 그 안에서 100% 실행. Stage마다 ResponseDiversity.* 접두 histogram 12~15개(Size·UniqueAuthors·MaxAuthorShare·AuthorHhi·AuthorEntropyNorm·UniqueSources·SourceEntropyNorm·InNetworkShare·SidCoverage·UniqueSidL1/L2·MaxSidL1Share·SidL{1,2}EntropyNorm). pre_heuristic은 weighted_score(DPP·author-decay·OON multiplier 적용 전 원점수) 내림차순 top-K counterfactual이라 heuristic 층의 실제 다양성 개선/랭킹 손실을 실측 가능. home-mixer/util/composition.rs(63줄 신규): Composition primitive(size·unique·max_count·HHI·entropy_norm)로 어떤 축이든 다중집합 통계 O(n) 계산. home-mixer/candidate_hydrators/tweet_type_metrics_hydrator.rs(+100/−4), home-mixer/util/tweet_type_metrics.rs(+27): TweetTypeMetrics bitset에 슬레이트 축 12개 bit 신설. 전역 축(239-244) unique_ratio_lte_50_pct·unique_author_lte_5/10/15·single_author_gte_25/50_pct, 문맥 축(315-320) author_repeat_in_slate·author_repeat_gte_3_in_slate·author_not_engaged_by_viewer·has_semantic_ids·sid_l{1,2}_repeat_in_slate. home-mixer/models/candidate.rs(+8): semantic_id_prefix(levels) helper. home-mixer/util/viewer_history.rs(61줄 신규): Arrow IPC columnar_scoring_sequence에서 18개 positive action(fav·reply·quote·RT·click·profile_click·photo_expand·video_open·video_quality_view·share·DM·copy_link·bookmark·follow) 중 하나 이상 켜진 authorId 집합을 추출하는 positively_engaged_author_ids. Phoenix proto ADS_WEB_CT_PURCHASE_VALUE = 5 (ContinuousActionName 5번째 continuous head, 웹 클릭-스루 컨버전 후 구매 금액 회귀), PredictNextActionsRequest field 23 repeated PurchaseValueBaseline purchase_value_baselines(impression_id·advertiser_account_id·mean_value_usd_28d, 광고주별 최근 28일 평균 구매액 baseline이 요청 페이로드로 유입), phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs(114줄 신규, is_valid_baseline·find_baseline, wire tag 안정성 unit test 5개). phoenix/xrex/configs/xrecsys_two_tower.py(+1/−1): H100 학습 attn 커널 pallas_ranker_varlen_attn → cutedsl_ranker_varlen_attn (GB300과 동일한 CUTe DSL 계열로 통일, 4세대+17에서 SM80·SM90 forward·backward 오픈소스화의 후속). phoenix/xrex/data/streaming/kafkaloader.py(+2/−2): Kafka OTel export 간격 30_000 → 120_000 ms(4배 완화). grox/flows/reply_spam/task_filter.py(+1/−1): TaskCoordinatedSpamFilter 팔로워 임계 10k → 30k(1k에서 세 번째 상향). home-mixer/params/param.rs mirror timestamp 2026-09-11T16:22:45Z → 2026-09-14T16:22:15Z
Phoenix가 click dwell time을 기본으로 학습하기 시작했고, 광고 브랜드 안전 V2 게이트는 롤백됐다(daily). +552/−233 (파일 26). Phoenix 광고 ADS_WEB_CT_PURCHASE_VALUE Huber ratio loss가 학습 그래프에 정식 연결(가중치 기본 0), 새 binary 연속 loss type 도입, click_dwell_time이 sigmoid BCE(threshold=10s)로 기본 학습 활성, 히스토리 인코더에도 click_dwell 특징 기본 ON, XAUC(clicked-only pairwise concordance) 메트릭 신설, sigmoid 헤드 서빙 rescale 헬퍼 신설, user_gender 카테고리컬 인덱스 매핑이 하드코딩(Male=2·Female=1)에서 proto enum 그대로로 교체(임베딩 테이블 인덱스 침묵 변경), H100 attn 커널 override가 pallas_ranker_varlen_attn → cutedsl_ranker_varlen_attn으로 최종 전환(4세대+21의 서비스 코드 변경 이후 override까지 정리), VMRanker gRPC 클라이언트에 DSCP=InferenceCritical 지정, EnableAdsBrandSafetyVerdictV2true → false(V2 광고 브랜드 안전 게이트 롤백), grox reply-spam 분류기가 스레드 팔로워 >150k에서 전용 oai-gemma4-26b-reply-spam 모델로 이원 분기, task_filter 임계 150k → 180k(GROK_GEMMA_FOLLOWER_SPLIT), grox 인용 포스트 렌더에 by @handle 및 self-quote 마커, AES decision_outcome.score_id 전달, VF safety_label/hydration/rules/twemcache의 6개 enum이 strum::IntoStaticStr 매크로로 통일(-180줄, 동작 무변화)
VMRanker가 점수만 다루던 DPP wrapper에서 value model 입력을 실어 보내는 쪽으로 넓어졌고, 후보가 어느 retrieval 소스에서 왔는지 남기는 파이프라인이 생겼다. VF 규칙 엔진은 판정을 숨김과 표시 두 갈래 트리로 나타내도록 다시 쓰였다 daily. +5,588/−2,601 (파일 72). VMRanker가 score-only DPP wrapper에서 value-model input carrier로 확장됐다. 두 새 파라미터 VMRankerSendValueModelInputs·VMRankerComputeValueModel(둘 다 기본 false)에 따라 세 모드(dpp_only·send_inputs·compute_value_model)로 갈린다. 신규 파일 home-mixer/scorers/vm_ranker_request.rs(+74)는 RankRequest에 viewer context, 후보별 8필드, PhoenixScores 26필드, 정책 플래그 2종을 싣는다. 정책 플래그는 author_policy_zeroed, cold_start_lift_to_rank다. 신규 domain type FsRecipientInputs(12필드, home-mixer/models/fs_recipient.rs, +55)는 FS matching에서 조립한 정보를 downstream scorer가 재사용하도록 한 타입으로 통일한다. 신규 클러스터 Experiment6이 등록됐다. Retrieval candidate attribution 파이프라인이 신설됐다. 신규 파일 home-mixer/side_effects/retrieval_candidates_kafka_side_effect.rs(+209)는 RetrievalCandidateBatch proto를 RETRIEVAL_CANDIDATES_TOPIC로 발행한다. 샘플은 5%(RetrievalCandidatesKafkaSamplePercent=5.0)이고, 후보 상한은 200(RetrievalCandidatesKafkaMaxCandidates=200)이다. 이를 뒷받침하는 필드로 home-mixer/models/candidate.rs에 PostCandidate::retrieval_sources: Vec<RetrievalSource>(served_type × cluster × score × position)가 신설됐다. Phoenix, MoE, Topics 세 retrieval RPC 소스는 신규 헬퍼 candidates_from_retrieval_response로 옮겨 갔고, dedup 필터는 여러 소스의 origin을 병합한다. 대응 aggregate metric Phoenix.RetrievalContribution.{Selected,PhoenixTopK,SelectedTotal} 세 계열은 side_effects/scored_stats_side_effect.rs(+90)에 연결됐다. VF rule engine이 flat VfAction에서 이원 Verdict 트리로 완전히 재작성됐다. 다시 쓴 분량은 약 2,500줄이다. 신규 파일 visibility-filtering/models/verdict.rs(+62)가 Verdict::Withheld{Drop|Tombstone}와 Shown{media?, engagement?}의 이원 트리를 정의한다. 선택적 media interstitial과 선택적 limited engagement는 겹칠 수 있고, 조합은 6가지다. 신규 파일 visibility-filtering/treatment.rs(+446)는 verdict를 Thrift Action, proto, metric label의 3가지로 사영한다. TombstoneReason::LocalRegulations와 LimitedEngagementReason::ConversationControl 슬롯은 wire만 있고 아직 rule이 없으며, #[expect(dead_code)]로 표시돼 있다. Rule 표현은 함수에서 데이터로 바뀌었다. visibility-filtering/rules/rule_spec.rs(+240/−69)의 RuleClause { rule_name, when: &[Condition], applies_to: Audience, action: ActionSpec }가 rule을 표현한다. Predicate 카탈로그는 4계열이다(TweetPredicate 11종·AuthorPredicate 8종·ViewerPredicate 5종·RelationshipPredicate 5종). 신규 TimelineHomeHydration은 4번째 SafetyLevel로, hydration path 전용 6-rule policy에 연결됐다. Thrift enum 4가 이제 인식된다. 신규 helper tweet_timestamp_ms(tweet_id)가 Snowflake epoch 헬퍼를 노출한다. 신규 rule NsfwHighPrecisionAdultInterstitialRule은 CreatedAfter(1705536000000)(2024-01-18 UTC) 이후 트윗에 InterstitialReason::Nudity(true)를 적용한다. 그 이전 트윗에는 기존 Sensitive(true)를 적용한다. Regression test가 TimelineHome의 30개, TimelineHomeRecommendations의 56개 rule 순서를 고정한다. Phoenix xrex serving의 bloom filter와 topic filter가 model_runner로 통합됐다.phoenix/xrex/inference/serving_filters_runner.py(−543)와 phoenix/xrex/models/recsys_two_tower_serving_filters.py(−173)가 삭제됐다. 두 필터는 phoenix/xrex/inference/model_runner.py(+328)의 RetrievalModelRunner로 통합됐다. Topic filter는 6개 옵션 parquet(1fav_topic_option_{0..5}_1day.parquet)를 쓴다. bloom filter의 mask는 bucket별 pinned host 메모리에 있고, rust(RetrieveRequestBatch::build_eligible_mask_into_shards())가 채운다. 둘 중 하나라도 켜지면 live post-embeddings hotswap이 비활성화된다(drained finalize 경로). 신규 phoenix/xrex/inference/int8_post_table.py(+11)의 quantize_post_table()은 per-post absolute-max 스케일링으로 bf16을 int8로 변환한다. Post embedding pspec은 ("stage", "expert", "replica", "data")의 4-axis sharding으로 확장돼 pipeline·data 병렬 축이 추가됐다. Phoenix engine의 copy_port가 대규모로 리팩터됐다.phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs(+879/−93)는 batch-per-batch 방식을 tokio JoinSet 기반 streaming continuation으로 바꿨다. slow-tail transfer가 다음 배치를 막던 문제가 해소됐다. 새 env var는 두 개다. COPY_PORT_TRAINER_CONNS_PER_SOURCE는 트레이너당 병렬 커넥션을 1~16으로 clamp하고, XAI_RECSYS_RDMA=1은 RDMA에서 다중화를 비활성화한다. 신규 spawn_striped_trainer_downloads()는 트레이너가 소유한 파일을 커넥션 수로 균등 분할해 파이프라이닝한다. 홈믹서의 하드코딩 상수 2개가 FS로 승격됐다.NEW_USER_OON_WEIGHT_FACTOR는 NewUserOonWeightFactor(기본 0.00001)로 바뀌었다. ANN_MAX_POST_CANDIDATE_AGE_HOURS는 SimclustersMaxCandidateAgeHours(기본 48)로 바뀌었다. Simclusters cache key는 (signal_id, max_candidate_age_hours)로 확장됐다. Cold-start 결정이 후보에 태그로 남는다.AuthorColdStart::apply_with_decisions()는 ColdStartOutcome { scores, author_policy_zeroed: Vec<bool>, lift: Option<ColdStartLift> }를 반환한다. RankingScorer가 두 결정 태그를 PostCandidate.author_policy_zeroed·cold_start_lift_to_rank에 채운다. 같은 author의 dedup은 control_viewer_dedups_author_rules_eval_for_duplicate_authors 테스트로 고정됐다. Phoenix MoE 소스에 kill-switch decider가 생겼다.PHOENIX_MOE_SOURCE_KILL_SWITCH_DECIDER = "disable_home_mixer_phoenix_moe_source"가 켜지면 소스가 즉시 꺼진다. PhoenixMoeColdStartMaxResults(기본 0)가 신설됐다. 4세대+25의 non-MoE cold-start 슬라이스 상한을 MoE에도 노출한 것이다. Grox SimpleReplyScorer가 확장됐다. quoted post가 있는 리플라이는 full ReplyScorer에 위임한다. 3단 JSON 복구(json_repair.repair_json() 폴백, regex 마지막 방어)와 ranked_replies_scores 4-bucket histogram이 들어갔다. param.rs mirror sync 시각은 2026-09-21T16:23:47Z다.
VM-Ranker 서비스가 value model을 직접 계산하게 됐고, 파라미터 mirror가 두 번째 채널로 공개됐다. VF에서는 전날 자리만 만들어 둔 대화 제어 슬롯이 새 규칙으로 채워져, 대화의 답글 범위 설정에 들지 않는 뷰어의 답글 참여를 제한한다 daily. +4,993/−3,132 (파일 92). VM-Ranker 서비스가 value model을 직접 계산한다. 신규 파일 vm-ranker/scoring/value_model.rs(+191)의 compute_or_fallback이 shared crate xai_value_model::compute_value_scores()를 인라인으로 호출한다. 별도 모델 서버가 아니다. 입력은 세 가지다. 필드 34개를 담은 ValueModelWeights, OON 배수를 담은 QueryScoringContext, Phoenix 참여 확률을 담은 CandidateScoringInputs다. 출력은 scores와 weighted 두 벡터다. 파라미터 mirror가 두 번째 채널로 공개됐다. 신규 vm-ranker/params.rs(+211, 파라미터 56개)의 양수 가중치로는 FavoriteWeight=0.5, ReplyWeight=5.0, RetweetWeight=1.0, QuoteWeight=5.0, ShareWeight=2.0, FollowAuthorWeight=4.0, ClickWeight=0.4, VideoOpenWeight=0.07, PhotoExpandWeight=0.05가 있다. 음수 가중치로는 NotInterestedWeight=-43.2, BlockAuthorWeight=-31.2, MuteAuthorWeight=-58.8, ReportWeight=-234.0이 있다. 그 밖의 값으로는 DppEnabled=true, DppTheta=0.65, DppMaxSelectedRank=150, ComputeValueModel=false(기본 off), OonWeightFactor=0.75, TopicOonWeightFactor=0.5, NewUserOonWeightFactor=0.00001, EnableAuthorDiversity=true, AuthorDiversityDecay=0.5, AuthorDiversityFloor=0.25 등이다. 파라미터 갱신은 신규 vm-ranker/config_sync.rs(+235)가 맡는다. 30초 주기로 git remote를 sparse-clone·reset해 features/home-mixer/main/rust_home_mixer.yml·abdecider/abdecider.yml을 폴링하고, 프로세스 재시작 없이 hot-reload한다. 캐시된 후보의 head 재계산을 건너뛰는 경로가 신설됐다. 홈믹서 param CachedPostsReuseWeightedScore, RankingScorer::reuses_cached_weighted_score(), unoffset_score()가 이 경로를 이룬다. 실험 페이로드 채널이 신설됐다. 홈믹서 param VMRankerSendDebiasInputs와 신규 home-mixer/scorers/vm_ranker_debias_payload.rs(+52)가 후보별 컨텐츠 피처와 요청 컨텍스트를 experiment_payload proto 필드에 싣는다. 컨텐츠 피처는 has_video·max_video_duration_ms·has_photo·media_count·weighted_text_len·newline_count·has_url과 그 quoted 등가다. 요청 컨텍스트는 engine_time_hint_ms·columnar user-sequence ArrowIPC·product_surface·client_app_id다. 홈믹서 vm_ranker.rs는 얇아졌다(15/−31). 인라인으로 갈리던 4-mode branching은 struct OptionalInputs { value_model, compute_value_model, cached_weighted_score, debias }로 캡슐화됐다. 응답 튜플은 (score, weighted_score)로 확장됐다. value model 경로의 신규 metric은 vm_ranker_value_model_requests_total{mode=passthrough|value_model|fallback}, vm_ranker_value_model_fallback_total{reason=...}, vm_ranker_value_model_stage_total{stage=heads|cached_weighted}다. 그 밖에 vm_ranker_config_sync_* 5종과 vm_ranker_params_resolved_total이 신설됐다. VF의 conversation-control 슬롯이 4개 rule로 채워졌다. 전날(3aa0fa33) 예약된 LimitedEngagementReason::ConversationControl 슬롯이 실제로 연결된 것이다. 신규 hydrator visibility-filtering/hydration/conversation_control_hydrator.rs(+310)는 TES get_conversation_controls()로 arm, root author, invited users를 조회한다. Community arm이면 socialgraph의 신규 batch_check_followed_by(viewer_id, root_authors)(역방향 엣지)로 root 저자가 viewer를 팔로우하는지 확인한다. Subscribers arm이면 batch_check_super_follows(viewer_id, root_authors)로 viewer가 root를 super-follow하는지 확인한다. 결과는 신규 타입 visibility-filtering/models/conversation_control.rs의 ConversationControlFeatures { control, root_author_follows_viewer, viewer_super_follows_root_author }에 담긴다. 신규 rule 4종은 visibility-filtering/rules/tweet_rules.rs:328-383에 있다. 각 rule은 자기 arm에서 조건에 맞지 않는 viewer의 reply engagement에 LimitedEngagementReason::ConversationControl을 부여한다. LimitRepliesByInvitationConversationRule은 초대되지 않은 viewer, LimitRepliesCommunityConversationRule은 팔로우를 받지 않는 viewer가 대상이다. LimitRepliesSubscribersConversationRule은 super-follow하지 않는 viewer, LimitRepliesVerifiedConversationRule은 미인증 viewer가 대상이다. Viewer::LoggedIn(u64)는 Viewer::LoggedIn { id, profile: ViewerProfile { allows_sensitive_media, viewer_age, has_verified_badge, account_country_code } }로 확장됐다. has_verified_badge는 Gizmoduck VerifiedType::Business/Government이거나 is_blue_verified일 때 true다. VF hydration이 lazy 방식으로 재편됐다.visibility-filtering/hydration/mod.rs(+360/−77)에 신규 enum Hydrator(7 variant)와 bitset Hydrators(u8)가 도입됐다. 각 predicate는 const fn hydrators()로 자신의 요구를 선언하고, RuleClause::hydrators()가 이를 union한다. RuleEngine::hydrators_for(safety_level)는 active policy가 실제로 참조하는 hydrator만 실행한다. TES 하이드레이션 코드가 대량 삭제됐다(tes_hydrator.rs -333, models/mod.rs -223, safety_label_source/lookup.rs -89). 이는 전날 Strato 컬럼으로 이관된 TweetForVisibility의 반작용이다. PureCoreHydration 래퍼와 캐시-recovery 로직이 제거됐고, fetch_pure_core → fetch_author_ids로 이름을 바꾸면서 TweetHydrationBatch<AuthorId>로 단순화됐다. grox 비디오 안전에 key-frame 파이프라인이 신설됐다. 신규 grox/libs/video_tools/key_frames.py(+206)의 KeyFramesExtractor는 PyAV와 OpenCV로 scene-cut을 검출한다. 32×32 grayscale 시그니처의 L2 delta가 max(6.0, 4.0×median_delta)보다 크면 scene-cut으로 본다. 이어 shot별 midframe salience 순위 상위 3장을 PTS 기준으로 정확히 디코딩한다(창 3초·15fps, blank shot은 stddev<8.0으로 후순위). 신규 grox/flows/ptos/plan_safety_ptos_key_frames.py(+52)의 DAG는 rate limit → key frames filter → media hydration → adult-only init 순으로 시작한다. 이어서 policy detection → safemodel sex/nudity → adult content cross-validation → sink 순으로 진행한다. 필터 task_safety_ptos_key_frames_filter.py(+81)의 게이팅 조건은 비디오 필수, fav ≥ HIGH_FAV_THRESHOLD=128, (기존 adult label 존재 OR fav ≥ KEY_FRAMES_UNCONDITIONAL_FAV=512)이다(grox/flows/ptos/constants.py:25, 43). rate limit은 60s TTL·10k dedupe다. 미디어 스토리지는 blobstore에서 O2 S3 프로토콜로 전환됐다. grox/core/data_loaders/media_reference_bundle.py의 새 S3Store(boto3, https://o2-https.o2-prod.atla-prod-storage.cluster.kube.int-x.ai)가 s3://<bucket>/<prefix> URI 파싱으로 대체했다. MediaHydrationConfig에는 use_key_frames(기본 False), key_frames_window_seconds=3.0, key_frames_max_fps=15.0, key_frames_max=3이 들어갔다. MOE cold-start 실험이 이관됐다.PhoenixMOEMaxResults와 PhoenixMoeColdStartMaxResults의 default 배치가 스왑됐고, PhoenixRetrievalMOEInferenceClusterId 클러스터 상수가 교체됐다.
홈믹서의 랭킹 스코어러 파일 전체가 삭제되고, 전날 인라인으로 호출하던 공유 value model crate가 실물 파일로 공개됐다. 랭킹 계산 파라미터 22개가 홈믹서 param.rs에서 빠져 vm-ranker/params.rs mirror에만 남았고, vm-ranker 호출이 실패하면 홈믹서는 로컬 가중합을 그대로 내보낸다 daily. +2,569/−2,257 (파일 62). 홈믹서의 ranking_scorer.rs(1,255줄) 파일 전체가 삭제됐다. 전날(1b3fec20) 인라인 호출 대상이었던 shared crate는 이번에 실물 파일 6개(총 807줄)로 공개됐다. 신규 파일은 xai-value-model/lib.rs(+14), inputs.rs(+35), phoenix_scores.rs(+28), proto.rs(+92), scoring.rs(+444), weights.rs(+194)다. 노출 심볼은 ValueModelWeights(34 필드)·CandidateScoringInputs·QueryScoringContext·PhoenixScores(26 필드)·compute_value_scores·compute_value_scores_with_adjustment·fuse_heads·compute_weighted_score·offset_score·post_fusion_multipliers·apply_cold_start_decisions·diversity_multiplier·author_pool_counts·PostFusionMultiplier·ValueScores·NEGATIVE_SCORES_OFFSET=0.001이다. 홈믹서 쪽 대체 어댑터는 신규 home-mixer/scorers/value_model.rs(+114) 하나뿐이다. weights_for(query)가 ValueModelWeights의 34개 필드를 FS 파라미터 29개와 고정값 5개로 채우고, weighted_score()는 공유 crate의 fuse_heads를 그대로 호출한다. 홈믹서 param.rs에서 랭킹 계산 파라미터 22개가 삭제됐다. 삭제된 파라미터는 EnableAuthorDiversity·AuthorDiversityDecay·AuthorDiversityFloor·OonWeightFactor·MultiplierPreOffset·EnableOonRescoreForInNetworkRepliesRetweets·TopicOonWeightFactor·NewUserAgeThresholdSecs·NewUserOonWeightFactor·EnableMultiplicativePostUnexplored·MultiplicativePostUnexploredAlpha·PostUnexploredWeightInNetworkOnly·EnableCdwellOnImpr·ContActiveSecs5mResidualNormWeight·WeightPerturbationSigma·WeightPerturbationSalt·EnableVMRanker·VMRankerDppTheta·VMRankerDppMaxSelectedRank·VMRankerSendValueModelInputs·VMRankerComputeValueModel·CachedPostsReuseWeightedScore다. 이제 이 값들의 유일한 mirror는 vm-ranker/params.rs(56 파라미터, 전날 신설)다. param.rs sync 시각은 2026-09-22T17:39:32Z → 2026-09-23T16:28:43Z로 바뀌었다. home-mixer/scorers/vm_ranker.rs가 local-first, RPC-refine 구조로 재구성됐다(+84/−76). 새 함수 local_scores()가 후보별 로컬 가중합과 AuthorColdStart::apply_with_decisions()의 콜드스타트 결정을 먼저 계산하고, vm-ranker gRPC로 refinement를 요청한다. RPC가 실패하면 로컬 가중합을 그대로 서빙한다. 기존에는 후보별로 Err를 전파했다. EnableVMRanker 게이트는 제거되고 EnableRanking으로 이관됐다. Phoenix retrieval에 HOME_HOT = 14 데이터셋이 신설됐다.phoenix/xrex/data/retrieval_dataset.py(+103/−8)에서 HOME_HOT은 HOME_COLD = 13 옆에 등록됐다. 둘 다 같은 1fav_1day.parquet 소스에서 나온다. 둘을 가르는 것은 신규 phoenix/xrex/data/cold_pool_filter.py(+78)의 cold_pool_mask(post_ids, meta_path, max_favs=8, max_views=500) × fresh_post_mask(post_ids, max_age_seconds)다. HOME_COLD는 cold_keep이고, HOME_HOT은 그 여집합이다. Phoenix bundle export가 per-target 인덱스 메타데이터를 담도록 확장됐다.phoenix/xrex/train/recsys_bundle_export.py(+170/−46)의 RetrievalExport dataclass에 4필드가 생겼다. 새 필드는 dataset_capacities·rows_from·max_age_seconds·optional_targets다. 같은 파일에 POST_SCALES_KEY·DATASET_RANGES_KEY 상수와 enable_int8_post_table·enable_bloom_filter·enable_topic_filter 게이팅도 들어갔다. VF가 retweet과 source의 verdict를 병합한다.visibility-filtering/models/verdict.rs(+60)에 Verdict::merge_retweet_verdict(retweet, source)가 생겼다. source의 Withheld가 우선하고, retweet의 Withheld는 source의 비-Withheld를 이길 때 유지된다. retweet이 fully-unrestricted면 source를 상속하고, 나머지 경우는 retweet을 유지한다. 실제 흐름은 visibility-filtering/evaluate_tweets.rs(+157/−18)에 있다. 1차 평가 뒤 평가된 리트윗의 source_tweet_id를 모아 in_batch와 fetched로 나눈다. fetched에 대해서는 filter_tweets.run()을 2차로 호출한다. 그다음 source verdict를 조회해 merge_retweet_verdict를 적용한다. 신규 메트릭은 evaluate_tweets_retweet_sources{outcome=in_batch|fetched}이다. 이를 뒷받침하는 변경으로 visibility-filtering/hydration/mod.rs(+244/−18)에 pure_cores: TweetHydrationBatch<PureCore>와 재시도용 PureCoreFallbackCache가 추가됐다. VF의 clock_cache.rs(412줄)가 삭제됐다.2026-09-10(4세대+19)에 "미배선"으로 도입됐던 프리미티브가 14일 만에 제거된 것이다. grox PTOS에 신규 카테고리 AgeGatingHarmfulText가 생겼다. 서브타입은 5개로 AdultContent·EatingDisorder·HarmfulSubstance·DangerousStunt·GraphicDescription이다. grox/flows/ptos/state.py(+6)와 classifier.py(+5)에서 USE_GEMMA_CATEGORIES 세트에 편입됐다. 이 커밋 기준으로 이 세트에 든 다른 카테고리는 Spam·IllegalAndRegulatedBehaviors뿐이다. 프롬프트 헬퍼는 age_gating_harmful_text_policy_prompt()이고, 템플릿 .j2 본문은 이 커밋 tree에 없다. grox reply spam의 GROK_GEMMA_FOLLOWER_SPLIT이 225k에서 250k로 올랐다. 4세대 공개의 15k에서 시작한 뒤 열한 번째 상향이다. 홈믹서 scored_stats에 SimClusters retrieval 기여 attribution이 추가됐다.Phoenix.RetrievalContribution.SimclustersTopK 계열이 [50, 100, 200, 400, 600, 800] 버킷으로 신설됐다. Phoenix.RetrievalContribution.ScoredTopK는 selected와 non-selected 전체를 대상으로 top-k 소스 분포를 관측한다.
값의 예는 reply 5.0, favorite 0.5, retweet 1.0, share via copy link 20.0, report −234다.
상호팔로우 원본에는 reply +15 boost가 붙는다.
2026-08-14 커밋(4세대+1)에서 가중치 해석 오해를 짚는 코드 주석과 브라질 2026 선거 필터가 생겼다. 콜드스타트 Thompson sampling, 14일 stale post 게이팅, BDSM enforcement note 생성기 일부 공개도 붙었다. (c65aa17, daily)
코드 주석에는 “ratio를 raw count 등가로 읽지 말라”는 경고가 명시됐다.
브라질 2026 선거 필터는 665개 계정을 하드코딩했고, 팔로잉 예외를 둔다.
2026-08-17 커밋(4세대+2)에서 광고 브랜드 안전 판정 V2 v1과 성인 콘텐츠 판정의 cross-validation 계층이 붙었다. Phoenix retrieval의 인덱싱 게이트는 제거됐다. (b089ce6, daily)
2026-08-18 커밋(4세대+3)에서 광고 안전 V2 헬퍼가 하루 만에 다시 설계됐다. Following 전용 VF hydrator, VMRanker의 SID 연결, Phoenix Kafka v3, grox reply_spam 통합도 함께 붙었다. (11a71f8, daily)
2026-08-19 커밋(4세대+4)에서 Phoenix 학습의 기본 optimizer가 Adam에서 Muon으로 바뀌고, Phoenix가 검색 랭킹으로 확장됐다. Following 파이프라인 하드닝, Grok 4.6 dial, VF 섀도 미러링도 들어 있다. (aad7179, daily)
Phoenix 학습이 Adam에서 Muon optimizer로 전환됐다.
dense-parameter 기본 optimizer가 optax 기반 Muon(Newton-Schulz orthogonalization)으로 바뀌었다.
embedding·norm·logits는 여전히 Adam을 쓰는 하이브리드다.
새 base learning rate는 7.1e-4, 이전 값은 1e-3이다.
Phoenix가 검색 랭킹으로 확장됐다.
새 config xrecsys_search가 등장했고, 별도 vocab(100M user/item, 30M author)을 쓴다.
Grok 4.6 internal dial이 0.1 → 0.3으로 올랐다. 하루 만에 3배다.
grok_sampler에 stop_predicate callback과 aclosing이 붙었다.
스트리밍 조기 종료와 llm.sample.early_stop.count 카운터가 함께 들어갔다.
grox User 스키마에 profile_image 필드가 신설됐다.
2026-08-21 커밋(4세대+6)에서 광고 conversion 액션 20개와 delayed feedback의 학습 손실 분기가 생겼다. Muon optimizer는 export에 정식 포함됐고, Grok 4.5 internal은 완전히 빠졌다. (28e414f, daily)
광고 conversion 액션 20개가 proto ActionName 171–190에 신설됐다.
visibility-filtering의 dark_traffic 타깃이 shadow에서 staging으로 바뀌었다.
xai-vf-shadow.prod.visibility가 xai-vf-service.staging.visibility:grpc로 리네이밍됐고, TLS 도메인도 .staging.으로 옮겨 갔다.
⚠ 이 변경으로 2026-08-19 커밋(4세대+4)의 shadow 서술이 재해석된다.
Grok 4.5 internal이 완전히 제거됐다.
2026-08-19 커밋(4세대+4)에 시작한 4.6 dial이 0.1→0.3→100%로 바뀌어 이틀 만에 끝났다. 대상은 아동 안전 심판과 deluxe 재분류이고, 4세대+2의 성인 콘텐츠 심판은 아니다.
_eapi_4_5_internal_breaker·_sample_4_5_internal·_GROK_4_6_INTERNAL_DIAL이 전부 삭제됐다.
retrieval post-embedding에 65k row 청킹이 들어갔다.
hotswap에 --hotswap_malloc_trim 옵션이 붙어, 사이클당 ~196MiB 메모리 누수가 문서화됐다.
2026-08-24 커밋(4세대+7)에서 VF 판정을 레퍼런스 VF와 매 요청 대조하는 스테이징 전용 하니스가 생기고, 광고 브랜드 안전 판정이 4단계로 늘었다. Grok 4.5는 PTOS 세 카테고리에서 강등만 허용하는 judge로 다시 들어왔다. (d011592, daily)
VF 레퍼런스 비교 하니스가 새 파일 visibility-filtering/reference_compare.rs(812줄)로 생겼다.
이 하니스는 Strato 기반 레퍼런스 VF인 StratoVfClient를 매 요청마다 병렬로 호출하고, 서비스가 산출한 verdict와 엄격 비교한다.
Muon 세대 이후 Phoenix 모델이 커져 다중 워커 동시 restore가 호스트 OOM을 유발하는 문제에 대응한 것이다.
reply_spam 스코어 라이트백에 지역 캐시 팬아웃이 추가됐다.
grox/flows/reply_spam/strato_loader.py의 ReplyRankingScoreStratoLoader는 4세대 공개 때부터 점수를 Strato에 put하고 Kafka v2에 emit했다(47c1bcd, 32줄). Kafka emit은 다운스트림 실시간 파이프라인용이다.
이번 커밋(+17/−0)은 put 뒤에 score ≤ 1.0이면 지역 캐시(Atla, Pdxa)에도 병렬로 쓰는 단계를 추가했다(d011592).
reply_spam follower 임계가 80k → 100k로 올랐다.
TaskSpamFilter와 TaskReplyRankingFilter가 둘 다 상향됐다.
11일간 다섯 번(15k → 30k → 40k → 60k → 80k → 100k) 올라 스팸 판정 대상이 계속 넓어졌다.
grox에 X Space(오디오) 렌더링이 들어갔다.
SpaceMetadata 클래스가 신설됐다. 이 커밋 시점에는 title만 있다.
Post에 space_metadata 필드가 생겼고, post_mapper.py가 Strato에서 이 값을 매핑한다.
2026-08-25 커밋(4세대+8)에서 스코어러가 요청한 액션을 abuse-enforcement-service가 이중 검증 뒤 실행하게 됐고, VM Ranker 경로가 크게 단순화됐다. quote·retweet을 거친 블록 우회도 Following에서 막는다. (0d3cdd8, +5,850/−586, 파일 47개, daily)
abuse-enforcement-service에 generic_actions 패스스루가 생겼다.
service-lib/src/generic_actions.rs(663줄 신규), growthbook.rs(+126), lib.rs(+276)로, 스코어러가 직접 요청한 액션을 두 allowlist로 이중 검증한 뒤 실행한다.
하나는 하드코딩된 kind allowlist다(user 5종·post 2종, MAX 16개/msg).
다른 하나는 GrowthBook 동적 allowlist다(kinds·suspend_policies·labels).
grox upa/constants.py·generators.py가 새로 연결됐다(내용 미확인).
engagement_counts_hydrator에는 +16 변경이 있었다.
2026-09-01 커밋(4세대+12)에서 NSFW 게이팅 대상 16개국이 처음으로 코드에 명시되고, special video 판정 경로가 새로 생겼다. 이 경로는 X가 능동적 회피 대응 layer를 코드로 공개한 첫 사례다. (6384ca7, +2,247/−735, 파일 70개, daily)
NSFW 게이팅 대상 16개국이 처음으로 코드에 명시됐다.
visibility-filtering/params.rs(216줄 신규)가 ar au br ca de es fr gb id it kr mx nl ph pt th를 default로 담는다.
phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py·encrypted_kvstore.py가 새로 생겼다(49줄+49줄).
암호화 체크포인트 kvstore이며, feed 랭킹과의 직결 관계는 확인되지 않았다.
2026-09-01 커밋(4세대+13)에서 visibility-filtering 규칙 파일 9개가 3개로 재구조화됐다. 같은 커밋에서 RQ-VAE 계열 SID의 랭커 입력 연결과 StableHLO 번들 export가 처음 공개됐지만, 둘 다 기본 off다. (7ba7768, +2,964/−2,861, 파일 34개, daily)
visibility-filtering의 rules 파일은 9개에서 3개로, 줄 수는 2,670줄에서 1,568줄로 줄었다.
규칙마다 struct + impl Rule이던 것을 공용 enum RuleSpec(Tweet/Author/Custom 세 변형, 한 줄 리터럴)으로 바꿨다.
삭제된 규칙 파일은 nsfw_age_gating.rs·nsfw_interstitial.rs·nullcast_rule.rs·socialgraph_rules.rs·tes_rules.rs다.
2026-08-19 커밋(4세대+4)은 VF 서비스에 요청 미러링(dark traffic) 장치를 붙여, 프로덕션 요청을 shadow 배포로 복제해 보낼 수 있게 했다 확인. VF 정책 변경을 프로덕션 트래픽으로 shadow 검증할 기반이 생겼지만, 그 위에서 어떤 규칙 실험이 도는지는 공개되지 않았다. 이 커밋은 daily에서 다뤘다.
⚠ 4세대+6(2026-08-21, 28e414f)에서 이 타깃의 이름이 xai-vf-service.staging.visibility:grpc로 바뀌었다. TLS 도메인도 visibility.visibility-filtering-service.staging.{dc}.s2s.twttr.net으로 옮겨 갔고, StaticShadowDiscovery도 StaticStagingDiscovery로 바뀌었다. 즉 dark_traffic이 "프로덕션 shadow 워크로드 미러링"에서 "staging 환경 미러링"으로 3일 만에 재정의됐다. 해석은 두 가지다.
하나는 shadow 워크로드를 따로 유지하지 않고 staging을 shadow처럼 쓰는 인프라 정리라는 해석이다.
다른 하나는 프로덕션 트래픽 shadow가 staging 배포 검증에 쓸 값이 있다는 판단이라는 해석이다.
어느 쪽이든 VF 실험이 프로덕션 shadow 자원이 아니라 staging 자원 예산에서 돌게 된다.
통과하는 예는 xai-vf-service-pr-123.devel.visibility:grpc다. GitHub PR 번호 기반 프리뷰 배포 관행을 시사한다 추정.
거부 케이스는 엄격하다. development·staging-devel·grpc-extra 등 비슷한 이름은 명시적으로 거부한다.
어떤 요청을 미러링할지는 /config/dark-traffic/dark_traffic.yaml의 규칙으로 정한다. 이 동적 config는 ReloadableDarkTrafficConfigBuilder가 파일이 바뀔 때마다 다시 읽는다.
의미: VF 서비스의 정책 변경을 프로덕션 트래픽으로 shadow 검증할 수 있는 기반이 생겼다. 정책 변경의 예로는 4세대+3의 favoriteCount>=1 게이트 제거, 4세대+4의 dark_traffic 자체, 앞으로의 규칙 튜닝이 있다. VF는 fail-closed(안전성 절의 fail-closed 항목)라 규칙 변경 리스크가 크므로, shadow deploy는 이 파이프라인에 자연스럽게 따라오는 필수 인프라다. 실제로 어떤 규칙 실험이 이 위에서 도는지는 공개되지 않았다. dark_traffic.yaml 실물이 레포에 없다.
4세대+7 (2026-08-24) — VF 레퍼런스 비교 하니스 (reference_compare.rs) 배선
2026-08-24 커밋(4세대+7)은 staging VF가 요청마다 Strato 기반 레퍼런스 VF를 함께 호출해 두 verdict(판정)를 엄격하게 비교하고, 다른 것만 로그로 남기는 하니스를 붙였다 확인. 4세대+4에서 도입되고 4세대+6에서 staging으로 향하게 된 dark traffic과 함께 보면, 이 하니스는 VF 재작성이 지금까지 어느 정도 원본과 일치하는지를 프로덕션 트래픽 규모로 실측하는 인프라다. 이 커밋은 daily에서 다뤘다.
FilterTweetsEndpoint는 Option<Arc<ReferenceCompareHarness>>를 들고 있다. 요청마다 harness.begin_compare(viewer_id, country_code, safety_level, tweet_ids)를 호출해 별도 tokio task를 spawn하고, Strato 기반 레퍼런스 VF에 병렬로 요청한다.
레퍼런스 VF는 StratoVfClient가 감싸며, 1·2세대 Scala VF 스택으로 추정된다 추정. 3세대 공개 코드는 Rust/Python이라 Scala VF가 없다.
main path는 자기 verdict를 oneshot::channel로 harness task에 흘려보내지만, 응답에는 영향이 없다(read-only).
harness task는 두 결과를 받으면 compare_batch()로 tweet_id마다 verdict를 엄격하게 비교한다. 기준은 is_exact_match()다. 서비스 action과 레퍼런스 action 라벨이 정확히 같고, FilteredReason도 완전히 일치해야 한다.
다른 verdict는 Diff { tweet_id, service, reference }로 모은다. 같은 (service, reference) 문자열 조합끼리 tweet_id 리스트로 그룹화한 뒤, 12KiB 라인 예산으로 청킹한 JSON 라인을 println!()으로 stdout에 내보내 로그 스트림에 흘린다.
각 라인의 형식은 {h: "vf_reference_compare", v: 1, batch, chunk, build, dc, level, viewer, diffs: [...]}다.
메트릭은 6종이다. 모든 메트릭에 build_sha 태그가 붙어 특정 빌드의 회귀를 추적할 수 있다.
_error(kind=timeout|reference_item|missing_result), _skipped(reason=level_unmapped|logged_out_viewer), _enabled(하니스 부팅 시 1회)
하니스는 켜는 조건, 실행 환경, 비교 범위, 대기 시간을 모두 제한한다.
마스터 스위치는 VF_DUAL_CALL_HARNESS_ENABLED 환경변수(bool)다.
APP_ENV도 확인한다. should_build_harness(flag, app_env)는 (true, Some("prod"))이면 Err("VF_DUAL_CALL_HARNESS_ENABLED is set but APP_ENV=prod; the reference comparator is staging-only")를 반환한다. server_deps.rs가 이 Err을 panic!()으로 올리므로 부팅이 실패한다. 즉 하니스는 staging 전용으로 코드 레벨에서 강제된다.
비교 대상은 SafetyLevel::TimelineHome과 TimelineHomeRecommendations뿐이다. 레퍼런스 VF 코드에도 없는 FilterAll은 건너뛴다. 로그아웃 뷰어도 건너뛴다.
레퍼런스 호출 timeout은 1.5초다. 시간을 넘기면 ERROR "timeout" metric만 남기고 조용히 끝낸다.
이 하니스가 dark_traffic·shadow와 무엇이 다른지는 세 커밋을 나란히 놓으면 드러난다.
4세대+4 (aad7179, 08-19):dark_traffic_setup.rs가 프로덕션 트래픽 요청 자체를 xai-vf-shadow.prod.visibility로 복제해 보낸다. 트래픽 자원 소비만 있고 verdict 대조는 없다. 어차피 dark side는 응답도 보이지 않는다.
4세대+6 (28e414f, 08-21): dark_traffic 타깃의 이름이 shadow prod에서 staging으로 바뀌었다. 즉 프로덕션 트래픽이 staging 배포를 shadow로 검증한다.
4세대+7 (d011592, 08-24):reference_compare.rs는 staging 배포에서 돈다(현재 실행 중인 프로세스가 staging이다). 요청마다 레퍼런스 VF(Strato-backed 이전 세대)를 병렬로 호출하고, verdict를 엄격하게 비교해 diff만 로깅한다. 즉 dark_traffic 스택이 흘려보낸 트래픽이 이 staging 서비스에 도착하면, 그 서비스는 자기 verdict를 계산하면서 동시에 Strato 레퍼런스와 대조한다.
두 레이어를 조합하면 프로덕션 트래픽 → dark_traffic → staging VF → reference_compare의 Strato 레퍼런스 대조 → diff 로그로 이어진다. 즉 이번 공개의 실체는 VF 재작성이 지금까지 어느 정도 원본과 일치하는지를 프로덕션 트래픽 규모로 실측하는 인프라다. VF 재작성은 4세대의 헤드라인 서브레포 중 하나였고, VF는 canon 주의 목록 #1(fail-closed, 규칙 변경 리스크가 큼)에 올라 있다. 그 검증 채널이 이제 완성됐다.
미공개:
StratoVfClient가 정확히 어느 VF 스택을 감싸는지(1·2세대 Scala VF의 잔재인지, 별도 스택인지)
2026-08-24 커밋(4세대+7)은 체크포인트를 복원할 때 동시에 읽는 바이트에 상한을 두고 노드 단위로 직렬화했다 확인. Muon 세대 이후 커진 모델을 여러 워커가 동시에 복원하다 호스트 OOM이 나던 문제를 막으려는 변경으로 본다 추정. 2026-08-26 커밋(4세대+9)은 저장 쪽에도 바이트 예산과 노드 단위 직렬화를 넣고, 배치 단위로 흘려 보내는 streamed 복원과 warm-start staging을 더했다 확인. 복원 쪽은 4세대+7 daily, 저장 쪽은 4세대+9 daily에서 다뤘다.
4세대+7은 체크포인트 로더를 리팩터링해 동시 restore의 호스트 메모리 사용에 상한을 뒀다 확인 — phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+178/−36). 코드의 로그 문자열은 이 throttle의 목적을 bound host transient로 적는다(같은 파일 212행). Muon 세대 이후 모델이 커져 다중 워커의 동시 restore가 호스트 OOM을 일으켰다는 경위는 코드에 없는 해석이다 추정.
load_checkpoint에 새 인자 3개가 생겼다.
domains: dict[str, Any] | None: tensor마다 ts.IndexDomain 슬라이스를 지정한다. 특정 tensor만 부분 로드하거나, 로컬 shape이 체크포인트 shape과 다를 때 슬라이스를 명시해 대응한다.
JSON 스펙(ts.IndexDomain(json=domain))과 인덱싱 표현식(ts.IndexDomain(shape=host_state[name].shape)[domain]) 두 형식을 지원한다.
tag: str | None = None: 체크포인트 서브디렉터리 이름이고, 기본값은 "orbax-ckpt"다. 실제 경로는 path / tag로 만든다.
즉 base path 하나에 여러 체크포인트 tag(예: "orbax-ckpt", "post-warmup", "muon-recipe-v3")를 함께 두는 관행이 코드에 반영됐다.
concurrent_gb: float | None: 동시에 디코딩하는 tensor 바이트의 상한이다. int(concurrent_gb * 10**9)로 환산한 뒤 배치 단위로 read를 끊는다(if cur_bytes + nbytes > concurrent_bytes: 배치 flush). None이면 스로틀 없이 모든 read를 병렬로 issue한다.
로그에 ACTIVE/no throttle을 명시하고, 개별 tensor가 스로틀 예산보다 크면 경고한다.
lock 파일 경로는 XAI_RESTORE_NODE_LOCK_FILE로 정한다. 기본값은 /dev/shm/xai_restore_node_lock이고, /dev/shm이 없으면 /tmp/xai_restore_node_lock이다.
_NodeBatchLock 클래스가 fcntl.flock(fd, LOCK_EX)로 배타 lock을 잡는다. 같은 노드의 여러 JAX 워커가 동시에 restore하는 것을 막는다. 노드 하나가 한 번에 한 배치만 처리하도록 강제하고, 1초 이상 기다리면 로그를 남긴다.
배치 사이에는 _release_batch_memory()가 gc.collect()와 ctypes.CDLL("libc.so.6").malloc_trim(0)으로 glibc 할당자 슬랙을 반환한다.
의미: 4세대+6에서 TRAINING.md가 "flagship ranking config + home_direct_packed_nano가 프로덕션 Muon 레시피로 end-to-end 학습"으로 다시 쓰이고 nano가 GB300 오버라이드를 물려받았는데, 그 세대의 후속 배치 검증 중에 restore path의 OOM 이슈를 발견한 것으로 해석된다 추정. 4세대 이전에는 모델 크기가 작아 이런 인프라가 필요 없었다. 하지만 Muon과 GB300 규모에서는 다중 워커 restore가 노드 메모리를 넘어서므로, node-lock으로 restore를 시간축에 분산하고 concurrent_gb로 배치 크기를 제한하는 것이 필수가 됐다. 4세대의 훈련 스택이 성숙했다는 신호다.
미공개:
어느 tensor가 partial slice(domains)로 로드되는지(Muon용 어떤 optimizer state인지, embedding table 청킹인지)
async_no_deferred 경로도 AsyncEmbGradientUpdate.pending=keep_step(이전 jnp.asarray(True))으로 맞췄다. 비동기 emb gradient가 다음 스텝에 실제로 반영될지를 이번 스텝의 gate 결과로 정한다. 또 _create_async_emb_executables의 assert self.grad_norm_keep_threshold is None이 제거됐다. 이전에는 async deferred 경로가 grad-norm 게이팅과 호환되지 않는다고 가정했는데, 이제 async에서도 그 threshold를 실제로 쓴다.
의미: Muon과 GB300 세대에서 dense와 embedding grad가 서로 다른 스케일로 튀는 관찰이 있었을 것이다 추정. lock-step 게이팅은 그 divergence의 잔여 영향을 원천 차단한다. dense와 embedding table이 lock-step으로만 움직이므로 재현 가능한 학습 정합성을 확보한다. metric에도 관측 필드 세 개가 따로 남는다. valid_step은 dense only, emb_valid_step은 emb only, deferred_emb_grad_norm/valid_step은 deferred 계열이다. 이 필드들로 어느 층에서 gate가 걸렸는지 진단할 수 있다.
배치 사이에 _release_batch_memory()를 부른다(gc.collect()와 malloc_trim(0)).
load_checkpoint_streamed: 새 함수다. 종전 load_checkpoint는 처음에 host_state(모든 텐서의 pinned-host 사본)를 만들었다. streamed 버전은 device_state를 그대로 두고, 배치별로 그 배치의 텐서만 pinned-host stage → 파일 read → device_put으로 device로 이동 → 배치 host memory 해제를 거친다.
auto-window는 min(max(max_leaf, total/16), 32GB)이고(window_gb=None 기본), window_gb를 명시할 수도 있다.
큰 텐서부터 처리하도록 plan.sort(key=lambda item: item[3], reverse=True)로 정렬한다.
Trainer.warm_start_staging_spec 훅: checkpoint에서 params만 복원하고 opt_state는 다시 초기화하는 warm start에서, pinned-host에도 opt_state 사본을 만들지 않는다.
이전에는 restore용 staging sharding으로 device_put할 때 전체 state가 pinned-host로 올라갔다. 이제는 warm_purge(self.state)로 opt_state 필드를 미리 잘라내고 pinned-host에 stage한 뒤, 로드된 부분만 device로 다시 device_put한다. 원래 opt_state field는 유지한다.
flagship Muon 학습이 pretrained params와 fresh optimizer로 시작하는 표준 흐름을, 저장·복원 스택이 원자적으로 지원한다.
admission.reset_estimates와 request_metrics.reset_admission_estimates: Phoenix 서빙 admission controller의 service_time·pipeline_time EWMA를 zero로 리셋한다. Prometheus gauge도 함께 맞추고, 회귀 테스트가 두 개 있다. 트래픽 프로파일이 급변하거나 hot-swap한 뒤 EWMA를 즉시 초기화하는 용도다 추정.
copy_port_client 로깅 정리: dense weights load와 emb_table load의 완료 로그에 총 바이트, 경과 시간, GB/s를 함께 적는다(divide-by-zero 가드). 시작 로그는 지웠고, 이제 끝 로그가 요약 역할을 한다.
의미: 4세대+6이 flagship Muon 학습을 export에 정식으로 포함한 뒤, 다중 워커가 저장할 때 checkpoint save가 wait_until_finished 밖의 D2H 스테이지에서 host OOM을 일으킨다는 관찰이 있었을 것이다 추정. 4세대+7의 restore 쪽과 대칭인 논리다. warm start 시나리오는 flagship이 pretrained params와 fresh optimizer로 시작하는 흐름을 표준화하는 방향이라, 저장 스택과 복원 스택 모두 이 사용 패턴에 맞춰 최적화됐다. 4세대+7 → +9의 흐름은 "Muon+GB300 체크포인트 라운드트립 전체를 RSS 상한 있는 batched pipeline으로 재정리"로 이해된다.
미공개:
save_concurrent_gb 프로덕션 튜닝 값
load_checkpoint_streamed가 기본 load_checkpoint를 언제 대체하는지
warm_start_staging의 첫 활성 config
admission reset_estimates의 실제 호출 트리거(hot-swap 완료인지, traffic shift 감지인지)
copy_port GB/s 목표치
4세대+8 (2026-08-25) — AES generic_actions 패스스루 배선
2026-08-25 커밋(4세대+8)부터 AES(abuse-enforcement-service)는 YAML에 적힌 정적 규칙뿐 아니라, 스코어러가 직접 요청한 조치도 허용 목록 두 겹을 거쳐 통과시킬 수 있다. 새 스코어러를 붙일 때 YAML 배포 주기가 짧아지지만, 어떤 스코어가 어떤 조치로 매핑되는지 정하는 실제 허용 목록 값은 공개 소스 밖에 있다. 이 커밋은 daily에서 다뤘다.
하드코딩 kind allowlist: user 5종(suspend·label·bounce_captcha·bounce_arkose·spam_liveness_check)과 post 2종(post_label·suspend_author)이 들어 있다. 메시지당 최대 16개까지다.
GrowthBook 동적 allowlist: kinds·suspend_policies·labels 세 축으로 검증한다.
거부된 요청은 버리지 않고, outcome.info["requested_actions_skipped"] JSON과 GENERIC_ACTION_TOTAL{outcome=skipped, reason} 메트릭으로 남겨 명시적인 관측성을 유지한다. 스코어러 팀은 자기 요청이 왜 거부됐는지 바로 확인할 수 있다. 첫 클라이언트로는 embedding 기반 spam 검출기 v10의 panda_reports_embedding_v10_rough_spam 라벨이 임시 PlatformManipulation suspend로 등장했다.
의미: 새 스코어러를 붙일 때 AES YAML 배포 사이클이 짧아지고, GrowthBook 콘솔에서 A/B 게이팅도 할 수 있게 된다. 다만 여기서도 "어떤 스코어가 어떤 조치로 매핑되는가"의 매핑 자체(GrowthBook의 실제 allowlist 값)는 공개 소스 밖에 있다.
2026-08-26 커밋(4세대+9)은 VF가 TES 조회에 실패했을 때 이전 조회의 media feature를 내줄 수 있던 fallback cache를 지우고, canary 워크로드를 dark traffic 미러링에서 뺐다 확인. 지운 cache는 VF 재작성 초기 이후 안정화 기간의 안전망이었다 추정. 이 커밋은 daily에서 다뤘다.
이전에는 TesHydrator(Tweet Entity Service, X 내부 트윗 메타데이터 조회 서비스)가 1M-entry LRU 기반 fallback cache를 들고 있었다. TES 호출이 실패해 media entity가 나오지 않으면 모드에 따라 다르게 동작했다.
FallbackCacheMode::ServeStale이면 이전에 성공한 조회의 stale하지만 유효한 media feature를 반환했다.
HydrationPipeline::new의 시그니처에서는 media_fallback_cache_mode: FallbackCacheMode 파라미터가 사라졌다. TesHydrator struct에는 이제 pub tes_client: Arc<dyn TESClient + Send + Sync> 필드 하나만 남았다.
의미: 이 fallback cache는 VF 재작성 초기(4세대 헤드라인) 이후 안정화 기간의 안전망이었다 추정. TES 인프라 재작성 중에 실패가 생기면 candidate가 media feature 없이 fail-close 필터링에 걸리는데, 이 캐시는 그 파괴적 결과를 완화하는 장치였다. 지금 제거된다는 것은 세 가지를 뜻한다.
VF 재작성이 4세대+7 reference_compare 하니스로 정합성을 감사할 수 있게 됐다.
TES media path의 실패율이 낮아져 fallback cache의 marginal utility가 줄었다.
should_enable(ordinal, max_ordinal)이 should_mirror(workload, ordinal, max_ordinal)로 이름이 바뀌고 확장됐다. 새 규칙은 세 가지다.
workload=None(env WORKLOAD_NAME 미설정)이면 fail-closed로 미러링하지 않는다.
workload.ends_with("-canary")이면 미러링하지 않는다. canary 워크로드는 절대 dark_traffic으로 미러링되지 않는다.
그 외에는 기존처럼 ordinal < max_ordinal로 판단한다.
새 테스트는 canary_never_mirrors(PROD와 CANARY 상수 대조)와 missing_workload_name_fails_closed 두 개다. 로그 메시지도 "disabled (ordinal >= max)"에서 "disabled (not a mirror host)"로 일반화됐다.
의미: dark_traffic은 4세대+4에서 도입되고 4세대+6에서 shadow → staging으로 재정의됐다. 이제 dark_traffic은 canary 워크로드가 실수로 미러링에 들어가 canary 이슈가 shadow verdict를 오염시키는 사고를 막는다. 프로덕션과 canary 두 워크로드가 같은 이름 prefix를 쓰는 관행 추정이라, 접미사 매칭이면 충분하다.
2026-08-28 커밋(4세대+10)은 VF가 안전 라벨을 조회하다 캐시에서 놓친 키를 샘플링해, 비동기로 다시 읽어 캐시를 채워 두는 워머를 붙였다 확인. 규칙 판정은 그대로이고, 이 워머는 라벨 조회의 캐시 hit rate를 끌어올리는 하부 인프라 최적화다. 이 커밋은 daily에서 다뤘다.
visibility-filtering/safety_label_source/에 새 파일 warmer.rs(203줄)가 추가되고 RemoteSource::with_warmer(...)로 endpoint에 붙었다 확인 — visibility-filtering/safety_label_source/warmer.rs (203줄 신규). 함께 바뀐 파일은 다음 다섯이다.
대상은 Strato 컬럼 visibility/baseTweetSafetyLabelMap이다. twemcache가 이 컬럼의 look-aside cache로 앞에 걸려 있고, miss가 나면 Manhattan으로 폴백한다. 이번 공개는 그 miss를 비동기로 재조회해 twemcache에 채워 두는 워머 채널을 추가한다.
워머는 네 단계로 동작한다.
RemoteSource::get(ids)에서 twemcache 결과는 네 종류로 갈린다. 이 가운데 Miss인 경우에만 별도 벡터 warm_ids에 모은다.
Hit
NotFound: 정의적 미존재
Miss: cache가 이 키를 모름
FallThrough(reason): cache 에러/타임아웃 등
정의적 미존재인 NotFound와 폴백 이유가 있는 FallThrough는 워밍 대상이 아니다. 워밍의 목적은 캐시가 채워지지 않은 상태를 해소하는 것이지, 다른 이유의 miss에 대응하는 것이 아니라는 원칙 때문이다.
Manhattan 폴백 요청이 나가기 전에warmer.warm(warm_ids)를 호출한다(fire-and-forget). 요청 응답 지연에는 영향을 주지 않는다.
SampledWarmer가 VF_CACHE_WARM_SAMPLE_PCT 환경변수(0–100, panic on out-of-range)로 확률 샘플링한다. 통과한 ID만 tokio bounded mpsc(용량 1024)에 try_send한다.
백그라운드 drain 태스크가 큐에서 벡터를 꺼낸다. 500ms linger 동안 뒤에 오는 벡터를 합쳐 최대 50 ID까지 배치로 묶는다. 이 배치를 StratoWarmFetcher::fetch(chunk)로 Strato에 batch_call한다(WARM_COLUMN_PATH = "visibility/baseTweetSafetyLabelMap").
Strato의 batch fetch가 결과를 twemcache에 write-back한다. 별도 read-through 캐시 계층이 있는 것으로 추정한다 추정.
채널이 가득 차도 요청 경로는 막히지 않는다.try_send가 실패하면(채널 full) WarmKeyResult::DroppedChannelFull 카운터만 올리고 요청 처리를 계속한다. 워머 채널이 포화돼도 요청 경로에는 절대 backpressure를 걸지 않도록 설계했다. 회귀 테스트 full_warm_channel_does_not_affect_fallback_result가 이를 명시적으로 assert한다.
관측 카운터 record_cache_warm_keys(result, count)는 outcome 6종을 센다.
EligibleMiss: miss로 워머 대상이 된 총량(샘플링 전)
SampledOut: 샘플링에서 탈락한 수
Enqueued: 큐에 성공적으로 들어간 수
DroppedChannelFull: 큐 포화로 버려진 수
FetchIssued: 실제 Strato 호출로 나간 수
FetchFailed: Strato 호출이 에러를 반환한 수
이 6종으로 워머의 각 단계 통과율과 병목 지점을 관측할 수 있다. 예를 들어 FetchFailed / FetchIssued가 높으면 Strato 인프라 이슈이고, DroppedChannelFull이 지속적으로 발생하면 채널 용량(1024)이 부족한 것이다.
같은 config.rs에서 부수 정리 3건도 이뤄졌다.VF_GIZMODUCK_CLIENT_ID·VF_TWEMCACHE_CLIENT_NAME·APP_ENV 3개 env가 새로 생겼다. gizmoduck_client_id()는 설정된 값이 없으면 visibility-filtering-service.{APP_ENV|prod} 템플릿으로 이름을 자동 조립한다. 즉 gizmoduck 저자 메타데이터 조회의 클라이언트 이름이 배포 환경(staging/prod)에 따라 자동으로 갈라진다. VF가 dark_traffic으로 staging에서 돌 때(4세대+4·+6·+7 참조) staging용 gizmoduck 쿼터와 레이트리밋을 프로덕션과 분리하려는 목적으로 추정한다 추정.
의미:
twemcache miss에는 두 경우가 있다. 하나는 트윗이 아직 어떤 라벨링도 받지 않은 신선한 상태다(cache는 물론 라벨도 없음). 다른 하나는 TTL 만료나 evict다. 이 워머는 특히 두 번째 경우, 즉 Manhattan에 라벨은 있는데 캐시가 놓쳐서 매 요청마다 Manhattan을 조회하는 낭비를 해소한다.
sample-pct 게이트가 붙어 있어서 트래픽이 급증해도 워머 자체가 Strato 폭주를 일으키지 않는다. 처음에는 VF_CACHE_WARM_SAMPLE_PCT를 낮게(예: 5–10) 시작해 점진적으로 올리는 dial-up 롤아웃이 가능하다. 4세대의 다른 dial-up 패턴(Grok 4.6 10%→30%→100%)과 비슷한 관행이다.
VF의 "요청 경로 층"이 새로 열렸다는 뜻은 아니다. 규칙 판정(3-tier / 54 규칙)은 그대로이고, 이 변경은 라벨 조회의 캐시 hit rate를 끌어올리는 하부 인프라 최적화다. 다만 VF는 매 요청마다 후보 수십~수백 tweet의 label lookup을 하므로, cache hit rate가 P99 지연에 직접 영향을 준다.
미공개:
VF_CACHE_WARM_SAMPLE_PCT의 초기 프로덕션 값
twemcache miss rate가 워머 도입 전후로 어떻게 바뀌는지
워머 지연(linger 500ms)이 twemcache의 stale window와 어떻게 상호작용하는지
4세대+10 (2026-08-28, 24c6094) — MultiRiskAdsBlender 신설 (BSR-high 광고 tier + medium-risk 폴백 슬롯)
2026-08-28 커밋(4세대+10)은 광고주가 medium-risk 맥락 노출을 허용한 광고에 한해, medium-risk 포스트 사이의 슬롯을 안전 슬롯보다 먼저 열어 주는 네 번째 광고 블렌더를 더했다 확인. 이 블렌더는 아직 기본값이 아니고, param으로 골라야 켜진다. 이 커밋은 daily에서 다뤘다.
슬롯 정책은 광고 tier와 포스트 tier의 인접성 매트릭스로 정해진다.BrandSafetyVerdict::HighRisk(4세대+7, 2026-08-24 신설, 위 절 참조)와 광고 측 AdAdjacencyControl.brand_safety_risk = BsrNormal | BsrHigh, 두 축을 조합해 슬롯 정책을 결정한다.
광고 tier
Safe 옆
MediumRisk 옆
HighRisk 옆
BsrNormal (기본)
배치
안 함
안 함
BsrHigh (광고주 opt-in)
배치
배치
안 함
즉 이전 블렌더들(safe_gap·partition_organic)은 "모든 광고 → Safe 슬롯만"이라는 대칭 정책을 강제했다. 반면 multi_risk는 광고주가 자발적으로 medium-risk 컨텍스트에 노출을 허용한 경우(BsrHigh) medium-medium 슬롯을 열어 준다. HighRisk 포스트는 어떤 광고와도 인접할 수 없다.
spacing_cap = (n − 1) / requested_spacing(기본 3): feed 길이가 광고 간격을 강제하는 상한이다.
safe_count / 2: 각 광고 슬롯이 위·아래 2개의 포스트 사이에 들어가므로 절반이 상한이다.
어느 상한이 binding constraint였는지는 MultiRisk.serving_limitation{factor=ads_supply|spacing|safe_posts} 태그로 명시한다. 이 태그로 광고 미노출 원인을 광고 공급 부족, 간격 부족, 안전 포스트 부족으로 나눠 실시간으로 슬라이스할 수 있다.
BsrHigh 광고는 medium-risk 포스트 사이 자리를 먼저 쓴다. medium-risk 포스트 pair가 남아 있으면 BsrHigh 광고를 그 pair의 위·아래에 먼저 배치하고, 겹침 검사에 걸렸을 때 안전 슬롯 예산이 남아 있으면 안전 슬롯으로 넘긴다 확인 — multi_risk_blender.rs:96-130. 다만 핸들 겹침 검사(handle_drop)와 키워드 겹침 검사(keyword_drop)는 그대로 통과해야 한다. 광고주 핸들이 이웃 포스트에 언급됐거나 광고 키워드가 이웃 포스트 텍스트와 겹치면 버려진다. 즉 BsrHigh의 medium-risk 배치는 "medium-risk 포스트 옆에 놓아도 된다는 광고주 계약"만 해소하고, 인접성 자기간섭(같은 광고주 언급·같은 주제) 방지는 그대로 유지한다.
슬롯 결과는 MultiRisk.slot_outcome{outcome=filled|unfilled_bsr_low|unfilled_handle|unfilled_keyword|unfilled_no_ads} 카운터로 세분해 센다.
filled: 슬롯에 광고 배치 성공
unfilled_bsr_low: 후보 광고가 BSR-low 신호로 버려졌고, 그 slot이 결국 비었다
unfilled_handle·unfilled_keyword: 위 자기간섭 검사에서 버려짐
unfilled_no_ads: 광고 공급 자체가 소진됨
SlotRejections::stuck_outcome()은 세 rejection reason 가운데 가장 많이 발생한 것을 slot의 대표 원인으로 뽑는다. 즉 A/B 대시보드에서 "slot 하나가 비었을 때 가장 자주 걸린 안전 필터"를 관측할 수 있다.
enforcement 결과는 MultiRisk.enforcement{action=drop|ok|handle_drop|keyword_drop} 카운터로 집계한다.
결정적으로 MultiRiskAdsBlender는 아직 default가 아니다. AdsBlenderType param 문자열이 "multi_risk"여야 켜진다. 즉 experiment 게이트 뒤에 있어 실제 노출 비율은 공개되지 않았다. 다음 관측 포인트는 AdsBlenderType의 기본값이 언제 multi_risk로 승격되는지다.
광고주 관점에서는 BSR-high opt-in의 인센티브 구조가 코드에 명시적으로 들어왔다는 신호다. medium-risk 컨텍스트를 감수하면 medium-risk 슬롯을 얻어 노출 총량이 늘어난다. 이전에는 코드상 그런 슬롯 자체가 없었다.
미공개:
AdsBlenderType의 experiment 롤아웃 비율
BsrHigh 광고 세그먼트의 실제 비중(광고주 opt-in 채택률)
medium-medium 배치가 revenue/브랜드 안전 metric에서 어느 정도 uplift를 만드는지
AdAdjacencyControl의 다른 필드(handles·keywords) 배포 관행
4세대+10 (2026-08-28, 24c6094) — trainer가 streamed restore를 실제로 소비 + gRPC 트레일러 보존 버그픽스 + copy_port shard-schedule 셔플
Phoenix 학습기(trainer)가 설정 플래그에 따라, 체크포인트를 텐서 하나씩 장치로 옮기며 불러오는 streamed restore를 실제로 호출하게 됐다 확인. 이 커밋은 또 서빙 응답을 압축할 때 gRPC 상태 트레일러가 사라지던 버그를 고치고, pod마다 텐서 shard를 받는 순서를 섞어 첫 shard를 가진 peer에 요청이 몰리지 않게 했다 확인 — 이 커밋을 다룬 daily.
세 갈래 하드닝이 한 커밋에서 나왔다. 4세대+9가 깔아 둔 스캐폴딩(저장 대칭, load_checkpoint_streamed API, copy_port GB/s 로깅)이 이 커밋에서 세 가지로 성숙했다.
maybe_load_checkpoint가 이제 checkpoint_config.restore_streamed 플래그를 읽어 load_checkpoint_streamed를 실제로 호출한다. streamed 경로가 켜지는 조건은 세 가지이고, 모두 참이어야 한다.
restore_streamed=True
ctx.checkpoint.format == "orbax"
save_method != "tensorstore"
하나라도 맞지 않으면 로그를 남기고 기존 load_checkpoint(전량 pinned-host 스테이지)로 폴백한다. use_streamed_restore=True 경로는 그 자체로 checkpointing_load.copy_aliased_arrays(self.state)를 호출해 aliased state leaves를 미리 분리한다. 새 함수 _graft_replaced(pairs)는 불러온 tensor를 원래 state tree의 자리에 in-place로 그래프트한다. aliased leaf가 두 자리에 있으면 어느 슬롯에 넣을지 애매해지므로, 이 경우는 assertion으로 일찍 막는다(multiple loaded tensors share one device array).
load_checkpoint_streamed가 받는 새 인자 두 개도 이 커밋에서 라우팅됐다.
window_gb: 해당 배치의 pinned-host 예산이다. None이면 auto-window가 된다.
on_replaced 콜백: 완료된 배치의 old/new device array pair 리스트를 받는다. 호출자는 이 리스트로 state tree에 grafting한다.
window_cap_gb는 window_gb가 명시되지 않았을 때만 restore_concurrent_gb로 정해지는 안전벨트다.
이 흐름의 핵심은 pinned-host에 optimizer state 사본을 만들지 않고, tensor를 하나씩 device로 옮긴 뒤 원 참조를 그래프트하는 것이다. 이득은 warm_start 케이스(pretrained params를 불러오고 opt state는 fresh로 시작)에서 특히 크다. 기존 do_not_load_opt_state=True 경로는 warm_purge(self.state)로 opt state 필드를 미리 잘라낸다. streamed 경로에서는 host_state를 None으로 두고 불러온 params만 grafting하므로, opt state의 pinned-host 사본이 아예 생기지 않는다. checkpoint stack은 이제 4세대+6·+7의 Muon·GB300 flagship이 pretrained params와 fresh optimizer로 시작하는 표준 흐름을 원자적으로 지원하게 됐다.
GrpcCompressionService는 응답 본문을 zstd로 압축할 때, 이전에는 collected.to_bytes()로 body만 뽑아 새 axum::body::Body::from(data)로 다시 감쌌다. 이 과정에서 gRPC trailers(특히 grpc-status·grpc-message)가 전부 유실됐다. gRPC 프레임은 body 뒤에 trailer로 상태 코드를 실어 보내는데, 그 부분이 버려진 것이다. 클라이언트는 응답을 정상으로 받으면서도 status trailer가 없어 UNKNOWN/EOF로 처리했을 가능성이 있다.
새 코드는 collected.trailers().cloned()로 trailer를 먼저 뽑아 둔다. 그다음 rebuild_response(parts, data, trailers) 헬퍼가 Full::new(data).with_trailers(async move { Some(Ok(tr)) })로 트레일러를 다시 붙여 응답 본문을 만든다. 압축이 성공해도 실패해도 트레일러가 유지되도록 두 경로를 통일했다. 회귀 테스트 두 개(compressed_response_forwards_trailers, non_compressed_response_forwards_trailers)가 이 동작을 명시한다.
같은 파일에는 새 히스토그램 recsys_engine_response_compress_ms{method}도 추가됐다. tokio::task::spawn_blocking(compress_grpc_frame)에 들어가기 직전에 Instant::now()를 찍고, 끝난 뒤 elapsed().as_secs_f64() * 1000.0으로 관측한다. 이렇게 재면 zstd 인코딩의 큐잉과 CPU 시간을 method별로 나눠 볼 수 있다. buckets는 request latency 버킷을 함께 쓴다(crate::request_metrics::latency_buckets_ms()).
의미: trailer 유실은 서빙 계약을 조용히 깨는 심각한 버그다. 클라이언트가 응답 상태를 파악하지 못하면 재시도 로직, 에러 로깅, 서킷브레이커가 모두 오작동한다. 이 커밋은 회귀 방지 테스트까지 붙여 같은 버그가 다시 생길 확률을 크게 낮췄다. _compress_ms 히스토그램으로는 GB300에서 큰 응답의 zstd 인코딩이 응답 지연에 얼마나 기여하는지 나눠 볼 수 있어, 압축 레벨을 튜닝할 근거가 된다.
shuffle_sharded_schedule(n, tensor_name)이 새로 생겼다. 이전에는 pod들이 한 텐서의 여러 shard를 병렬로 받을 때 모두 index 0부터 차례로 요청했다. 그래서 첫 shard의 owner peer가 순간적으로 과부하에 걸렸다.
새 로직은 POD_NAME(또는 HOSTNAME)과 tensor name을 hash로 섞는다. 이 hash로 초기화한 StdRng::seed_from_u64(hash)로 shuffle해서, 각 pod가 다른 순서로 shard를 요청하게 한다. seed가 결정론적이어서 같은 pod는 restart돼도 같은 순서를 유지한다(로그 재현 가능성). 섞인 순서로 받은 결과는 restore_piece_order(shuffled, schedule) -> Option<Vec<T>> 헬퍼가 원래 순서로 되돌린다. emb_table.rs와 copy_port_client.rs 두 소비 지점은 모두 이 새 헬퍼를 쓰도록 통일했다. 셔플은 sharded ownership에만 적용하고, replicated는 in-order를 유지한다. replicated에서는 모든 pod가 같은 데이터를 받으므로 셔플 이득이 없다.
로그에는 첫 8개 index만 남긴다(copy_port: shard schedule shuffled name={tensor} n={total} first={:?}). 이 로그로 셔플이 실제로 걸렸는지 진단할 수 있다. TRANSFER_FAILED_SENTINEL(u32 max로 추정한다 추정)을 만나면 restore 전에 에러로 일찍 반환한다.
의미: 많은 pod가 대형 임베딩 테이블과 dense weight를 restore하면 shard 0을 맡은 peer에 traffic spike가 몰린다. 이 문제는 4세대+7의 노드-serialize와 concurrent_gb로 완화됐지만, peer 쪽 스파이크는 여전히 남아 있었다. shuffle은 그 마지막 병목을 pod 수에 반비례하게 나눈다. 결정론적 셔플이 중요한 이유는 두 가지다.
셔플 자체를 로그로 재현할 수 있다.
같은 pod가 같은 tensor를 여러 번 로드해도 순서가 같아서, 캐시 hit rate 관측이 안정된다.
미공개:
restore_streamed의 첫 활성 config(현재 기본 false로 추정 추정, config에 추가된 라인 1줄만 확인)
warm_start_staging에서 realistic한 keep_emb_opt_state=True 소비자
gRPC trailer 유실 버그가 프로덕션에서 관측된 증거(고객 티켓? 재시도 폭주?)
recsys_engine_response_compress_ms의 P99 목표치
copy_port 셔플 도입 전후 shard 0 peer의 request rate 분포 변화
4세대+11 (2026-08-28, bc8e5f0) — visibility-filtering 규칙 계층 리팩터 + 90건 골든 코퍼스 (정책 변화 없음)
VF 규칙 파일 24개가 판정 로직은 그대로 둔 채 도메인 메서드를 쓰도록 얇아졌고, 판정을 고정하는 90건짜리 회귀 코퍼스가 새로 들어왔다 확인. 정책은 바뀌지 않았고, 이제 정책에 규칙을 추가하면서 코퍼스 케이스를 넣지 않으면 CI가 깨진다 확인 — 이 커밋을 다룬 daily.
WORLDWIDE_COUNTRY_CODE = "xx" 상수와 takedown reason 추출 함수도 tes_rules.rs에서 이곳으로 옮겨졌다. 확인
90건 코퍼스는 카테고리 13개로 나뉜다. 카테고리는 filter_all·baseline·author_state·relationship·tweet_label·tweet_shape·age_gating·exclusive_content·interstitial·oon_media·oon_tweet_label·oon_user_label·interaction이다. 각 케이스는 Case { name, level, viewer, candidate, expected_action, expected_decided_by } 형식이고, 어설션 세 가지가 걸린다. 확인
golden_corpus_pins_policy_verdicts: 모든 케이스가 여전히 같은 액션과 같은 decided_by를 내는지 확인한다.
every_wired_rule_decides_a_corpus_case: Policies에 연결된 rule 이름 집합에서 코퍼스가 실제로 결정자로 쓴 이름 집합을 빼면 공집합이어야 한다. 정책에 rule을 추가하고 코퍼스에 케이스를 넣지 않으면 CI가 깨진다.
이 어설션을 위해 registry.rs에 새 헬퍼 wired_rule_names(level)가 #[cfg(test)]로 추가됐다.
corpus_case_names_are_unique: 케이스 이름이 겹치면 안 된다.
개별 rule 파일은 로직을 유지한 채 표현만 얇아졌다. 예를 들어 tes_rules.rs의 DropStaleTweetsRule에서는 이전의 조기 반환 세 개(리트윗 검사, source_tweet_id.is_some() 검사, is_stale 검사)가 두 줄로 줄었다. 확인 — visibility-filtering/rules/tes_rules.rs
if context.is_stale_tweet() && !context.is_retweet() {
return VfAction::Drop(FilteredReason::UnspecifiedReason);
}
tweet_flag_rules.rs와 user_rules.rs처럼 fn(&TweetFeatures) -> bool 콜백을 받던 곳은 시그니처가 fn(&RuleContext<'_>) -> bool로 바뀌었다. 콜백 본문도 |t| t.nsfw.user → |context| context.has_tweet_nsfw_user_flag()처럼 옮겨졌다. registry.rs의 −298 가운데 절반은 개별 rule 테스트를 코퍼스로 옮긴 것이고, 나머지 절반은 Viewer::LoggedIn(999) 같은 반복 인라인 셋업을 지운 것으로 파악된다. 추정
의미: 정책 자체는 변화 없다. 이 커밋은 4세대 초기 재작성 이후 VF가 "안전망을 걷어내면서(4세대+7 reference_compare 하니스, 4세대+9 media fallback cache 제거) 대신 회귀 방어 자산을 안으로 축적"하는 흐름의 다음 단계로 읽힌다. RuleContext가 도메인 언어(예: legal_takedown_in_viewer_country)로 풍부해지면서, 앞으로 rule을 추가하거나 고칠 때 판정 근거가 파일 이름과 나란히 읽힌다. 또 규칙 연결과 어설션을 묶은 장치(every_wired_rule_decides_a_corpus_case)는 규칙 라이브러리가 커져도 "테스트 없이 배선만" 하는 일을 CI 수준에서 원천 봉쇄한다.
미공개: 리팩터 도중 어떤 규칙 파일의 시맨틱이 실제로 미묘하게 바뀌었을 가능성은 남아 있다. 이 커밋만으로는 골든 코퍼스 90건이 이전 프로덕션 판정과 정확히 일치하는지 알 수 없다. 코퍼스가 이 커밋에서 처음 도입됐으므로, 이전 버전과의 대조는 손으로 해야 한다. 4세대+7 reference_compare 하니스가 이 커밋 뒤에도 계속 돌고 있다면, 실제 트래픽으로 검증되고 있는 셈이다.
같은 커밋(bc8e5f0)의 서빙 쪽에서는 리랭커 헤드를 고르는 값이 Phoenix 요청에 실리기 시작했지만, 그 값을 쓰는 서빙 로직은 이 커밋에 없다 확인. 임베딩 테이블을 gRPC로 받을 때의 대용량 복사는 런타임 스레드를 붙잡지 않도록 blocking pool로 옮겨졌다 확인 — 이 커밋을 다룬 daily.
변경은 두 갈래다.
리랭커 헤드를 고르는 파라미터가 home-mixer → proto 2곳 → Phoenix 요청 경로까지 전 구간 연결됐다. 서빙 쪽 소비 로직은 이 커밋에 없다.
emb_table의 gRPC 수신 콜백에서 non-temporal 복사와 Adler-32 계산을 spawn_blocking으로 분리했다. 이제 tokio 런타임 스레드가 대용량 memcpy에 붙잡히지 않는다.
PostCandidate에 pub reranker_head_tag: Option<u32> 필드가 추가됐고, PhoenixScorer는 요청할 때 이 파라미터 값을 담아 보낸다.
reranker_head_tag: Some(query.params.get(RerankerHeadTag) as u32),
스코어링 결과로 돌아온 reranker_head_tag는 candidate.reranker_head_tag = scored.reranker_head_tag로 다시 후보에 붙는다(라운드트립). phoenix_request.rs도 scoring_sequence.reranker_head_tag에 파라미터 값을 채워 요청에 싣는다.
의미: 추정 기본값이 0이고 서버 쪽 소비 로직 변경이 이 커밋에는 없으므로, 헤드 라우팅을 켜기 전의 연결 단계다. head_tag라는 이름과, UserActionSequence(피드 스코어러 입력)에도 함께 붙는 배치는 "한 시퀀스에 대해 어떤 리랭커 헤드를 쓸지 지정"하는 용도로 읽힌다. 모델 안의 리랭커 헤드 여러 개 가운데 하나를 실험 기간 동안 A/B로 고르는 스위치라는 해석이다. 여러 랭커 헤드를 함께 학습해 두고 서빙할 때 태그로 라우팅하는 패턴은 A/B 성숙기에 흔히 나타난다.
기존에는 send_entries 안에서 gRPC proto 콜백이 목적지 슬라이스에 직접 non-temporal 복사를 하고 Adler-32를 갱신했다. 새 코드는 데이터를 4 MiB 청크로 나눠 sync_channel(2)에 밀어 넣는다. 복사는 tokio::task::spawn_blocking으로 따로 실행되는 소비자가 맡는다.
const GRPC_COPY_PIECE: usize = 4 << 20;
let dest_ptr = buf.as_mut_ptr() as usize;
let dest_len = buf.len();
let (tx, rx) = std::sync::mpsc::sync_channel::<bytes::Bytes>(2);
let copy_join = JoinOnDrop::new(tokio::task::spawn_blocking(move || {
let dest = unsafe { std::slice::from_raw_parts_mut(dest_ptr as *mut u8, dest_len) };
let mut pos = 0;
let mut checksum = 1u32;
while let Ok(chunk) = rx.recv() {
install_grpc_chunk(dest, &mut pos, &mut checksum, &chunk);
}
(pos, checksum)
}));
콜백은 bytes::Bytes::copy_from_slice로 감싼 청크를 채널로 보내기만 한다. non-temporal 복사와 Adler-32 계산은 blocking pool 스레드에서 실행된다. RDMA 경로에서도 최종 Adler-32 합산까지 spawn_blocking으로 옮겼다.
새 헬퍼 JoinOnDrop<T>가 안전장치로 붙었다. 정상 경로에서는 join().await으로 결과를 회수한다. drop될 때는 block_in_place와 block_on(handle)으로 blocking 태스크를 반드시 종료한다. 확인목적은 파일 디스크립터·핸들 누수 방지로 추정된다추정.
의미: 추정 gRPC 콜백은 Tokio 런타임 스레드에서 돈다. 그래서 non-temporal 복사와 Adler-32 계산이 런타임 스레드를 오래 붙잡으면 다른 태스크(요청 라우팅·admission 결정·헬스체크 등)가 지연된다. 이번 변경으로 gRPC 수신 스레드는 memcpy 없이 청크를 넘기기만 하고, CPU-바운드 복사는 blocking pool이 맡는다. 4 MiB 청크 크기와 sync_channel 용량 2는 백프레셔로 수신 속도를 조절한다. 이 변경은 4세대+9의 emb_table core 분리(load_tensor_into 시그니처, pyo3-free), 4세대+10의 copy_port shuffle과 같은 흐름에 있다. 대용량 임베딩 테이블 서빙 인프라가 여러 곳에서 tokio 런타임과 CPU-바운드 작업의 경계를 다듬고 있다.
부수 변경: copy_port_client.rs(+16/−15)에서 join_all(...spawn) → collect → map_err 관용구를 새 join_transfers 헬퍼로 뽑았다. 중복만 없앴고 동작은 바뀌지 않았다. 테스트 이름 두 개도 바뀌었다. ..._despite_spawn은 없어지고, ..._between_spawned_batches는 ..._between_batches가 됐다. 이는 어제 커밋에서 결정성 셔플을 도입하며 붙었던 문구를 정리한 것으로 파악된다. 확인
proto_parser.rs(+11)에는 Bytes 콜백 타입이 추가됐다. 기존 Str 콜백(FnMut(usize, &[u8])) 옆에 Bytes 콜백이 생겨, 청크를 소유권 이동으로 넘길 수 있게 됐다. Bytes 콜백의 시그니처는 FnMut(usize, Bytes)다. 이 커밋에서 emb_table은 여전히 Str 시그니처를 쓰므로, 진짜 zero-copy는 나중 커밋에서 지켜볼 지점이다. 확인
미공개:
서빙 쪽 Phoenix가 RerankerHeadTag를 실제로 어떻게 소비하는지(이 커밋에는 소비 로직 없음)
UserActionSequence.rerankerHeadTag와 ScoreInfo.rerankerHeadTag가 요청·응답 대칭인지(요청은 어떤 헤드로 스코어링할지, 응답은 어떤 헤드가 실제로 사용됐는지), 아니면 다른 목적인지
emb_table이 Bytes 콜백을 아직 사용하지 않는 이유(다른 소비자를 위한 사전 준비 vs 다음 커밋에서 emb_table을 전환할 계획)
gRPC 콜백을 blocking pool로 옮긴 뒤 tokio 런타임의 tail latency가 얼마나 개선됐는지
4세대+12 (2026-09-01, 6384ca7) — NSFW 게이팅 대상 16개국 공개 + Rust↔Scala VF drift 감시 + motion-reveal 기반 특수 영상 안전 파이프라인
나이를 밝히지 않은 뷰어에게서 NSFW 미디어를 거르는 대상 16개국이 VF 코드의 기본값으로 처음 명시됐고, Rust와 Scala 두 VF 스택의 목록이 어긋나는지 감시하는 장치가 붙었다 확인. 또 fav 수가 문턱 이상인 고인기 영상만 따로 받아, 정적 오버레이 아래 가려진 콘텐츠를 드러내는 motion-reveal 프레임을 원본과 함께 Grok에게 보여 판정하는 안전 파이프라인이 생겼다 확인 — 이 커밋을 다룬 daily.
이 커밋은 안전 스택에 두 축을 더한다. 하나는 age-gating 정책이 코드에 처음 명시된 것이고, 다른 하나는 능동적 회피 대응 층이 새로 연결된 것이다.
NSFW 게이팅 대상 16개국이 기본값으로 코드에 명시됐다확인 — visibility-filtering/params.rs (216줄 신규). default_nsfw_gating_countries()는 다음 목록을 반환한다.
나라 이름으로 옮기면 아르헨티나·호주·브라질·캐나다·독일·스페인·프랑스·영국·인도네시아·이탈리아·한국·멕시코·네덜란드·필리핀·포르투갈·태국이다. 이 목록은 feature switch rust_vf_nsfw_gating_countries로 런타임에 override할 수 있다. 값은 ArcSwap으로 감싸 30초마다 다시 조회한다.
Rust VF와 Scala VF의 목록이 어긋나는지(drift) 감시한다. 같은 파일의 refresh_and_check_drift는 refresh할 때마다 sibling YAML country_specific_nsfw_content_gating.yml(Scala VF 스택이 참조)을 함께 읽는다. Rust 결과와 Scala 목록이 어긋나면 tracing::warn!을 남기고 xai_stats_receiver의 nsfw_gating_countries_drift 카운터를 올린다. 두 스택이 병렬로 도는 상태이고, drift를 관측할 수 있게 설계됐다.
잘못된 값이 섞이면 목록 전체를 기본값으로 되돌린다(fail-whole fallback). feature switch 값 안에 잘못된 원소(예: 숫자)가 섞이면, 부분 리스트를 쓰지 않고 기본 16개국 전체로 폴백한다. 회귀 테스트 malformed_value_falls_back_whole_not_partial가 이 계약을 잠근다.
fav count가 grox_config.media_hydration.deluxe_fav_count_threshold 이상이다.
즉 바이럴이 확정된 고팬 영상에만 진입한다. 스크리닝·판정·LLM cross-validation 전 과정이 후보당 비싸기 때문에, 저팬 영상까지 넓히지 않는다.
special_video_screen은 motion-reveal 프레임이 있는지로 영상을 거른다확인 — grox/flows/ptos/task_special_video_screen.py. _technique_signals는 dict를 반환하고, 현재 검출 기법은 motion_reveal 한 종류다. 이 필드가 비어 있지 않은 영상만 다음 태스크로 넘어간다.
프롬프트가 motion-reveal 프레임의 뜻과 판정 기준(rubric)을 정해 둔다확인 — grox/core/lm/convo.pyMOTION_REVEAL_DESCRIPTION. motion-reveal 프레임은 원본 영상에서 정적 층을 빼고 미약한 움직임 층을 증폭한 프레임이다. 이 처리를 거치면 정적 오버레이(캡션 이미지·투명 마스크·워터마크 등)에 가려져 있던 실제 콘텐츠가 드러난다. Grok LLM은 원본과 motion-reveal 두 프레임 셋을 함께 받고, 프롬프트에는 caveat 두 가지가 명시돼 있다.
motion-reveal은 왜곡을 만든다. "불투명 옷이 맨살처럼 보이게" 할 수 있다. 재현율은 높지만 정확률이 낮을 수 있어서, 무조건 hard 판정을 내리지 않고 downstream 판정과 cross-validation으로 보내는 진입 신호로만 쓴다.
원본과 motion-reveal 프레임은 판정에서 따로 다뤄야 한다. 프롬프트에는 "원본 프레임에도 이미 보이는 사람은 원본만 근거로 판정, motion-reveal 프레임에만 새로 보이는 것이 진짜 숨겨진 콘텐츠다"라는 rubric이 명시돼 있다.
motion-reveal 프레임을 만드는 이미지 처리 함수가 추가됐다확인 — grox/libs/video_tools/image.py (+125). CLAHE(contrast limited adaptive histogram equalization), adaptive brightness, saturation boost, sharpen 등이 들어갔다. X 안전 파이프라인이 영상 프레임을 능동적으로 변형해, 회피된 콘텐츠를 드러내는 전처리를 프로덕션에 넣은 것이다.
special video 경로만 fail-close로 동작한다확인 — grox/flows/ptos/task_safety_ptos_adult_content_cross_validation.py:106-117, 130-148. Grok 심판 호출이 예외로 실패하면, task_type == SAFETY_PTOS_SPECIAL_VIDEO에서는 원 판정을 유지하지 않고 Soft로 강제 강등한다(Grok 4.5 Cross Validation failed). 다른 경로는 원 판정을 유지하는 것이 기본이다. 특수 영상 경로의 Soft 강제는 원 판정과 상관없이 결과를 Soft 한 단계로 맞춘다. 원 판정이 Hard였다면 완화이고, AdultContent 위반이 없었다면 Soft 위반이 새로 붙는다 확인 — 같은 파일 130-148행.
정책 면에서 이 커밋이 뜻하는 바는 네 가지다.
X가 어느 나라에서 age-gating을 강제하는지 코드에 처음 명시했다. 이 목록은 원래 Scala VF에도 있었다. 이번에 Rust VF에 기본값으로 들어오고, 두 목록이 어긋나면 metric으로 관측할 수 있게 됐다. 이 두 변화에서 Rust 이관을 안전하게 완료하려는 흐름이 보인다. 미국은 목록에 없다.
Safety 스택이 능동적 회피 대응 층을 갖게 됐다. 이전에는 "모델이 라벨을 붙이면 → 규칙이 판정"하는 수동 스택이었다. 이제 그 앞에 "회피 기법을 검출 → 능동적 회피 제거 → 그 프레임으로 재판정"이라는 층이 추가됐다.
회피 대응은 고팬 영상에 국한된다.
비싼 회피 대응을 확산이 확정된 콘텐츠에만 걸어 비용을 관리한다.
확산 규모 때문에 회피 인센티브가 크므로 회피 검출을 강화한다.
저팬 상태에서 회피 검출이 걸리지 않는 것은 이 아키텍처의 열린 약점이다. 소급 처리가 연결돼 있는지는 확인되지 않았다.
_technique_signals dict가 새 기법 추가를 예상한 형태다. motion-reveal 다음으로 어떤 기법(캡션 오버레이·splash frame·chroma key 등)이 추가될지가 지켜볼 지점이다 추정.
미공개:
deluxe_fav_count_threshold의 값
motion-reveal 프레임 생성의 정확한 알고리즘(어떤 것이 "정적 층"이고 어떤 것이 "미약한 움직임 층"인지의 분리 기준)
다른 회피 기법의 후보와 도입 시기
저팬 상태의 소급 처리 연결 유무
4세대+13 (2026-09-01, 7ba7768) — visibility-filtering rules 재구조화 (struct-per-rule → data-driven RuleSpec, 정책 변화 없음)
VF 규칙을 규칙마다 구조체로 쓰던 방식에서 데이터 리터럴 목록으로 바꾼 리팩터다 확인. 규칙 목록과 순서는 그대로이고 이번 커밋만으로는 안전 판정이 바뀌지 않지만, 같은 커밋에서 home-mixer의 광고 블렌더 기본 유형이 multi_risk로 바뀌었다 확인 — 이 커밋을 다룬 daily.
같은 날 두 번째 커밋(20:47 UTC)에서 VF의 rules/ 하위가 9개 파일에서 3개 파일과 공용 enum으로 재편됐다. 2,670줄을 지우고 1,568줄을 더했지만 규칙 목록·순서는 그대로이고, 회귀 방지 테스트가 이를 리터럴로 잠근다. 4세대+11 (bc8e5f0)의 RuleContext 정비와 90건 golden corpus 도입에 이은 다음 단계다.
nsfw_interstitial.rs가 label-triggered interstitial과 author-flag-triggered interstitial로 쪼개졌다. 앞의 것은 홈 base 규칙에 들어가고, 뒤의 것은 별도 상수로 나갔다.
tes_rules.rs가 홈 공통(stale과 takedown)과 recs 전용(DMCA와 geo-restricted media)으로 쪼개졌다. 즉 recommendations 전용 규칙 축이 코드에서 명시적으로 드러났다.
SensitiveViewerNoStatedAgeDropRule은 이름과 조건을 그대로 두고 상태 저장 방식을 바꿨다확인. 4세대+12(6384ca7)에서 nsfw_age_gating.rs에 struct와 Arc<NsfwGatingCountries> 필드로 있던 규칙이 Custom 스펙으로 옮겨졌다.
예전에는 규칙 자체가 Arc<NsfwGatingCountries>를 필드로 들고 있었다. 이제는 RuleContext가 참조를 갖고, 규칙은 context.nsfw_gating_country(code)로 조회한다. 규칙은 상태를 갖지 않는 zero-sized data literal이 됐다.
규칙 이름 세 개(SensitiveViewerLoggedOutDropRule·SensitiveViewerUnderageDropRule·SensitiveViewerNoStatedAgeDropRule)는 그대로 유지된다. 감사·메트릭·회귀 테스트가 이 이름을 리터럴로 참조하기 때문이다.
새 테스트 wired_rule_order_matches_pre_migration_sequence는 TimelineHome 28개, TimelineHomeRecommendations 추가 26개, FilterAll 1개의 규칙 이름을 순서까지 리터럴로 검증한다. 홈 타임라인의 규칙 순서는 다음과 같다(참조용).
Recommendations 정책은 홈 목록 뒤에 26개를 이어 붙인다. 이어 붙는 순서는 RECS_MEDIA_DROPS → OON_NSFW_AUTHOR_DROPS → OON_TWEET_FLAG_DROPS → OON_TWEET_LABEL_DROPS → OON_USER_LABEL_DROPS다. Policies struct에는 nsfw_gating_countries: Arc<NsfwGatingCountries> 필드가 추가됐다. evaluate할 때 RuleContext::new(..., &self.nsfw_gating_countries)로 이 참조를 넘긴다.
의미
정책은 그대로다. 이번 커밋만으로는 안전 판정이 바뀌지 않는다. 4세대+11의 90건 golden corpus, every_wired_rule_decides_a_corpus_case 어설션과 짝을 이뤄 "규칙 라이브러리가 커지면서도 배선-테스트-순서를 CI로 잠그는" 방향의 다음 단계다.
규칙 목록을 파악하고 감사하기가 크게 쉬워진다. 예전에는 "이 저장소에 어떤 NSFW/드랍 규칙이 있는가"를 확인하려면 파일 9개를 훑어야 했다. 이제는 tweet_rules.rs 하나만 열면 상수 그룹별로 모든 규칙이 한눈에 보인다. 이 형태는 규제 대응이나 투명성 리포트(Under the Hood)에서 원문을 인용하기에도 알맞다.
recs 전용 축이 코드에 명시됐다.RECS_MEDIA_DROPS(DMCA/geo)가 별도 상수로 나왔다는 것은, 앞으로 recs 전용 규칙을 추가할 때 홈 base와 섞이지 않고 이 상수에만 붙는다는 뜻이다.
부수 변경: home-mixer/params/param.rs에서 AdsBlenderType 기본값이 partition_organic_low_risk에서 multi_risk로 바뀌었다. VF 리팩터와 직접 관련은 없다. 확인
미공개: 이 리팩터 도중 규칙의 시맨틱이 미묘하게 바뀌었을 가능성은 남아 있다. wired_rule_order_matches_pre_migration_sequence는 이름과 순서만 검증하고, 각 규칙의 조건 predicate 자체는 4세대+11의 golden corpus(90건)에 의존한다. 프로덕션 트래픽 규모의 정합성은 4세대+7 reference_compare 하니스가 계속 검증한다.
4세대+14 (2026-09-02, 85ac72a) — home-mixer가 Phoenix backbone scores 요청 + scarecrow → XReview 인테이크 신설 + VF rules Box<dyn> 소거 + 체크포인트 in-envelope 파생 마스터 키
Phoenix 랭커가 공용 backbone과 reranker head의 두 층으로 이뤄져 있다는 점이 이 커밋에서 코드로 처음 확정됐다. 같은 커밋에서 home-mixer는 피드 엔드포인트 다섯 곳 모두에서 backbone 점수를 요청하기 시작했고, scarecrow의 자동 탐지 봇이 유저 신고와 같은 XReview 큐에 리포트를 넣는 경로도 생겼다 확인(daily).
커밋 하나에 다음 변화가 함께 들어 있다.
Phoenix 랭커의 backbone과 head 두 층 구조가 응답 wire에 드러났다.
scarecrow의 자동 탐지 봇이 유저 리포트인 척 XReview에 프로그램으로 신고를 넣는 gRPC 파이프라인이 새로 생겼다.
4세대+11에서 시작해 4세대+13에서 데이터 리터럴로 바뀐 VF 규칙 리팩터가 phase 2로 넘어가, 정책이 런타임 힙이 아니라 rodata에 놓이게 됐다.
4세대+13이 시작한 checkpoint 암호화가 in-envelope 파생 마스터 키를 지원하게 됐고, encryption_context 스키마가 강제됐다.
Phoenix retrieval의 dwell 기본값도 short에서 long으로 함께 바뀌었다.
A. Phoenix backbone scores 응답이 5개 피드 엔드포인트에서 강제 요청됨
home-mixer는 피드 엔드포인트 다섯 곳 모두에서 Phoenix에 backbone 점수를 요청한다확인 — home-mixer/server.rs. 각 엔드포인트는 요청을 Phoenix로 넘기기 직전에 query.return_backbone_scores = true를 하드코딩으로 세팅한다. 파라미터 게이팅은 없다. 다섯 gRPC 엔드포인트는 ScoredPostsService, ForYouFeedService, ForYouFeed의 폴링 변형, RankedFollowingFeedService, FollowingFeedService다.
Phoenix proto의 요청과 응답 양쪽에 backbone 필드가 새로 생겼다확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto. 요청 필드는 returnBackboneScores(#20)다. 응답 필드는 backboneTopLogProbs(#10, repeated float)와 backboneContinuousValues(#11, repeated float)다. 이름 규약으로 보면 Top은 discrete-head의 상위 로그확률에, Continuous는 dense 회귀 출력에 대응한다.
PostCandidate에 backbone 점수 필드가 추가됐고, scorer는 응답을 두 층으로 파싱한다확인 — home-mixer/models/candidate.rs, home-mixer/scorers/phoenix_scorer.rs. 새 필드는 backbone_scores: Option<PhoenixScores>다. predictions.candidate_scores()는 기존 reranker head의 출력이고, predictions.candidate_backbone_scores()는 새로 노출된 backbone 층의 출력이다. candidate는 한 예측 라운드에서 두 층의 점수를 모두 들고 다음 단계로 나간다.
의미 Phoenix 랭커가 공용 backbone과 reranker head의 두 층 구조라는 사실이 코드로 처음 확정됐다. canon 이전 판이 관찰한 것은 두 가지였다. 4세대에서는 "24개 문서 참여 확률 트랜스포머"라는 최상위 시맨틱을 알았고, 4세대+11(bc8e5f0)의 rerankerHeadTag 필드로 "여러 재순위 헤드"까지 관찰했다. 이번 커밋은 그 헤드들 아래에 backbone 층이 있고, 그 출력이 별도 응답 필드로 노출된다는 것을 확정한다. 다만 backbone_scores는 candidate에 저장되는 데서 그치고, 이번 diff에는 downstream 소비처가 보이지 않는다. 그래서 MultiRiskAdsBlender, VMRanker, DPP re-rank, attribution 로거 가운데 어느 것이 backbone 로짓을 소비하는지가 앞으로 지켜볼 지점이다.
이 밖에 두 가지가 함께 바뀌었다.
Phoenix retrieval 집계 방식의 기본값이 짧은 dwell에서 긴 dwell로 바뀌었다 확인. home-mixer/params/param.rs에서 PhoenixRetrievalAggregationType default가 DENSE_WITH_SHORT_DWELL → DENSE_WITH_LONG_DWELL로 바뀐 것이다.
파일 헤더 sync 시각이 2026-09-01T16:42:25Z → 2026-09-02T16:03:45Z로 갱신된 미러 커밋이므로, 이 값은 프로덕션 feature-switch가 하루 사이에 실제로 바뀐 것을 소스가 따라잡은 것이다.
Phoenix 서빙의 배치 준비 코드(phoenix/crates/serving/xai-recsys-engine/src/python.rs)에 버퍼 방어가 들어갔다. candidate embedding과 search-query embedding 슬라이스에 .fill(0) 방어와 copy_len > 0 조건이 함께 추가됐다. 재사용 버퍼에 남은 값이 새어 나가는 일과 빈 슬라이스에서 panic이 나는 일을 막는다.
B. scarecrow(구 Botmaker) → XReview 인테이크 gRPC 파이프라인 신설 (신규 canon)
이번 커밋은 scarecrow와 XReview를 프로그램으로 잇는 경로를 만들었다. scarecrow의 자동 탐지 봇이 XReview 인테이크에 유저 리포트인 척 리포트를 넣는 gRPC 경로다. scarecrow는 X 내부의 규칙 기반 자동 탐지·조치 엔진이고, XReview는 유저 신고·모더레이션 큐다. scarecrow는 옛 Twitter Botmaker에서 물려받은 것으로, com.twitter.botmaker 패키지가 그대로 남아 있다.
report의 uniqueness는 content-hash report id와 detection_timestamp_ms가 결정한다. 같은 봇이 같은 콘텐츠를 두 번 감지해도 timestamp가 다르면 별개의 report가 되고, XReview가 이를 사후에 evidence rollup으로 집계한다.
report_type은 XReview 내부에서 리뷰어 팀을 고르는 라우팅(lane tag)의 discriminator다. scarecrow에서 인용한 자체 문서는 이렇게 썼다: "Impersonation bots should use bystander_impersonation (lane tags key on that type, not generic impersonation)." 타입을 잘못 고르면 lane이 잘못 정해지고, 리뷰가 늦어지거나 무시된다.
XReview 연결은 useng.common.xreview 공용 라이브러리를 거친다. scarecrow만이 아니라 X 내부의 여러 백엔드가 XReview 인테이크를 프로그램으로 호출한다는 정황이다.
의미 X의 모더레이션 파이프라인이 이번 커밋으로 확장됐다. canon 이전 판이 알던 것은 인라인 visibility-filtering과 grox PtOS 라벨링, 두 층이었다. 이번 커밋은 세 번째 층을 코드로 드러낸다. scarecrow가 XReview에 자동으로 신고하면, XReview가 evidence rollup으로 모으고 인간 리뷰어 lane으로 라우팅한다. 인라인으로 걸러 내는 경로가 아니라, 비동기로 신고하고 집계하는 경로다. ⚠ 주장 보강: 이전에는 X의 자동 탐지에서 인간 리뷰로 넘어가는 핸드오프를 "grox PtOS → policy service" 정도로 이해했다. 이번 커밋으로 그 핸드오프의 한 축이 유저 신고와 같은 XReview 큐를 공유한다는 사실이 코드로 확인됐다. bystander_impersonation 같은 XReview allowlist 타입이 있다는 것은 사칭 대응 라인이 lane 단위로 세분화되어 있다는 뜻이다.
C. VF rules 재구조화 phase 2 — Box<dyn Rule> 소거, 정책이 정적 테이블로
VF 규칙의 trait과 동적 디스패치용 벡터가 코드에서 사라졌다확인 — visibility-filtering/rules/mod.rs (−155줄). 대상은 Rule trait과 Vec<Box<dyn Rule>>이다. Rule trait 정의와 evaluate_rules 함수, 관련 unit test 5개가 모두 제거됐다.
Policies의 이름이 RuleEngine으로 바뀌었다확인. 이 struct는 더 이상 규칙 벡터를 소유하지 않고 NsfwGatingCountries 참조만 감싸므로, 새 이름은 이것이 dispatch 층임을 명시한다. RuleEngine::new()는 힙 할당을 하지 않는다.
규칙 평가의 시맨틱은 그대로다.
Drop은 short-circuit한다.
Interstitial이 나온 뒤에 Drop이 오면 Drop이 이기고, 없으면 처음 나온 것이 이긴다.
Allow면 decided_by = None이다.
홈 공통 9 slice와 추천 전용 5 slice로 이뤄진 구성은 4세대+13과 같다. 새 테스트 wired_rule_names_are_unique_and_nonempty는 rule slice 15개의 이름이 비어 있지 않고 서로 겹치지 않는지 검증한다. 15개는 홈 공통 9개와 추천 전용 5개에 FILTER_ALL을 더한 수다 확인 — tweet_rules.rs:1130-1157.
의미 4세대+11부터 +14까지 네 커밋에 걸친 리팩터가 어디로 가는지 분명해졌다. 규칙은 코드에서 데이터로, 정책은 힙에서 rodata로 옮기고, 이름은 dispatch 관점으로 정리하는 방향이다. 다음 단계는 규칙 리터럴을 config 파일에서 로드하는 것이고, 그러면 코드 배포 없이 규칙을 바꿀 수 있다 추정. 다만 static dispatch의 이점을 포기하는 방향이라 앞으로의 커밋을 봐야 확정된다.
D. 체크포인트 암호화 진화 — in-envelope 파생 마스터 키 + encryption_context 강제 + mint_nonce 자동 첨부
reader는 xai_encrypted tensorstore 드라이버를 직접 쓴다확인 — phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py. 4세대+13에서는 IO마다 KMS를 호출하는 xai_kms.KvServe KMS-proxy KvStore를 썼다. 이제는 DEK를 한 번 unwrap한 뒤 로컬에서 대칭 복호화한다. adopt_tree_dek는 _DEK 사이드카가 있으면 그것을 쓰고, 없으면 checkpoint 파일 envelope 헤더의 파생 마스터 키에서 unwrap하는 방식으로 backward-compatible을 유지한다.
writer는 encryption_context에 domain과 run 두 키를 반드시 요구한다확인 — phoenix/xrex/train/checkpoint_write.py. 누락되면 ValueError가 난다. domain은 시스템 도메인(예: "recsys", "grox")이고, run은 학습 run id다. 이 규칙은 KMS 감사 로그가 "어느 domain의 어느 run이 어느 checkpoint를 언제 unwrap했는가"를 남기게 강제한다.
encryption_context에 mint_nonce = sha256(path)가 자동으로 들어간다확인. 체크포인트 경로의 해시가 자동으로 context에 들어가서, 공격자가 체크포인트를 다른 경로로 복사해 unwrap을 시도하면 KMS가 context 매칭 실패로 거부한다. AWS-KMS encryption-context의 표준 패턴을 활용한 것이다.
_uses_tensorstore_save는 더 이상 encrypt를 강제하지 않는다확인 — phoenix/xrex/train/trainer.py. 두 옵션이 분리된 것이다. save_checkpoint는 kms_client를 인자로 받아 orbax_encrypted.get_encrypted_checkpointer를 자체적으로 조립한다. _AsyncCheckpointer는 orbax_encrypted 모듈의 wait_until_finished까지 기다려서, flush가 암호화 IO의 완료까지 보장하게 한다.
의미 이번 커밋으로 write path의 강제 검증과, read path의 두 포맷(사이드카와 in-envelope) 지원이 완성됐다. 파일 포맷이 코드에 남아 있어 앞으로 감사 도구나 뷰어 도구가 파싱할 수 있다. KMS-proxy KvStore가 사라지면서 restore 지연이 크게 줄어든다. Phoenix가 파일 수만 개짜리 체크포인트를 로드할 때 KMS 호출이 수만 번에서 1번으로 준다. 다만 CheckpointConfig.encrypt = True가 실제 트레이닝 config의 default에서 켜졌는지는 이번 diff에도 여전히 없다.
E. two-tower 학습 config qk_norm=True·attn_logit_cap=-1
two-tower 학습 config가 QK-norm을 켜고 attention 로짓 상한을 껐다확인 — phoenix/xrex/configs/xrecsys_two_tower.py. 값은 qk_norm: False → True, attn_logit_cap: 80.0 → -1(off)로 바뀌었다. 두 키는 4세대 공개부터 combined base에 False와 80.0으로 있었다 확인 — 47c1bcd의 xrecsys_two_tower.py:274-275. 2026-08-21 커밋(4세대+6)이 nano config에 True와 -1을 넣은 지 12일 만에 combined base 기본값도 같은 값이 됐다 확인 — 28e414f의 :429-430. QK-norm이 QK 내적을 bounded로 만들기 때문에 별도 로짓 상한이 필요 없어진다.
4세대+15 (2026-09-04, 9b0dc31) — grox 스레드 단위 multi-step reply spam 분류기 신설 + PTOS Illegal 카테고리 Gemma+Grok 4.1-fast 이중화 + Phoenix 서빙 SID 사이드카 완전 제거 + H100 remat 정책 + FA4 attention 가변길이 히스토리 지원 + VF hydration/safety-label 대규모 단순화
grox에 답글 스레드 전체를 한 번에 보고, 같은 저자가 자기 답글에 이어 단 답글들을 함께 스팸으로 판정하는 두 번째 경로가 생겼다 확인. PTOS의 불법·규제 행위 카테고리는 first-pass를 Gemma가, 심판을 Grok 4.1-fast가 맡게 됐고, Phoenix 서빙은 SID 사이드카 없이 요청에 실려 온 히스토리 SID를 쓴다 확인 (daily).
grox reply_spam에 다섯 파일, 500여 줄의 새 파이프라인이 추가돼 두 번째 답글 스팸 판정 경로가 정식으로 연결됐다. 이 경로는 답글 스레드 전체를 LLM에 넣고 "이 스레드에서 스팸으로 볼 포스트 인덱스 목록"을 돌려받는다.
PTOS 정책 심판 스택에 Grok 4.1-fast(EAPI_GROK_4_1_FAST_X_ALGO)가 IllegalAndRegulatedBehaviors 카테고리의 cross-validator로 등장했다. 이로써 다른 카테고리의 심판인 4.5 X_ALGO(4세대+7)와 비대칭 배정이 시작됐다. 같은 카테고리는 first-pass에서도 Gemma 경로(USE_GEMMA_CATEGORIES)로 승격됐다.
Phoenix 서빙 엔진에서 sid_client.rs(189줄)와 sid_retrieval_runner.py가 완전히 삭제되고, xai-recsys-sid-proto 의존성이 제거됐다. SID retrieval 사이드카가 사라졌고, 히스토리 SID는 요청에 직접 실려 들어온다.
Phoenix 학습 remat 정책에 SAVE_H100_RECSYS = 32가 신설됐다. GB300 외에 H100 세대에서도 학습이 돈다는 첫 코드 신호가 나왔다.
FA4 attention 커널(ranker_attention_fa4.py, +139/−75)이 재작성됐다. 이제 가변 길이 히스토리를 직접 처리하고(hist_valid_len, valid_block_upper/lower), 캐시는 사라졌다.
visibility-filtering의 hydration·safety_label_source 스택이 800+ 줄 순감소하며 전체적으로 얇아졌다.
줄어든 양은 registry.rs가 −378, safety_label_source/codec.rs가 −206, hydration/mod.rs가 −90 등이다.
그 밖에 Brazil2026ElectionFilter 계정 리스트도 소규모로 유지 보수됐다(TSE 갱신으로 계정 ID 166개 추가).
A. grox PlanMultiStepReplySpam — 스레드 전체를 Gemma에 던지고 여러 후속 답글을 한 번에 스팸으로 판정하는 두 번째 리플라이 스팸 경로
이번 커밋은 기존 답글 스팸 판정과 나란히 도는 두 번째 판정 경로를 신설한다. 스레드 전체를 LLM 프롬프트에 넣고, 여러 후속 답글을 한 번에 "이 인덱스들이 스팸이다"로 표시받는 흐름이다. 기존 grox reply_spam 파이프라인은 답글 하나에 단발성 판정을 내리고 결과를 저장하는 흐름이었다. 이 파이프라인에는 4세대+2~+10에 걸쳐 흐름 통합, follower 임계 100k까지 상향, Kafka-first 저장 순서 등이 적용됐다. 이름에 multi_step이 붙은 것은 판정 대상 단위가 개별 답글이 아니라 답글 chain 여러 개라는 뜻이다.
신설 파일은 6개다(Plan/State/Task/Classifier/Prompt와 generators 편입)확인:
"Thread" 절은 스레드의 모든 포스트를 #### Post N 형식으로 인덱스 번호를 붙여 나열한다.
프롬프트는 "Post 0은 root (never flag)이고, 가장 높은 번호가 the reply being evaluated"임을 명시적으로 알린다.
스레드가 MAX_THREAD_SIZE = 10을 넘으면 앞 5개와 뒤 5개만 남기고 중간을 잘라 낸다.
옵션은 각 포스트에 include_follower_count=True, root에만 include_bio=True, 그리고 max_media=2다.
이 프롬프트 설계 자체도 정본에 고정된다. LM에게 개별 포스트가 스팸인지를 묻는 것이 아니라, 스레드 안에서 스팸 인덱스 집합을 뽑아내라는 형태의 태스크다. 응답은 spam_post_indexes: list[int]와 reason: str로 된 JSON이다.
LM은 Gemma 2다. 설정은 model_name = GEMMA_2이고, OaiSampler로 호출한다(grok_sampler/oai_sampler.py). grox의 USE_GEMMA_CATEGORIES에는 4세대+9(45b48ba)에서 이미 Spam이 있었고, 이번 커밋에서 IllegalAndRegulatedBehaviors도 추가됐다(아래 B절). Gemma 경로는 X가 유료 grok API 예산 절감을 위해 밀고 있는 스택이다 추정. Multi-step reply spam이 처음부터 Gemma 전용이라는 점은 이 흐름과 들어맞는다.
후처리는 LM 출력을 신뢰하지 않고 3단 안전장치를 거친다확인:
_drop_if_newest_reply_not_flagged는 LM이 내놓은 후보 인덱스에 판정 대상(스레드의 마지막 포스트)이 없으면 결과 전체를 버린다. Task 진입 조건이 "이 반응이 스팸이라 볼 수 있어야 파이프라인이 도는 것"이라, 마지막 반응이 스팸이 아니라고 판정된 결과는 정합성 위반이다.
_drop_other_author_posts는 LM이 스레드의 다른 저자 포스트를 스팸으로 지목하면 그 포스트를 제거한다. 이 파이프라인의 대상은 같은 저자가 자기 답글에 자기가 답한 chain이라, 다른 저자의 포스트가 결과에 나오면 무시한다.
_drop_singleton은 결과가 정확히 1개면 버린다. Chain 스팸은 정의상 "여러 후속 답글"이 대상이라, 하나만 잡힌 결과는 이 파이프라인의 유효한 결과가 아니다.
이 세 필터를 지나고 남은 인덱스 집합이 실제 스팸 판정이다.
판정 결과는 TaskWriteMultiStepReplySpamReplyRanking이 저장한다확인:
지목된 포스트마다 _apply_reply_spam_label(post_id, author_id)를 적용한다. 단일 판정 파이프라인이 쓰는 것과 같은 헬퍼다.
ReplyRankingScoreStratoLoader.save_reply_ranking_score(..., ReplyRankingScore(score=0.0, reasoning=reasoning[-500:]))로 점수를 저장한다. score는 0.0(가장 낮은 신뢰)으로 강제하고, reasoning은 마지막 500자만 남긴다.
눈여겨볼 점은 TaskMediaHydration이 filter를 통과한 뒤에만 돈다는 순서다. media hydration은 비용이 크므로, filter가 상당 부분을 잘라 낸 뒤에만 실행된다.
새 Plan은 기존 스트림 두 개에 합류한다. 두 스트림 generator가 이 Plan을 자기 PLANS_TO_INJECT set에 추가한다. 하나는 ReplyRankingTaskGenerator(kafka TOPIC_UNIFIED_POSTS_V3)이고, 다른 하나는 ReplyRankingRecoveryTaskGenerator다. 즉 이 multi-step 판정은 기존 답글 랭킹, 코멘트 스팸, coordinated 스팸 세 판정과 함께 같은 v3 이벤트 소스에서 병렬로 돈다.
의미 이번 커밋으로 답글 스팸 판정에 세 번째 축이 붙었다. 그 축은 한 저자가 자기 답글 chain으로 root 노출을 훔치는 패턴이다. canon 이전 판이 알던 흐름은 둘이었다. reply_spam은 개별 답글을 대상으로 판정하고, coordinated_spam은 "여러 계정이 담합한 반복 답글"을 대상으로 판정한다. Coordinated spam이 계정 축의 담합이라면 multi-step은 시간 축의 chain 담합이다. 게이팅이 root 팔로워 1k라는 낮은 임계로 잡히면서(단발성 판정은 100k), 이 파이프라인은 root 저자의 노출 자산이 상당한 스레드에 광범위하게 도는 것으로 보인다. 또 판정을 Gemma로 돌려 grok API 예산을 절감하는 방향과도 들어맞는다.
아직 공개되지 않은 것들이 있다.
시스템 프롬프트 원문(README 명시로 export에서 제외)
이 파이프라인의 초기 활성 트래픽 비율(experiment 게이팅이 이 diff에 없다)
초기 프로덕션 metric(multi_step_reply_spam.scored.count 등 카운터의 실측값)
singleton drop이 실제로 얼마나 발동하는지(LM이 chain의 첫 씨앗 하나만 잡는 경향이 있는지)
B. PTOS SafetyPtosPolicyCrossValidator가 IllegalAndRegulatedBehaviors 카테고리를 Grok 4.5에서 Grok 4.1-fast로 이관 + Gemma가 first-pass에도 배정 (심판·primary 두 축 동시 이동)
PTOS 교차 검증 심판이 이번 커밋부터 카테고리마다 다른 모델을 쓰기 시작한다 확인. 4세대+7(d011592)에서 신설된 SafetyPtosPolicyCrossValidator는 단일 심판 스택이었다. 세 카테고리(ChildSafety·ViolentMedia·IllegalAndRegulatedBehaviors)를 모두 EAPI_GROK_4_5_X_ALGO로 재확인했다. 이번 커밋에서 IllegalAndRegulatedBehaviors만 심판이 다른 모델로 갈아탄다.
_validate_illegal_and_regulated_behaviors의 primary breaker·sample 줄이 바뀌었다. 이전에는 _eapi_4_5_x_algo_breaker.guard()와 self.eapi_4_5_x_algo.sample(...)였고, 이제는 self.eapi_4_1_x_algo.sample(...)이다. 즉 이 카테고리의 심판 모델이 4.5-X_ALGO에서 4.1-fast-X_ALGO로 이관됐다. 서킷브레이커 guard가 사라진 점도 눈여겨볼 만하다. 4.1-fast는 breaker 없이 직접 호출하고, 4.5는 breaker로 감쌌다. 메트릭 이름도 ..._cross_model_validate_with_grok_4_5에서 ..._cross_model_validate_with_grok_4_1로 바뀌었다.
의미 심판 모델을 카테고리마다 다르게 배정하는 흐름이 코드에 명시됐다. ChildSafety·ViolentMedia는 여전히 4.5-X_ALGO를 쓰고, IllegalAndRegulatedBehaviors만 4.1-fast로 갈아탔다. 카테고리별 판정 난도, 판정 latency 요구, 심판 disagreement 특성에 따라 심판 모델을 따로 튜닝한다는 정책이다. 이름에 fast 접미사가 있고 breaker 없이 호출하는 것을 보면, 4.1-fast는 4.5보다 응답이 빠르고 tolerable한 모델로 상정된다. X_ALGO 접미사는 x-algorithm 도메인 파인튜닝을 시사한다 추정.
이전에는 Spam만 있었다. 이제 Gemma가 두 카테고리에서 first-pass로 돈다.
SafetyPtosPolicyClassifier의 확률 dial 게이트(use_oai_gemma4_dial)가 제거됐다확인. dial 값은 4세대 공개부터 이 커밋 직전까지 1.0이었다(85ac72a의 classifier.py:490, 715). 이전에는 if sample_for_gemma and not self.deluxe and self.use_gemma and random.random() < self.use_oai_gemma4_dial:로 확률 gate가 있었다. 이번 커밋은 이 gate를 전부 걷어 냈고, 조건은 if sample_for_gemma: ... oai_gemma4.sample(...)가 됐다. 즉 USE_GEMMA_CATEGORIES에 있는 카테고리는 100% Gemma로 라우팅되고, grok는 폴백만 맡는다. Spam은 4세대+9(45b48ba)에서 처음 Gemma 후보로 도입될 때 확률 dial로 시작해 15일 만에 무조건 승격됐다. 이번 커밋에 IllegalAndRegulatedBehaviors까지 붙어, 두 카테고리가 함께 100% Gemma가 됐다.
이전에는 ViolentMedia만 이 재분류 경로를 탔다. 즉 IllegalAndRegulatedBehaviors는 first-pass Gemma, deluxe cross-model 재분류(비용이 크다), 심판 Grok 4.1-fast cross-validator 순서의 세 층 스택이 됐다.
의미 PTOS 판정 계층이 카테고리마다 따로 진화하고 있다는 사실이 분명해진다. Spam은 4세대 공개부터 Gemma first-pass다(dial 1.0). ChildSafety·ViolentMedia는 4세대+7 이후로 Grok 4.5-X_ALGO 심판을 쓴다. IllegalAndRegulatedBehaviors는 이번 커밋으로 Gemma first-pass, Grok 4.1-fast 심판, 재분류의 세 층이 됐다. Judge 모델과 primary 모델을 카테고리별로 따로 튜닝하는 이중 스택이 이어지고 있다. Gemma 승격은 grok 예산 절감을 위한 것으로 보이고, 4.1-fast 심판은 4.5 심판보다 저지연·저비용으로 예산 관리 방향과 들어맞는다 추정.
C. Phoenix 서빙 엔진에서 SID retrieval 사이드카가 통째 제거 — 히스토리 SID가 요청에 실려 들어옴
Phoenix 서빙 엔진이 SID를 따로 조회하던 사이드카 경로가 삭제됐고, 히스토리 SID는 이제 요청에 실려 들어온다 확인.
create_server에서 sid_client = xai_recsys_engine.PySemanticIdClient(...)를 조립하던 코드 12줄도 제거했다.
대신 들어온 시맨틱은 남은 로그에서 드러난다확인. 로그는 "Parsing history SIDs from the request (sid_num_levels=%d); no sid_endpoint"다. 즉 히스토리 SID는 이제 요청에 직접 실려 들어온다. 이전에는 sid_endpoint가 설정돼 있으면, 서빙 엔진이 별도 SID 서비스를 호출해 유저 히스토리의 post_id마다 SID 코드워드를 조회하는 사이드카 경로가 있었다. 이번 커밋으로 그 경로가 사라졌다. 이제는 up-stream(home-mixer 등)이 요청을 만들 때 SID를 이미 담아 보낸다는 계약으로 이관됐다.
use_pinned_d2h의 default도 False → True로 바뀌었다. CUDA pinned host memory로 D2H를 전송하면 ~50 GB/s이고, JAX 기본은 ~3 GB/s다. 추론당 ~29ms가 절감된다. 이 옵션은 4세대+6에서 도입됐고, 이번 커밋에서 기본값이 바뀌었다.
의미 Phoenix retrieval의 SID 파이프라인 구조가 크게 정리됐다. 이전 canon은 4세대에서 phoenix-rankall이 SID 인덱스 window(video_14day 등)를 관리한다는 것을 알았다. 4세대+3에서 VMRanker에 SID 필드 7개가 연결됐다는 것도 알았고, 서빙 엔진에는 옵션인 SID 사이드카가 있다고 이해했다. 이번 커밋으로 서빙 엔진에서 SID 사이드카가 완전히 사라지고 요청에 SID가 직접 실린다는 사실이 확정된다. 즉 SID codeword 조회 책임이 online serving 밖으로 이관됐다. home-mixer가 candidate hydration 단계에서 SID를 미리 뽑아 request에 담고, phoenix-rankall이 그것을 그대로 소비한다. 요청당 추가 gRPC 호출 하나가 사라지고, SID 인프라의 fault domain이 좁아진다.
아직 공개되지 않은 것들이 있다.
home-mixer(또는 candidate hydration 층)에서 SID를 채우는 hydrator의 이름과 소스
SID codeword storage(Manhattan? Strato?)
이관 시점(experiment로 dual-write 기간이 있었는지)
D. Phoenix 학습 remat에 H100 정책 신설 (GB300 외에 H100에서도 학습이 도는 첫 신호)
SAVE_H100_RECSYS가 새 값 32로 등록됐다. custom_remat_policy(policy)에는 elif policy == RematType.SAVE_H100_RECSYS 분기가 추가됐다. 이 분기는 GB300과 동일한 이름 리스트를 rematerialization checkpoint 대상으로 저장한다.
이름 리스트는 attn_outputs·dense_outputs·dense_outputs_individual·attn·gate_up_proj·dense_up_proj·query_heads_rope·key_heads_rope·cutedsl_attn_outputs·query_heads·key_heads·value_heads·scalar_stats다.
두 정책은 이름 리스트가 같지만 enum 값은 따로 갖는다. 이는 GB300과 H100에 각각 다른 튜닝(예: 저장 텐서 우선순위 프리셋)이 뒤에 붙을 가능성을 열어 둔 구조다.
의미 이번 커밋은 H100용 remat 정책을 코드로 명시함으로써 X가 GB300과 H100 두 세대의 하드웨어에 학습을 배분한다는 첫 신호를 남겼다. canon 이전 판은 4세대+6에서 _GB300_OVERRIDES가 등장하며 Phoenix 프로덕션 학습이 GB300에서 돈다는 것을 확정했다. H100 학습 여부는 언급이 없었다. GB300은 flagship이고, H100은 secondary·backfill·research용일 가능성이 있다 추정. _H100_OVERRIDES 같은 hardware-specific config block은 이 커밋에 아직 없다. 다음 커밋에서 나올 관측 포인트다.
E. Phoenix ranker_attention_fa4 — 가변 길이 히스토리 원생 지원 + 캐시 제거
FA4 attention 커널이 이제 길이가 제각각인 히스토리를 padding 없이 직접 처리한다.
FA4 커널은 이전에 "히스토리가 항상 tile 크기의 배수(128)로 정확히 채워진다"를 가정했다확인 — phoenix/xrex/cutedsl/ranker_attention_fa4.py. FA4는 NVIDIA CUTLASS/CUTe DSL 기반 flash attention v4다. 이전 시그니처는 build_dense_block_sparse_layout(batch_size, seq_len, hist_len, num_q_heads)였다(batch 인자가 있는 4-인자). 이제는 (seq_len, hist_len, num_q_heads, hist_valid_len)로 바뀌었다. 새 인자 hist_valid_len은 배치의 아이템마다 실제 유효 히스토리 토큰 수를 받는다.
계산이 numpy에서 jax.numpy로 옮겨 갔다. 이전에는 import numpy as np로 layout을 python 호스트에서 미리 만들어 캐시(_DENSE_BS_LAYOUT_CACHE)했다. 이번에는 jnp.where·jnp.arange·jnp.broadcast_to로 JIT-friendly하게 재작성됐고, 캐시는 제거됐다. 이유는 분명하다. hist_valid_len이 배치마다 달라지므로 layout이 결정 함수가 아니라 배치 데이터의 함수가 됐고, python 호스트 캐싱이 무의미해진다.
새 필드 valid_block_upper·valid_block_lower가 생겼다. attention mask가 partial-history block을 처리할 수 있도록, 상한과 하한을 배치별로 계산해 커널에 넘긴다. partial-history block은 히스토리 tile 안에서 실제 유효 토큰이 tile 크기 이하인 경우다. is_partial = is_hist & (t == n_full) & (has_partial == 1)이 partial block의 위치를 표시한다. full_q_seq·mask_q_seq는 forward·backward 패스에서 각각 유효 토큰 위치를 인덱스로 산출한다.
ranker_attention_fa4의 시그니처가 넓어졌다.valid_block_upper·valid_block_lower 두 optional 인자가 붙었고, 값이 없으면 zero-fill로 폴백한다. custom_vjp는 새 인자들도 gradient 계산에 포함시키기 위해 bs_args = (*fwd_bs, *bwd_bs, valid_block_upper, valid_block_lower)로 압축·분해한다. input_output_aliases는 {12: 0, 13: 1, 14: 2}에서 {14: 0, 15: 1, 16: 2}로 밀렸다. 새 인자 두 개가 앞에 붙었기 때문이다.
의미 FA4 커널이 이제 padding이 필요 없는 varlen 히스토리를 직접 처리한다. 이전에는 짧은 히스토리(예: 신규 유저)를 tile 크기까지 padding해서, attend 대상이 늘어나거나 실제 attention 시맨틱이 오염됐을 것이다. 이번 커널은 partial block의 상·하한 마스킹을 안에서 처리해, padding 없이 정확한 attention을 계산한다. 신규 유저와 짧은 세션에서 학습·추론 정확성이 개선된다. 4세대+6에서는 attn_impl="cutedsl_ranker_varlen_attn"이 GB300 override의 attention 커널이었다. 그 varlen 시맨틱이 이번 커밋에서 히스토리 축까지 확장된 것으로 이해할 수 있다. sequence-transformer-ranking의 attn 커널 축에 새 관찰이 붙는다.
F. visibility-filtering 대규모 hydration·safety-label 단순화 (800+ 줄 순 감소)
visibility-filtering의 hydration·safety-label 스택에서 코드가 크게 줄었다 확인.
전체 크기가 순감소한 방향이라, 이번 변경은 정책 확장이 아니라 안전망·중복 코드·과설계 층을 걷어 내는 작업이다. 이번 커밋은 아래 커밋들의 흐름을 같은 방향으로 이어 간다. 재작성이 안정된 뒤, 덧붙어 있던 폴백·중복·과설계 코드를 걷어 내는 흐름이다.
4세대+9(45b48ba)의 media fallback cache 제거
4세대+8(0d3cdd8)의 VMRanker 128줄 삭제
4세대+11의 rules 리팩터
4세대+14(85ac72a)의 Box<dyn Rule> 소거
같은 커밋에서 visibility-filtering/rules/fixtures.rs(+81)와 golden_corpus.rs(+102)가 확장돼, 회귀 방어망은 더 두꺼워졌다. 단순화와 회귀 방어 사이의 균형을 유지하는 일관된 패턴이다.
다음 질문은 심층 분석이 필요하다. 이번 daily에서는 개요 수준까지만 확정한다.
safety_label_source codec의 206줄 삭제가 어떤 wire 포맷·라벨 소스를 걷어 냈는지
hydration/mod.rs의 90줄 삭제가 어떤 hydrator entry를 제거했는지
config.rs의 118줄 삭제가 어떤 config 계층을 통합했는지
G. Brazil2026ElectionFilter 유지 보수 + PR #88 (e4dcedd) VF in-network id dedup
브라질 선거 필터의 계정 목록이 정기 갱신 관행대로 바뀌었고, 외부 기여자의 PR이 머지됐다 확인.
Brazil filter 파일이 바뀌었다확인 — home-mixer/filters/brazil_2026_election_filter.rs (+339/−4). 실제 delta는 다음 세 가지다.
계정 ID 166개를 하드코딩으로 추가해, 집합이 2,388개에서 2,554개가 됐다 확인. 추가된 계정의 예는 +@nanacachen·+@AecioNeves·+@lgmbrasilia·+@crismonteirosp·+@rodrigolimamdh·+@samiabomfim·+@gabrielmagno_13이다. 계정은 obfuscated user_id와 주석의 @username으로 적는다.
"not the candidate's actual account" 항목 3~4명을 신설했다.
"no live account found" 항목 소수를 갱신했다.
4세대+8(0d3cdd8)의 정기 갱신 관행이 계속된다는 관찰이다. 파일 위치는 그대로 home-mixer/filters/이고, AllowFollowedAuthors 예외도 그대로다. 이 관행이 정기 커밋 시그널로 계속 등장한다는 것이 canon의 관찰이다.
PR #88(e4dcedd, 2026-09-03 20:28 UTC)이 머지됐다확인 — xai-org/x-algorithm@e4dcedd. 외부 기여자(joshs1017dev)의 PR로, home-mixer/candidate_hydrators/vf_candidate_hydrator.rs에 두 줄을 추가했다. in_network_ids를 VF lookup 전에 dedup하는 성능 최적화 하나다. 외부 기여자 PR이 머지되는 관행 자체가 canon의 관찰 대상이다. 4세대의 오픈소스 태도가 실제로 외부 PR을 받는다는 증거다.
4세대+16 (2026-09-04, 902a06f) — cold-start experiment의 codivert impression 대칭 배선 + treatment 뷰어 Phoenix MoE 후보 국한 + Phoenix serving 계약에 experiment_overrides map · SlateContext.exact_k/exact_gap 추가 + VF dark-traffic 미러링에 devel app_env 확장 + VF muted-keyword rule 예정 신호
콜드스타트 실험의 control과 treatment 두 arm이 같은 bucket impression을 남기도록 바뀌었고, treatment 뷰어의 콜드스타트 후보는 Phoenix MoE 후보로 좁혀졌다 확인. Phoenix 서빙 요청에는 요청별 실험 param override와 slate metric 필드 두 개가 추가됐다 확인 (daily).
새 param은 rust_home_mixer_phoenix_moe_codivert_viewer_is_control과 rust_home_mixer_phoenix_moe_codivert_viewer_is_treatment다(bool, default false). moe_codivert_viewer experiment에는 대칭 YAML rule이 생겼다. Treatment/control 두 arm은 각각 enable_viewer_cold_start_boost=true/false로 대칭으로 갈린다. 핵심 재정렬은 다음 순서다.
먼저 ColdStartParams::read(query)로 param 조회를 한 번에 묶고, viewer_arm bucket impression을 무조건 남긴다.
Impression을 남긴 뒤에 반환하므로, 두 arm의 관측 로그 shape가 대칭이 된다. 이전 판은 !EnableViewerColdStart이면 impression을 남기기 전에 조기 반환했다.
새 회귀 테스트가 이 대칭을 고정한다.
both_arms_log_the_same_codivert_impressions는 control과 treatment가 각각 moe_codivert_viewer impression을 정확히 3회 남기는지 검증한다.
both_viewer_arms_log_the_same_author_impressions는 두 arm이 moe_exp(author 대상) impression을 정확히 2회 남기는지 검증한다.
impression count는 테스트 유틸 SpyingBucketImpressor(experiment SDK가 노출)로 계측한다. X가 A/B bucket 관측 대칭성을 회귀 테스트로 강제한다는 문화 관찰이다.
B. Cold-start Treatment 후보 필터가 Phoenix MoE 후보에만 걸리도록 좁혀졌다확인 — 위 파일:
ViewerArm::Treatment => corpus == AuthorCorpus::Treatment && is_phoenix_moe(c),
이전에는 corpus == AuthorCorpus::Treatment만 확인했다. 이번에는 && is_phoenix_moe(c) 조건이 추가됐고, 이 조건은 served_type == ForYouPhoenixRetrievalMoe라는 뜻이다. 이제 Treatment 뷰어의 cold-start 후보는 treatment corpus author의 포스트이면서 Phoenix MoE 소스에서 온 후보만이다. 테스트 이름도 treatment_viewer_keeps_treatment_moe_and_cold_starts_treatment_corpus에서 treatment_viewer_cold_starts_treatment_moe_only로 바뀌었다. Cold-start와 Phoenix MoE는 이제 하나의 codivert 실험 안에 묶여, 두 효과가 confounded되지 않게 격리된다.
C. Engagement counts hydration이 cold-start 게이팅에서 분리됐다확인 — home-mixer/candidate_hydrators/engagement_counts_hydrator.rs. enable()은 EnableViewerColdStart 종속을 없애고, EnableEngagementCountsHydration || is_shadow_traffic로만 결정한다. !query.has_cached_posts 게이팅도 제거돼, 캐시가 있어도 갱신한다. Cold-start Thompson sampling이 view_count_on_home·fav_count에 의존하는 4세대+1 초기 연결의 강결합이 해체됐다.
PredictNextActionsRequest.experiment_overrides = 21(map<string, string>)은 요청마다 experiment 파라미터를 override한다. 이 필드는 4세대+16 codivert 연결(A)과 짝을 이룬다.
home-mixer가 upstream에서 experiment 배정을 확정하면, 그 결정을 Phoenix로 그대로 전달해 downstream experiment 스코프를 upstream과 일관되게 한다.
SlateContext.exact_k = 16·exact_gap = 17(optional uint32)이 생겼다. SID 계열(4세대+3)과 recon 계열(4세대+9~+11)에 이어 exact 계열이 붙은 것이다. 이름 그대로 정확 일치 기반의 slot 이웃 metric으로 추정한다 추정.
Home-mixer 쪽에서도 home-mixer/models/candidate.rs의 SlateContext struct와 From<xai_recsys_proto::SlateContext> 변환 impl 양쪽에 대응 필드를 추가했다.
E. VF dark-traffic 미러링이 devel app_env까지 넓어졌다확인 — visibility-filtering/dark_traffic_setup.rs. parse_staging_listener가 parse_mirror_listener로 이름이 바뀌었고, STAGING_APP_ENV·DEVEL_APP_ENV 두 값을 순회한다. Devel TLS 도메인은 visibility.xai-vf-service.devel.{dc}.s2s.twttr.net이고, staging과 다르다. MirrorChannelFactory는 리스너의 xds_dest에 .devel.이 있으면 devel 팩토리를, 없으면 staging 팩토리를 고른다.
통과하는 예로 xai-vf-service.devel.visibility:grpc는 xai-vf-service.devel이 된다. xai-vf-service-pr-123.devel.visibility:grpc는 xai-vf-service-pr-123.devel이 된다.
GitHub PR 번호 기반의 프리뷰 배포 관행을 시사한다 추정.
Reject 케이스도 엄격하다. development·staging-devel·devel-staging·grpc-extra·grpc.other 등 유사한 이름은 명시적으로 거부한다.
#[cfg_attr(
not(test),
expect(dead_code, reason = "retained for the upcoming muted-keyword rule")
)]
pub text: String,
VF 서비스의 CoreFeature.text는 현재 어떤 rule도 소비하지 않지만, dead-code 억제 어트리뷰트로 명시적으로 보존됐다. 새 테스트 assemble_hydrates_text_from_core_data는 hydration 경로에서 text가 채워지는지 회귀 테스트로 고정한다. 4세대+4의 Following 전용 FollowingViewerMutedKeywordFilter가 이번에는 VF 서비스 전역으로 확장될 예정이다. Following과 For You 파이프라인을 통합하는지, 병렬 두 층으로 두는지는 다음 커밋에서 볼 관측 포인트다.
G. VF에서 pub을 pub(crate)로 좁히는 대규모 정리가 있었다확인 — visibility-filtering/lib.rs. clients·hydration·models·rules·safety_label_source·twemcache 모듈이 crate-internal로 좁혀졌다. config.rs·params.rs·server.rs에서는 여러 pub 함수와 상수가 pub(crate)로 바뀌거나 dead-code로 제거됐다. twemcache/client.rs의 DEFAULT_REQUEST_TIMEOUT·DEFAULT_CONNECT_TIMEOUT 상수도 제거됐다. 4세대+15(9b0dc31)의 800줄 순감소와 방향이 같다. 재작성이 안정된 뒤, 외부에 노출할 필요가 없어진 API를 좁히는 흐름이다.
랭킹 쪽에는 행동별 가중치를 요청마다 흔드는 장치가 생겼지만 흔드는 폭의 기본값은 0.0이고, 랭커가 받는 포스트 정보에는 콘텐츠 특징 필드가 생겼다 확인(A·C 항목). 안전 판정에는 PTOS를 이미 거친 30초 이상 영상의 앞 10초를 다시 보는 경로가 생겼고, 검색 전용 임베딩 파이프라인과 attention 커널 backward 코드 공개도 같은 커밋에 들어 있다 확인(B·D·E 항목). 이 커밋은 daily에서 다뤘다.
랭킹 스코어러가 참여 헤드 26개의 가중치를 요청마다 exp(±sigma)로 흔든다. home-mixer/scorers/ranking_scorer.rs에 새로 생긴 ScoringWeights::perturbed()가 이 일을 하고, 부호는 md5(salt:user_id:head)로 결정론적으로 정해진다.
grox/flows/mm_emb/에 검색 지면 전용 v8-search 임베딩 스택이 생겼다. 새 파일 12개(+1,026줄)가 추가되고 기존 파일 5개(+171줄)가 수정됐다.
Phoenix 서빙 proto에 ContentFeatures message와 TweetInfo.contentFeatures·quotedContentFeatures 필드가 새로 생겼다. 값은 새 파일 home-mixer/models/content_features.rs(185줄 신규)가 Twitter의 텍스트 가중 규칙으로 만든다.
grox/flows/ptos/에 PlanSafetyPtosAdultContentLeadingFrames 8단 DAG와 진입 필터가 새로 생겨, 영상의 앞 10초를 잘라 다시 판정한다. 진입하려면 이미 PTOS를 거쳤고 NSFW 판정이 없는 30초 이상 영상이어야 하며, deluxe fav 임계도 넘어야 한다.
phoenix/xrex/cutedsl/ranker_fa4/에 flash_fwd·flash_fwd_sm90·flash_bwd·flash_bwd_sm90·flash_bwd_preprocess·ampere_helpers 6개 파일(총 7,080줄)이 새로 생겼다. Dao-AILab flash-attention 원본 fork를 X.AI가 수정한 SM80/SM90 forward·backward 커널을 전부 오픈소스로 공개한 것이고, backward pass 원본은 이번에 처음 공개됐다.
그 밖에 phoenix/xrex/utils/aot.py는 CutlassCall device blob을 SHA256 digest로 sanitize해 AOT 캐시를 안정화한다. copy_port_client.rs는 MANIFEST.json이 있는지 보고 번들이 끝난 checkpoint만 고르고, quote_hydrator.rs에는 MediaInfoCacheClient 병렬 hydration이 추가됐다.
param.rs 헤더의 sync 시각은 2026-09-04T16:22:24Z → 2026-09-08T16:24:06Z로 바뀌었다.
같은 (salt, user_id)에서 헤드 26개는 서로 독립적인 부호를 갖고, salt를 바꾸면 유저별 배치가 전부 재편성된다. σ=0.1이면 가중치가 약 ±10.5%, σ=0.3이면 약 ±35% 방향으로 흔들린다. log-space에서 대칭인 곱셈 노이즈다. 이 장치는 Bandit-style exploration(gradient-free)이나, 프로덕션 트래픽에서의 A/B weight-sensitivity 측정 인프라로 읽을 수 있다 추정. 기본값이 0.0이라 프로덕션 기본 동작은 여전히 결정론적이다.
TaskBackfillPreloadGate는 V8_SEARCH_MODEL_VERSION = "v8_search_1"로 이미 임베딩된 포스트를 건너뛴다. 통과한 포스트만 _min_fav.observe_pass()로 rate 계산에 반영한다.
fanout은 task_backfill_fanout.py (85줄)가 맡는다. BackfillIdBatch.post_ids(kafka value의 byte-packed <q list)는 먼저 BackfillConsumerDedup으로 처음 관측한 것인지 확인한다. 이어서 BatchedFavHydrator로 fav count를 병렬 hydration하고, asyncio.Semaphore(FANOUT_CONCURRENCY=240) 상한 안에서 Plan을 병렬 실행한다. task_timeout을 넘기면 TimeoutError가 나고, 결과는 task.backfill_fanout.posts.count{result=executed|failed}로 남는다.
이 스택은 검색 지면 전용 벡터 공간을 v5·v8.2와 별도로 연결한 것이다. 4세대+4의 Phoenix xrecsys_search config와 짝을 이룬다. 검색 랭킹 모델과 그 짝인 검색 문서 임베딩 파이프라인이 모두 갖춰진 것이다. 다만 RECSYS_V8_SEARCH_EMBED 두 endpoint의 실제 모델(dim, base architecture, training data)과 retrieval index 연결은 공개되지 않았다.
빌더 build(candidate: &PostCandidate) -> ContentFeatures와 build_quoted(...)는 순수 함수다. TextMetrics::of(text, attachment_urls)는 Twitter의 텍스트 길이 계산 규칙을 그대로 따른다.
URL은 무조건 23자로 센다(URL_WEIGHTED_LEN). t.co shortener에 대응한 것이다.
media_attachment_url_is_not_an_external_link는 미디어 URL과 external 링크의 구분을 고정한다.
의미 랭커가 텍스트 자체의 구조 시그널을 wire에서 first-class로 받는다. weighted_text_len은 "얼마나 꽉 차 있나 / URL만 걸린 pass-through인가"를, newline_count는 형식을, has_url은 external navigation을 나타내는 시그널이다. 다만 이 커밋에는 phoenix/xrex/models/recsys_model.py에서 이 필드를 embedding하는 코드가 없다. wire에는 실렸지만, 학습에서 소비하는지는 다음 커밋에서 관측할 대상이다.
의미 이 경로는 4세대+12의 special-video 경로와 4세대+7의 3-카테고리 cross-validator에 이어, 첫 몇 초를 위장하는 시간축 회피를 겨냥한 세 번째 방어 layer다. special-video 경로는 정적 오버레이 회피에 대응하고, cross-validator는 강등만 허용한다. prior에 AdultContent violated policy가 있는데도 isNsfw가 아닌 것은 cross-validator가 강등만 허용하기 때문이다. 이 경로는 강등된 판정을 다시 뒤집을 수 있는 두 번째 심판이다. 무조건 진입(unconditional) 임계인 512 fav는 프로덕션의 중간 인기 포스트 규모다.
새로 추가된 코드는 총 7,080줄이다 확인 — 49815da의 파일별 추가 줄 수 합. 파일 헤더에는 # Derived from flash-attention (https://github.com/Dao-AILab/flash-attention); modified by X.AI Corp.와 SPDX-License-Identifier: Apache-2.0 Copyright 2026 X.AI Corp.가 적혀 있다. 이번에 오픈소스로 공개된 것은 Dao-AILab/flash-attention 원본을 base로 X.AI가 수정한 fork다. phoenix/NOTICE 파일에도 flash-attention 서브구성요소의 저작권 표기가 +198줄 추가됐다.
커널의 주요 특징은 다음과 같다.
CuTeDSL(cutlass Python DSL) 기반이다.
head_dim != head_dim_v인 asymmetric attention을 지원한다.
pack_gqa(Grouped Query Attention)를 지원한다.
is_causal·is_local·score_mod·mask_mod·has_aux_tensors 옵션이 있다.
스케줄러는 SingleTileScheduler·SingleTileVarlenScheduler 두 가지다(fixed·varlen).
backward pass 원본이 처음으로 오픈소스로 공개됐다. 4세대+15에서 forward의 varlen 지원 재작성이 공개됐지만, backward는 이번이 처음이다. 학습에 필수인 backward가 X 내부 저장소에만 있고, 오픈소스 미러에서는 forward만 공개된 형태였다.
SM90(H100) 쪽 연결이 확정됐다. 4세대+15의 SAVE_H100_RECSYS = 32 remat 정책은 "H100 학습이 돈다는 첫 코드 신호"였다. 이번 커밋에는 SM90용 forward·backward 두 파일이 명시적으로 있으므로, H100 학습 쪽 연결이 완결됐다. X는 GB300(SM100)과 H100(SM90) 두 세대 하드웨어에 각각 최적화된 attention 커널을 유지한다.
X는 flash-attention 상류를 재사용하는 전략을 택했다. CuTeDSL을 처음부터 새로 쓰기보다 Dao-AILab 원본을 fork해 수정하는 방식이다. attention 커널이 LLM 커뮤니티가 검증한 표준화된 인프라이며, X도 그 표준을 벗어나지 않았다는 신호다.
공개되지 않은 것은 SM90/SM80 커널의 벤치마크(GB/s·TFLOPS·baseline 대비 speedup)와 block_sparse_utils.py의 sparse pattern 실제 사용처다.
그 밖의 변경
CutlassCall의 device blob을 SHA256 digest로 바꿔, JAX AOT 캐시가 같은 CUDA kernel binary에 안정적인 cache key를 갖게 하고 재컴파일이 되풀이되는 것을 막는다 확인 — phoenix/xrex/utils/aot.py (+54/−1).
candidate-pipeline 8개 파일에서 Pipeline 코어 signature를 정리했고, 시맨틱 변화는 없다 확인 (+103/−38).
VF safety_label_source/proto.rs에서 proto가 조금 확장됐다 확인 (+34/−38).
video_tools의 image.py(+42/−19), subtitles.py(+3/−1), video_frames.py(+7/−7)가 조금 수정됐다 확인.
visibility-filtering-client/vf_client.rs에서 VF 클라이언트에 새 API가 추가됐다 확인 (+119/0).
grox reply_spam 코드가 정리됐다 확인.
4세대+15에서 새로 생긴 multi-step reply spam 파이프라인을 조금 손본 것이고, 줄어든 줄은 task_write.py(−17), task_multi_step_reply_spam.py(−9)다.
공개되지 않은 것은 다섯 가지다.
weight perturbation의 실제 프로덕션 σ 값과 salt 배정 로직
v8-search 임베딩의 dim·base model·retrieval index 연결
ContentFeatures가 실제로 어느 랭킹 헤드에서 어떻게 쓰이는지
FA4 SM80/SM90 커널의 벤치마크 수치
leading-frames 경로의 실제 재분류율
4세대+18 (2026-09-09, 75d93d9) — VF 판정 축 리팩터 (FilteredReason → Action) + 신규 광고 포맷 header-image-prompt-ad + grox 30초+ 영상 preview thumbnail + broadcast의 PTOS 안전 파이프라인 정식 편입 + 랭킹 훈련 config에서 multimodal 임베딩 전부 비활성화
이 커밋부터 visibility-filtering은 걸러 낸 이유 대신 명시적인 조치 값으로 후보를 거르고, 홈 타임라인에는 여섯 번째 광고 포맷이 생겼다 확인(A·B 항목). 라이브 방송이 PTOS 안전 파이프라인에 들어왔고, 랭킹 학습 config 두 개 모두에서 멀티모달 포스트 임베딩이 꺼졌다 확인(D·E 항목). 이 커밋은 daily에서 다뤘다.
TweetVisibility는 기존 reason: Option<FilteredReason>과 safety_labels에 더해 action: Action 필드를 정식 멤버로 갖게 됐다. Action variant는 Allow·Interstitial·Avoid·Downrank·Drop(DropReason)·Tombstone·NotEvaluated다. StratoVfClient는 Strato에서 받은 FilteredReason으로부터 action_from_strato_reason으로 action을 유도한다. 유도 규칙은 네 가지다.
⚠ canon 주장이 갱신됐다. 이전 정본은 "VFFilter는 fail-closed다. SafetyResult 이외의 모든 FilteredReason 변종이 무조건 drop이다"였다. 새 판정 함수는 문법이 완전히 달라졌지만 fail-closed 성격은 유지된다. non-SafetyResult reason은 client 층에서 이미 Drop으로 변환되고, NotEvaluated도 drop이다. 바뀐 것은 하나다.Downrank와 Avoid가 명시적 keep 대상이 됐다. 이전에는 경로에 따라 애매했다.
B. 홈 타임라인에 여섯 번째 광고 포맷 header-image-prompt-ad가 생겼다
기존 다섯 포맷(promoted-tweet · preroll 비디오 · SSP/RTB Trend Takeover · rtb-image-ad · GoogleSDKNativeAd)에 이은 포맷이다. marshal_ad()는 맨 앞에서 AdIndexInfo.header_image_prompt_ad_info: Option<HeaderImagePromptAdInfo>가 있는지 보고, 있으면 marshal_header_image_prompt_ad로 분기한다. 이 경로는 광고를 URT TimelineEntryContent::Item(TimelineItem { content: TimelineItemContent::Message(MessagePrompt { content: MessageContent::HeaderImagePrompt(HeaderImagePrompt { .. }) }) })로 마샬링한다. Entry ID 형식은 header-image-prompt-ad-<impression_hex>다.
각 버튼은 자체 ClientEventInfo(action = "primary_cta" 또는 "secondary_cta")와 on_click_callbacks: Option<Vec<Callback>>를 가진다. Secondary 버튼은 secondary_button_text가 비어 있지 않을 때만 붙는다. 홈 타임라인에서 URT MessageContent 타입을 쓰는 광고는 이 포맷이 첫 사례이고, secondary CTA를 지원하는 광고 포맷도 이것이 처음이다. 테스트 3개가 이 계약을 고정한다 확인 — home-mixer/util/urt/ad_marshaller.rs (+258/−3).
Video pydantic 모델에 preview_image: bytes | None 필드가 생겼다. 새 상수는 _VIDEO_PREVIEW_MIN_DURATION_MS = 30_000과 PREVIEW_IMAGE_LABEL = "[Video Preview Thumbnail]"이다. hydrate_video_preview는 4-gate를 통과한 영상의 첫 프레임을 resize_tile(bs, tile_size)로 리사이즈해 저장한다. 4-gate는 convo_video 존재, url 존재, videoInfo 존재, duration > 30s다. tile 크기는 deluxe(=main_post ∧ is_high_fav)면 deluxe_image_tile_size, 아니면 image_tile_size다. LLM 입력에서는 interleave_storyboard가 preview를 storyboard 앞에 [LABEL, image_bytes, "\n"] 세 원소로 넣는다 확인. _image_part chat marshaling도 같은 순서로 라벨 텍스트와 이미지 파트를 storyboard 앞에 붙인다 확인 — grox/core/data_loaders/media_loader.py (+100/−19), grox/core/lm/convo.py (+18).
Broadcast payload가 정식으로 추가됐다. 기존 코드는 long_videos ≥ 30s만 봤고, 없으면 no_long_video로 건너뛰었다. 새 코드는 broadcasts = [p.broadcast_metadata for p in (post, post.quoted_post) if p and p.broadcast_metadata and p.broadcast_metadata.broadcast_id]도 함께 계산해, 둘 중 하나라도 있으면 진입한다. 이때 broadcast_metadata.crop_seconds = 10.0이 설정되고, eligible.count에 media = "both" | "broadcast" | "video" 태그가 붙는다.
바뀐 뒤의 gate는 5개다.
post.user 존재
30초 이상 영상 또는 broadcast_id 존재
prior PTOS 판정 존재
prior 판정이 NSFW 아님
prior AdultContent violated policy 또는 fav ≥ 512
PTOS safemodel sex/nudity payload에도 broadcast 프레임이 정식으로 들어왔다. enable_local_broadcast_frame_extraction으로 gate되며, 켜져 있으면 _iter_broadcast_videos(post)가 broadcast_metadata.video.convo_video.frames를 순회한다. _sample_uniform(frames, _MAX_FRAMES_PER_VIDEO=8)로 고른 samples는 ("broadcast_frame", frame) payload로 append된다. _FRAME_KINDS = {"video_frame", "broadcast_frame"} 상수로 has_video·n_video_frames를 셀 때 두 종류를 동등하게 취급한다 확인 — grox/flows/ptos/task_safety_ptos_adult_content_leading_frames_filter.py (+18/−8), grox/flows/ptos/task_safety_ptos_safemodel_sex_nudity.py (+21/−2).
E. 랭킹 훈련 config에서 multimodal post 임베딩이 전부 꺼졌다확인 — phoenix/README.md (+3/−3).
nano 훈련 config 표의 "Multimodal post embedding" 행이 "v5 on xrecsys_seqpack; off on home_direct_packed"에서 "off (home_direct_packed and xrecsys_seqpack)"로 바뀌었다. 부수 설명 문구도 "on the xrecsys_seqpack training config"에서 "and on every registered ranking config"로 갱신됐다. "and on"은 문법적 elision이고, 표와 합쳐 읽으면 "off everywhere"다. 즉 등록된 랭킹 훈련 config 두 개 모두에서 multimodal post 임베딩이 학습 입력에서 빠졌다. 두 config는 flagship home_direct_packed와 seqpack xrecsys_seqpack이다. 4세대+17(하루 전, 2026-09-08)이 v8-search 스택을 신설한 것과 함께 보면, v5를 랭킹에서 뗐고 v8-search는 아직 랭킹 config에 연결되지 않은 중간 상태로 읽힌다 추정. 다음 커밋에서 v8-search가 랭킹 config에 들어오는지 지켜본다.
phoenix/xrex/utils/gpu.py에 _parse_cpulist와 schedulable_cpus(*nodes) 헬퍼가 새로 생겼다.
pin_process_to_local_numa는 numa.memory.set_membind_nodes(node)를 실행한 뒤, os.sched_setaffinity(0, schedulable_cpus(node))로 isolated CPU를 제외한 노드 CPU만 스케줄 대상으로 정한다.
이전에는 numa.schedule.run_on_nodes(node)로 노드 전체를 잡아, isolated 코어에 스케줄될 여지가 있었다.
Phoenix inference 코드가 조금 바뀌었다. 바뀐 줄은 xrecsys.py(−1), model_runner.py(+2/−1), recsys_model.py(+5)다. 4세대+17의 ContentFeatures를 소비하는 코드를 연결한 것일 가능성이 남지만, 이번 커밋 진단만으로는 확정할 수 없다 추정.
공개되지 않은 것은 다섯 가지다.
Action::Downrank를 실제로 리턴하는 rule의 위치
FilteredReason::ViewerBlocksAuthor를 리턴하는 rule이 어떻게 연결되는지
header-image-prompt-ad가 5종 광고 블렌더 중 어느 것에 진입하는지
v8-search가 랭킹 config에 연결되는 시점
broadcast PTOS의 실제 재판정 규모
4세대+19 (2026-09-10, fee1d0f) — VF hydration timeout 통일 (80/100 → 390 ms) + TES 실패 시 AuthorId fallback cache + AuthorLabel 64-bit bitset + gRPC deadline 관측 + clock-cache primitive 신설(미배선) + ProductSurface·UserAction 신규 상수 + two-tower user tower padding segment ID + UTH 국가별 계정 takedown 배치 잡
이 커밋에서 VF가 TES와 Gizmoduck을 부를 때 Strato 층에서 거는 호출 timeout이 각각 100 ms와 80 ms에서 390 ms 하나로 늘었다 확인. 또 TES 조회가 실패해도 캐시에 남은 작성자 id가 있으면 작성자 기준 규칙은 계속 판정하고, 작성자 라벨은 판정에 쓰는 11개만 남기게 됐다 확인. 이 커밋은 2026-09-12 daily에서 다뤘다.
A. VF hydration timeout이 하나로 통일됐다. Strato per-call caller timeout이 TES 100 ms, Gizmoduck 80 ms에서 통합 390 ms로 올랐다.visibility-filtering/hydration/mod.rs에 pub(crate) const HYDRATION_TIMEOUT: Duration = Duration::from_millis(390);이 추가됐다. TES와 Gizmoduck 두 하이드레이터의 CLIENT_TIMEOUT은 이 공유 상수로 옮겨졌다. 이전에는 두 하이드레이터가 4세대 공개 이래 각자 150 ms 로컬 상수를 썼다. server_deps.rs의 Strato 클라이언트 설정 상수 두 개도 이 값을 따른다. TES_STRATO_REQUEST_TIMEOUT_MS(이전 100)와 GIZMODUCK_STRATO_REQUEST_TIMEOUT_MS(이전 80)가 이제 crate::hydration::HYDRATION_TIMEOUT.as_millis() as u64(=390)를 참조한다. 즉 두 백엔드에 대한 Strato 층 per-call caller timeout이 3~4배 늘어났다.확인 — visibility-filtering/hydration/mod.rs, visibility-filtering/hydration/gizmoduck_hydrator.rs, visibility-filtering/hydration/tes_hydrator.rs, visibility-filtering/server_deps.rs.
B. TES 조회가 실패하면 AuthorId fallback cache에서 작성자를 찾는다. 이 캐시는 기본으로 켜져 있고, 기본 크기는 1 M entries다.TesHydrator가 author_id_cache: Option<FallbackCache<TweetId, u64>> 필드를 갖게 됐다. fetch_pure_core는 이제 HashMap<TweetId, PureCoreData> 대신 PureCoreHydration { core: HashMap<TweetId, PureCoreData>, recovered_authors: HashMap<TweetId, u64> }를 리턴한다. TES 호출 결과가 tweet 하나에 대해 Hydrated::Found(core_data)면 (tweet_id, author_id)를 캐시에 기록하고 core도 채운다. NotFound나 Failed면 캐시에서 이 tweet의 이전 author_id를 조회한다. resolve_candidate가 이 결과를 소비하는 우선순위는 raw.request_author_id → core.get(&tweet_id).map(|c| c.author_id) → recovered_authors.get(&tweet_id)이다. 즉 TES가 죽었거나 응답이 없어도, 이 tweet의 이전 author_id가 캐시에 있으면 author-side rules(user label, 계정 상태)는 여전히 판정된다. 이때는 tweet-side rules(NSFW media, takedown reason 등)만 건너뛴다. 새 환경변수 VF_AUTHOR_ID_FALLBACK_ENABLED는 기본 true이고, is_none_or를 써서 "미설정 = 활성"으로 둔다. 새 환경변수 VF_AUTHOR_ID_FALLBACK_CAPACITY는 기본 1_000_000이다. 확인 — visibility-filtering/hydration/tes_hydrator.rs (+234/−42), visibility-filtering/models/mod.rs (+49/−9), visibility-filtering/config.rs (+14).
C. 작성자 라벨이 AuthorLabel의 11개 explicit variant와 64-bit bitset으로 바뀌었다. 기존 HashSet<LabelValue>는 빠졌다.models/author.rs의 UserLabelSet(HashSet<LabelValue>)가 AuthorLabelSet(u64)로 바뀌었다. 새 AuthorLabel enum은 다음 11개 variant만 명시한다: NsfwHighRecall·NsfwHighPrecision·NsfwNearPerfect·NsfwAvatarImage·NsfwBannerImage·SpamHighRecall·AbusiveHighRecall·Compromised·ReadOnly·ImpersonationHighPrecision·DoNotAmplify. 이 11개는 4세대+13(7ba7768)에서 규칙 상수로 정리된 OON_USER_LABEL_DROPS 규칙 11개와 정확히 1:1로 대응한다.AuthorFeatures는 #[derive(Clone, Copy, ...)]로 승격됐다. 크기는 정확히 16 바이트로 잠기고, assert size_of::<Option<AuthorFeatures>>() == 16이 이를 고정한다. Gizmoduck 하이드레이터의 author_features(user_result, counts)는 thrift LabelValue를 하나씩 author_label(LabelValue) 매핑으로 Option<AuthorLabel>로 좁힌다. 매핑되지 않는 label은 카운트만 올린다. 새 메트릭은 vf_author_labels{result="mapped"|"unmapped"}이고, 대시보드가 이 이름을 참조하는지 assert하는 테스트도 함께 붙었다. 즉 VF는 이제 이 11개 라벨만 판정에 쓰고, thrift LabelValue의 다른 값(예: NSFW_TEXT_HIGH_RECALL, NSFW_NEW_USER 등)은 인풋에서 명시적으로 버려진다. 이전에는 HashSet에 모두 담아 두고 rule이 조회할 때 존재 여부만 봤으므로, 판정에는 안 썼지만 메모리에는 들고 있었다. 확인 — visibility-filtering/models/author.rs (+49/−13), visibility-filtering/hydration/gizmoduck_hydrator.rs, visibility-filtering/rules/author_rules.rs (+30/−27), visibility-filtering/rules/context.rs.
D. VF가 gRPC deadline을 지켰는지 관측하고(filter_tweets_deadline{outcome=within|overrun|absent}), 요청 시간을 hydration과 post_hydration 두 phase로 나눠 잰다.filter_tweets.rs::FilterTweetsEndpoint::filter_tweets가 요청 metadata의 grpc-timeout 헤더를 파싱한다. 단위 H/M/S/m/u/n은 각각 시·분·초·밀리·마이크로·나노로 매핑한다. digits는 8자리 이하여야 하고, 그 밖의 형식은 None이 된다. 요청이 성공하면 RequestMetricsGuard::record_deadline(grpc_timeout)을 호출한다. record_deadline의 동작은 세 경우로 나뉜다.
elapsed <= deadline이면 counter를 outcome=within으로 올리고, filter_tweets_deadline_remaining_ms 히스토그램에 잔여를 기록한다.
초과했으면 outcome=overrun으로 올리고, filter_tweets_deadline_overrun_ms에 초과분을 기록한다.
timeout 헤더가 없으면 outcome=absent로 올린다.
새 메트릭 filter_tweets_phase_ms{stage="hydration"|"post_hydration"}은 filter.rs::FilterTweets::run에서 hydrated_at - started와 hydrated_at.elapsed()를 각각 관측한다. 즉 VF 서비스는 이제 다음 두 가지를 측정할 수 있다.
E. 32-way set-associative × 64-shard CLOCK 캐시 primitive인 clock_cache.rs가 새로 생겼지만, 아직 연결된 곳은 없다. 변경은 visibility-filtering/clock_cache.rs +446, visibility-filtering/lib.rs의 pub(crate) mod clock_cache; 등록이다. 파일 상단은 #![cfg_attr(not(test), expect(dead_code, reason = "TODO: no consumer wired yet"))]로 열려 있다. 캐시는 fixed-capacity이고, 키는 u64, 값은 V: Copy다. 구조는 3층으로, shard(64) × set(용량÷WAYS를 shard로 분산) × way(32) 순이다. insert(generation, key, value)는 이미 있는 슬롯을 덮어쓸 때 저장된 generation을 보고, 더 오래된 것이면 건너뛴다. 기존 슬롯이 없으면 CLOCK 알고리즘으로 evict한다. hand가 회전하면서 Referenced → Cold로 감쇠시키다가, Cold나 Empty를 찾으면 그 자리에 삽입하는 방식이다. begin_request()는 각 요청에 monotonic generation을 부여해서, 나중에 오는 요청이 오래된 캐시 값을 되살리지 못하게 막는다. 기존 FallbackCache(TinyLFU 계열, moka::sync::Cache)와는 별개의 primitive다. 이 캐시는 shard별 mutex와 fixed-slot 배열로 write throughput과 lock contention에 더 강한 새 hot-path 캐시다. 이번 커밋에는 소비자가 없으므로, 다음 커밋에서 어느 하이드레이터(TES author-id? Gizmoduck AuthorFeatures? SafetyLabel expiring cache?)의 백엔드로 연결되는지가 관측 대상이다. 확인 — visibility-filtering/clock_cache.rs, visibility-filtering/lib.rs.
F. Phoenix proto에 ProductSurface 두 개(TREND_PAGE=16, EXPLORE_SPOTLIGHT=17)와 UserActionAggregationType::IMMERSIVE_LONG_DWELL=29가 새로 생겼다. 바뀐 파일은 phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto와 phoenix/python/common/xai-proto/proto/recsys.proto다. 이전 PLACEHOLDER_16·PLACEHOLDER_17이 각각 PRODUCT_SURFACE_TREND_PAGE = 16, PRODUCT_SURFACE_EXPLORE_SPOTLIGHT = 17로 이름을 얻었다. Trend 지면과 Explore Spotlight 지면이 Phoenix ranking에 정식 편입되기 위한 proto 슬롯이 열렸다. 4세대+3에서 코드에 정리된 15개 지면 상수 뒤에 두 지면이 추가된 것이고, 실제 홈믹서 연결은 아직 없다. 또 UserActionAggregationType에서 PLACEHOLDER_29가 IMMERSIVE_LONG_DWELL = 29로 이름을 얻었다. 기존 IMMERSIVE = 26과 별개로 긴 dwell 시간을 갖는 immersive 상호작용을 별도 aggregation 유형으로 분리할 수 있게 됐다. Nano/flagship 학습 config가 이 새 aggregation을 어떤 시퀀스 파이프라인에서 소비하는지는 이번 커밋에 없다. 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto.
G. Two-tower user tower가 padding 위치에는 PADDING_SEGMENT_ID를, 실제 history 위치에는 HISTORY_SEGMENT_ID를 쓴다. 기존의 all-zero 대신 mask를 반영한다.phoenix/xrex/models/recsys_two_tower_model.py의 user_tower_segment_ids가 optional padding_mask: jax.Array | None 인자를 받도록 확장됐다. 이전 로직은 두 갈래였다.
use_history_segment_ids=True면 배열을 HISTORY_SEGMENT_ID로 완전히 채운다.
False면 jnp.zeros를 쓴다.
새 로직은 padding_mask가 주어지면 jnp.where(padding_mask, HISTORY_SEGMENT_ID, PADDING_SEGMENT_ID)로 mask 위치에 따라 두 값을 섞는다. _pool 함수의 실제 호출부가 이제 user_padding_mask를 넘기므로, 프로덕션 학습 경로가 이 새 갈래를 탄다. 즉 user tower의 attention·RoPE·attention bias가 padding 위치에서 history와 구분되어 계산된다. 4세대+15의 FA4 attention 커널 가변길이 히스토리 지원과 성격이 같은 정확성 보완이다. 확인 — phoenix/xrex/models/recsys_two_tower_model.py (+15/−4).
H. Home-mixer의 광고 injection 로그에 ads_blender 필드가 명시적으로 들어갔다.home-mixer/side_effects/ads_injection_logging_side_effect.rs가 AdsBlenderType 파라미터(feature-switch)를 조회해서 로그 payload에 ads_blender 문자열을 채운다. RequestType::Following이면 파라미터를 조회하지 않고 하드코딩된 "following"을 쓴다. AdsBlenderType param은 그 밖의 지면에서만 조회한다. 4세대+10(24c6094)은 다섯 광고 블렌더(safe_gap·partition_organic·partition_organic_low_risk·time_gap·multi_risk)를 도입했다. 이번에는 이어서 팔로잉 지면의 별도 블렌더 라벨 "following"이 로깅 축에 정식 편입됐다. 이것이 여섯 번째 블렌더인지, 아니면 팔로잉 지면은 다섯 블렌더 밖에서 별도 파이프라인을 타므로 로깅에만 라벨이 잡히는 것인지는 이번 diff만으로는 확정할 수 없다 추정. 코드 흐름에는 후자가 더 어울린다(param 조회를 건너뛴다는 것이 파이프라인이 다르다는 신호로 읽힌다) 추정. 확인 — home-mixer/side_effects/ads_injection_logging_side_effect.rs (+5/−1).
I. Under-The-Hood에 국가별 계정 takedown 일일 배치 잡 UthPctdAccountTakedownEventsJob이 생겼고, UTH 라벨 aggregate에 countryCodes 필드가 붙었다.under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala가 +143줄로 추가됐다. 이 잡은 두 데이터셋을 join한다. AuditServicePctdActionsScalaDataset은 User 타입 PCTD action 로그이고, UsersourceScalaDataset은 user 스냅샷의 takedown reason이다. join 결과 (userId, day, reason, country)는 매일 파티션된 파케이(daily_account_takedowns/YYYY/MM/DD)로 쓴다. Reason 우선순위는 LEGAL_REQUEST < BYSTANDER_REPORT < DMCA < UNSPECIFIED다. 같은 (userId, country)에 여러 reason이 잡히면 우선순위 값이 낮은 것을 남긴다. Cross-DC 스냅샷 접근(AllowCrossDC)을 허용한다. 기본 reducers는 50, 기본 usersourceSnapshotMaxAgeDays는 10, 테스트 유저 필터는 TestUserFilter.isTestUserId로 지원한다. uth_serving.thrift의 UthPostLabelAggregate와 UthAccountLabelAggregate 두 스트럭트에는 countryCodes: optional list<string> 필드(#3)가 붙었다. 즉 UTH 투명성 툴이 국가 차원의 takedown 기록을 새로 서빙할 준비를 시작했다. 어떤 라벨이 어떤 나라에서 언제 활성이었는지 유저에게 노출하는 계획으로 읽힌다. 4세대+12(2026-09-01, 6384ca7)에서 NSFW 게이팅 대상 16개국이 공개된 것과 짝을 이룬다. UTH URL(x.com/i/under_the_hood)에 국가별 필터가 등장하는지 지켜본다. 확인 — under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala, under-the-hood/thrift/uth_serving.thrift.
hydration/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) 헬퍼가 붙었다. 확인 — visibility-filtering/hydration/metrics.rs.
Grox motion-reveal 이미지 빌더의 프레임 상한이 32 → 48로 늘었다. ECC affine 정합, Farneback optical flow refinement, weighted median, LAB 스킨 색상 plausibility 게이트도 더해졌다. Grox의 motion-reveal 썸네일 생성(video → single frame 하이라이트)이 프레임을 더 많이 받고, 정합·denoise·색채 검증을 단계별로 통과시키도록 정제됐다. 확인 — grox/libs/video_tools/image.py (+288/−25).
_skin_plausible: 오버레이의 chroma 중앙이 스킨색 축 근처에 들어가는지 보는 게이트
home-mixer/params/param.rs는 mirror sync tick만 있었다. Feature-switch defaults 미러의 timestamp가 2026-09-09T16:23:17Z에서 2026-09-10T16:21:03Z로 갱신된 것 외에 실질 변경은 없다.
미공개: 다음은 공개되지 않았다.
clock_cache의 실제 소비자
TREND_PAGE와 EXPLORE_SPOTLIGHT 지면이 홈믹서의 어떤 후보·랭킹 경로로 접속되는지
IMMERSIVE_LONG_DWELL을 감지·집계하는 이벤트 정의
UTH 국가별 takedown이 유저 화면에 어떤 UI로 노출되는지
VF HYDRATION_TIMEOUT 390 ms가 실제 latency 분포에서 P99를 얼마나 늘렸는지
4세대+20 (2026-09-12, 6bb4594) — VMRanker DPP가 seed anchor를 pinning + Phoenix ScoredCandidate 로그가 Thrift→Proto 이중 발행 + VF client 발견이 xDS LDS listener 경유로 이관 + Phoenix proto LLM-judge search relevance action + Phoenix wire returnLogitsList + CompactAuthorFeatures 8-byte packed(미배선) + VF worldwide takedown 규칙 세밀화 + UTH report 월 상한 파라미터 + Phoenix 학습 Kafka mTLS 모드
이 커밋에서 VMRanker의 DPP는 요청에 기준 포스트(seed)가 오고 그 임베딩이 있으면, seed를 커널에 넣어 그와 가까운 후보의 이득을 깎되 결과에는 넣지 않게 됐다 확인. 안전 판정에서는 세계 단위 코드만 붙은 bystander 신고와 legal 저작권 요청이 더 이상 자동으로 걸러지지 않는다 확인. 이 커밋은 2026-09-13 daily에서 다뤘다.
A. VMRanker DPP가 seed_tweet_id를 pinned anchor로 쓴다. 이 seed는 top_k 예산을 소진하지 않는다.RankRequest에 새 필드 seed_tweet_id가 들어왔다. dpp_model::rank는 이 id로 임베딩 스토어(ctx.store.client)를 조회한다. 임베딩이 있으면 DppInput { id, score: 0.0, embedding, norm, embedding_missing: false }를 만들어 dpp::rescore의 새 seed: Option<&DppInput> 인자로 전달한다. rescore는 top-K candidate와 seed 하나로 total = m + 1 크기의 kernel을 구성한다. greedy 선택의 첫 슬롯은 seed 위치에 강제로 pinning한다(pinned_first: Option<usize>). 그 뒤 선택 결과에서 seed 인덱스는 걸러 내고 리턴한다. 즉 seed는 kernel 안에 앉아 다른 후보와의 코사인 유사도가 계산되면서, 자신과 가까운 후보의 결정성(determinantal) 이득을 깎는다. 하지만 최종 슬레이트에는 나타나지 않고 top_k 예산도 소진하지 않는다. 새 메트릭 vm_ranker_dpp_seed_context_total{result="pinned"|"embedding_missing"}은 seed 임베딩 hit와 miss를 따로 센다. 신규 테스트 seed_suppresses_near_duplicate와 seed_does_not_consume_top_k_budget이 이 두 계약을 잠근다. 확인 — vm-ranker/dpp.rs (+117/−40), vm-ranker/scoring/dpp_model.rs (+28/−1), vm-ranker/metrics.rs (+8).
이 커밋 전까지 VMRanker DPP는 집합 내 다양성(top-K 후보 사이 코사인 반발)만 최적화했고, 홈 타임라인의 blender는 그 결과를 그대로 배치했다. 이 커밋이 도입한 seed pin은 "보고 있는 tweet과 이질적인 후보를 우대"하는 문맥성 다양성이다 추정. permalink 페이지의 related tweets, 특정 tweet의 "more like this" 흐름, 스레드 컨텐츠의 대안 슬레이트가 자연스러운 소비처로 읽힌다 추정. 홈믹서 어느 파이프라인이 seed_tweet_id를 실어 주는지는 이번 커밋에 없다.
B. Phoenix ScoredCandidate 로그를 Thrift PredictionScore BTreeSet 형식과 Proto LoggedScoredCandidate HashMap 형식으로 이중 발행한다. Proto 쪽은 새 Kafka 토픽으로 나간다.home-mixer/side_effects/phoenix_experiments_side_effect.rs가 이제 후보 하나마다 두 개의 페이로드를 만들어, tokio::join!으로 두 토픽에 동시에 쓴다.
기존 Thrift 바이너리 ScoredCandidate는 PHOENIX_SCORES_TOPIC(cluster Aiml)로 간다.
신규 proto LoggedScoredCandidate는 LOGGED_SCORED_CANDIDATES_TOPIC(cluster Phoenix)로 간다.
Proto 페이로드의 prediction_scores는 map<string, double>이다. 그래서 Thrift의 BTreeSet<Box<PredictionScore>> 정렬 컬렉션과 달리 O(1) 조회가 가능하고, wire 크기도 정렬 인덱스 없이 훨씬 압축된다. 매핑 키는 phoenix.<cluster_name>.<action_name> 명명 규약을 그대로 유지한다. 키가 붙는 대상은 28개 참여 헤드와 pdwell 별칭이다. PhoenixCandidatePipeline의 생성자·new_mock·prod 조립 코드가 logged_scored_candidates_kafka_client를 6번째 Kafka publisher로 받도록 확장됐다. Thrift 스트림은 유지되고 Proto 스트림이 병렬로 붙었다. 다운스트림(랭킹 로그 소비자)이 언제 Proto로 전환하는지는 이번 커밋 밖이다. 확인 — home-mixer/side_effects/phoenix_experiments_side_effect.rs (+207/−54), home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs (+17/−1).
C. VF가 TES·Gizmoduck 클라이언트를 xDS LDS listener를 거쳐 찾도록 옮겼고, Strato 상수를 통일했다. 이전에는 visibility-filtering/server_deps.rs가 datacenter 템플릿 문자열(tweet-entity-service.tweet-entity-service.prod.{datacenter}.s2s.twttr.net)로 gRPC endpoint를 직접 잡았다. 또 각 서비스 crate의 TESClientConfig·GizmoduckClientConfig를 썼다. 이 둘은 aperture size, penalized peak EWMA / least-request P2C, readiness probe port 등을 담은 서비스별 config 구조체다. 이번 커밋은 이 모두를 지우고 공통 헬퍼 build_xds_strato(XdsStratoParams, deterministic_aperture)로 대체했다. 새 헬퍼는 xai_x_rpc::grpc_client::ChannelBuilder로 채널을 다음처럼 조립한다.
D. Phoenix proto에 LLM-judge search relevance action 두 개와 returnLogitsList 응답 형식이 생겼다.phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto와 파이썬 미러 양쪽에 다음 정의가 붙었다.
액션 두 개는 4세대+4(aad7179, 2026-08-19)의 xrecsys_search 검색 랭킹 config 확장선상에 있다. 이 커밋 전까지 검색 관련성 신호는 클라이언트 시그널(사용자가 결과를 선택하거나 건너뛴 것)뿐이었다. 이번 커밋으로 LLM judge의 관련성 판정이 별도 action 축으로 편입됐다. xrecsys_search config가 이 두 액션을 학습 타깃으로 삼아 검색 관련성 예측을 supervise한다는 것이 자연스러운 해석이다 추정. grox나 dedicated LLM judge가 (query, tweet) 쌍에 relevant·not 라벨을 흘려 주는 파이프라인이 어딘가에 있어야 한다 추정.
returnLogitsList는 응답 페이로드 형식이다. PredictRequestItem에 새 필드 return_logits_list: bool이 추가됐다. 이 필드를 켜면 python.rs::PredictRequestBatch가 return_log_map(sparse HashMap<u32, f32>) 대신 다음처럼 응답한다.
dist_entry.requested_action_logits = item
.requested_action_indices
.iter()
.map(|&idx| dist.get(idx as usize).copied().unwrap_or(f32::NEG_INFINITY))
.collect();
0~6번 비트: 7개 계정 상태 flag(is_suspended, is_deactivated, is_protected, is_nsfw_user, is_nsfw_admin, is_erased, is_offboarded)
8~62번 비트: 55-bit label bitset
Option<CompactAuthorFeatures> 크기가 정확히 8 바이트임을 assert한다(NonZeroU64 niche 최적화 활용).
의미: 4세대+19(fee1d0f, 2026-09-10)에서 AuthorFeatures가 이미 16 바이트 Copy 구조체로 축소됐다. 이번 커밋의 CompactAuthorFeatures는 그것을 다시 절반으로 줄이는 8 바이트 표현이다. 라운드트립을 모든 라벨·flag 조합에 대해 exhaustively 검증하는 테스트가 함께 붙었다(1<<7 × 1<<11 = 262,144 조합). 소비자가 아직 없으므로(#[allow(dead_code)]) 다음 커밋에서 hydration 파이프라인이나 캐시(4세대+19에 신설된 clock_cache가 가장 유력)의 값 타입으로 채택될 것으로 읽힌다 추정. 확인 — visibility-filtering/models/author.rs (+94/−2).
F. VF takedown 규칙이 worldwide 코드를 reason별로 나눠 처리한다.visibility-filtering/rules/context.rs가 TakedownPredicates::filter_by_country_or_worldwide의 worldwide 판정을 새 헬퍼 is_worldwide_code(cc) = "xx" OR "xy"(대소문자 무관)로 뽑아냈다. 그리고 예외 두 개를 추가했다.
legal_takedown_country: TakedownReason::LegalRequest { country_code }가 "xy"(WORLDWIDE_COPYRIGHT_COUNTRY_CODE)면 이제 None을 리턴한다. 즉 LegalRequest{"xy"}는 더 이상 legal takedown rule을 트리거하지 않는다. DMCA reason이 여전히 synthetic worldwide 코드로 "xy"를 리턴하는 것은 유지된다.
local_laws_takedown_country: TakedownReason::BystanderReport { country_code }가 worldwide 코드("xx" 또는 "xy")면 None을 리턴한다. 즉 BystanderReport{"xx"} 세계 규모 신고는 더 이상 local laws rule로 drop되지 않는다.
새 테스트 케이스 3개(legal_takedown_copyright_code_allows_without_viewer_country, unspecified_takedown_worldwide_drops_without_viewer_country, local_laws_takedown_worldwide_allows_any_viewer)가 이 세밀화를 잠근다. 이는 4세대+12(6384ca7, 2026-09-01)에서 국가 스코프 처리를 원본으로 도입한 이후 처음 나온 실질적 정책 완화다. bystander 신고와 legal-copyright 요청 가운데 world-wide 스코프만 붙은 것은 더 이상 자동 drop이 아니다. UnspecifiedReason은 worldwide이면 여전히 drop이 유지된다. 확인 — visibility-filtering/rules/context.rs (+14/−4), visibility-filtering/rules/golden_corpus.rs (+33/−3), visibility-filtering/rules/tweet_rules.rs (+22/−11).
G. Under-The-Hood report에 최대 월 상한 FS 파라미터와 employee 역할이 생겼다.under-the-hood/strato/lib/params.strato가 새 int 파라미터 under_the_hood_report_max_month_bucket(default 0 = 미사용)을 노출한다. underTheHoodReport.User.strato의 reportMonthBucket(now, maxMonthBucket)은 이 상한이 0보다 크면 계산된 calendar 월을 그 상한으로 클램프한다. userRoles Set에는 "birdwatch_team" 옆에 "employee"가 추가됐다. 즉 UTH 리포트의 표시 월을 운영자가 특정 값 이하로 잠글 수 있게 됐고, 직원 역할이 리포트를 볼 수 있는 대상에 포함됐다. 월 상한은 대규모 사고나 롤아웃을 준비할 때 데이터가 완성된 마지막 월에 고정하는 안전판으로 읽힌다. 확인 — under-the-hood/strato/lib/params.strato (+5), under-the-hood/strato/columns/underTheHoodReport.User.strato (+15/−7).
H. Phoenix 학습 파이프라인의 Kafka 인증에 mTLS 모드가 추가됐고, SASL과 자동으로 골라 쓴다.phoenix/xrex/data/streaming/kafkaconsumer.py와 kafkaloader.py가 새 KafkaAuth dataclass(mode="mtls"|"sasl")를 도입했다. auto_detect_auth는 다음 순서로 인증을 결정한다.
KAFKA_AUTH env var이 "sasl"이면 SASL을 강제한다.
KAFKA_MTLS_CERTS_DIR(기본 /root/client-certs)에 ca-bundle.crt·client.fullchain·client.key가 모두 있으면 mTLS를 쓴다.
client 인증서만 있고 CA가 없으면 platform CA bundle(/etc/ssl/internal-ca/ca-bundle.crt)이나 시스템 truststore로 대체해 mTLS를 쓴다.
계정 ID 163개가 새로 들어가고 10개가 빠져, 집합이 2,554개에서 2,707개가 됐다 확인 — 같은 파일. 새로 들어간 계정의 예: @VladimirAlves, @merisio, @LorenLuniere, @capitaoassumcao, @annavalente, @ademarmago, @henriquecatan, @jabscres, @yarahaquim, @ElySilveira, @MarceloFaro30, @freire_roberto, @felipececilio, @claudiojacoski
@rafaelcampelo → @RobertoOshiro 스왑 1건
"no live account found" 코멘트 신규 등재 다수
"not the candidate's actual account"·"no live account" 코멘트 정리 다수
VF client의 두 파일 visibility-filtering-client/discovery.rs(+11/−8)와 vf_client.rs(+1/−1)가 위 C의 xDS 이관에 맞춰 소폭 리팩터됐다. 정책 변화는 없다.
home-mixer/params/param.rs는 mirror sync tick만 있었다. Feature-switch defaults 미러 timestamp가 2026-09-10T16:21:03Z → 2026-09-11T16:22:45Z로 바뀌었고, 실질 변경은 없다.
Grox motion-reveal 이미지 빌더가 소폭 정비됐다. grox/libs/video_tools/image.py(+60/−55)가 4세대+19에 도입된 ECC·Farneback 파이프라인의 후속 정리로 리팩터됐다. 정책 변화는 없다.
미공개: 다음은 공개되지 않았다.
홈믹서 어느 파이프라인이 seed_tweet_id를 실어 주는지
LOGGED_SCORED_CANDIDATES_TOPIC의 다운스트림 소비자
xDS LDS listener의 실제 등록 정의
CompactAuthorFeatures의 실제 소비자
LLM_JUDGE_RELEVANT_TO_SEARCH·LLM_JUDGE_NOT_RELEVANT_TO_SEARCH는 4세대+23(42266f3, 2026-09-17)에서 5일 만에 vacate됐다. 그 자리에는 CLIENT_TWEET_LIVE_STREAM_REPLY=107·RESERVED_108이 들어갔다. 이 두 액션의 연결처를 묻는 질문은 이제 무효다.
4세대+21 (2026-09-15, 2d4a03c) — 응답 다양성 3단(pre-heuristic·top10·final) 관측 side effect + Phoenix 광고 continuous head ADS_WEB_CT_PURCHASE_VALUE + PurchaseValueBaseline 요청 필드 + TweetTypeMetrics 슬레이트 축 12개 bit(239-244, 315-320) + viewer positive-engagement 파서 + H100 attn 커널이 GB300과 동일한 CUTe DSL로 통일 + Kafka OTel export 30s → 120s + coordinated_spam follower 10k → 30k
응답 슬레이트의 다양성을 휴리스틱 층 적용 전, 상위 10개, 최종의 세 단계에서 나란히 재는 관측이 생겼고, 슬레이트 다양성과 작성자 문맥도 후보별 bit로 기록된다 확인. 광고 쪽에는 웹 클릭 뒤 구매 금액을 회귀 target으로 삼는 head가 생겼고, 안전 쪽에서는 coordinated spam 필터의 팔로워 임계가 10k에서 30k로 올랐다 확인(daily).
A. 응답 슬레이트의 다양성을 세 단계에서 나란히 재는 관측 side effect가 생겼다.
새 ResponseDiversityStatsSideEffect는 pre_heuristic, top10, final 3단을 병렬로 관측한다. 기본 표본 비율은 5%이고, 실험 버킷에서는 100% 실행된다.
구현 파일은 home-mixer/side_effects/response_diversity_stats_side_effect.rs(186줄 신규)다. 이 side effect는 Phoenix candidate pipeline에 6번째 관측 side effect로 연결됐다.
활성 조건은 두 갈래다.
기본값은 DEFAULT_SAMPLING_RATE = 0.05다.
새 feature-switch param EnableResponseDiversityStatsExperimentBucket이 실험 버킷을 잡으면, 그 버킷에서는 100% 실행된다. 이때 각 지표에 ddg=<실험 id>·bucket=<버킷 id> 스코프 태그를 붙여 발행한다.
세 stage는 보는 범위가 다르다.
pre_heuristic: selected_candidates와 non_selected_candidates를 합쳐 weighted_score 내림차순으로 상위 K개를 잡는다. 이 점수는 head-weight sum 원점수이고, DPP·author-decay·OON 배수를 적용하기 전의 값이다. 이 stage는 "heuristic 층 없이 랭커 원점수 top-K를 그대로 냈다면"의 counterfactual이다.
top10: 최종 슬레이트를 score(post-heuristic)로 정렬한 상위 10개다.
final: 슬레이트 전체다.
stage마다 histogram 지표를 발행하며, 이름은 모두 ResponseDiversity.*로 시작한다.
의미: heuristic 층이 랭커 원점수에 비해 슬레이트를 얼마나 바꾸는지는 지금까지 계량되지 않았다. pre_heuristic counterfactual은 이 차이를 실측할 수 있게 만든 첫 연결이다.
B. 다중집합 통계를 계산하는 공용 primitive가 생겼다.
새 Composition은 다중집합 통계(unique·max·HHI·normalized entropy)를 O(n)으로 계산한다. 구현은 home-mixer/util/composition.rs(63줄 신규)에 있다. Composition::from_keys<K: Hash + Eq>(iter) 하나로 어떤 축이든(작성자·served_type·SID L1/L2) 통계를 뽑는다. A의 ResponseDiversityStatsSideEffect와 C의 TweetTypeMetricsHydrator 신규 bit 산출, 두 곳에서 재사용된다. 확인 — home-mixer/util/composition.rs, home-mixer/util/mod.rs (+2).
C. 후보별 bitset에 슬레이트 전역·문맥을 나타내는 bit 12개가 생겼다.
TweetTypeMetrics bitset에 슬레이트 축 bit 239-244와 315-320이 새로 정의됐다. home-mixer/candidate_hydrators/tweet_type_metrics_hydrator.rs(+100/−4)에는 두 함수가 추가됐다.
author_diversity_bits(authors: &Composition) -> HashSet<usize>는 슬레이트 전역 bit를 만들고, 모든 후보에 똑같이 적용된다.
slate_position_bits(query, candidates) -> Vec<HashSet<usize>>는 후보마다 다르게 적용된다.
hydrate에서 두 bit set은 후보별 base bitset과 union으로 합쳐진 뒤 압축돼 tweet_type_metrics payload로 저장된다. 새 predicate는 home-mixer/util/tweet_type_metrics.rs(+27)에 정의됐다.
AUTHOR_REPEAT_IN_SLATE = 315, AUTHOR_REPEAT_GTE_3_IN_SLATE = 316: 같은 author_id가 두 번째, 세 번째 이상 등장할 때부터 켜진다. 첫 등장에는 켜지지 않는다. 표시하는 것은 "이 tweet은 같은 작성자의 몇 번째 등장인가"다.
AUTHOR_NOT_ENGAGED_BY_VIEWER = 317: 후보 작성자를 뷰어가 팔로우하지 않고, 뷰어의 positive-engagement set에도 그 작성자가 없다는 뜻이다.
HAS_SEMANTIC_IDS = 318
SID_L1_REPEAT_IN_SLATE = 319, SID_L2_REPEAT_IN_SLATE = 320: 같은 SID L1(L2) prefix가 슬레이트에 이미 있는 두 번째 이상 후보에서 켜진다.
새 helper는 PostCandidate::semantic_id_prefix(levels: usize) -> Option<&[i32]>다(home-mixer/models/candidate.rs +8). 이 helper는 SID 배열에서 상위 levels 코드 슬라이스를 안전하게 잘라 낸다.
새 함수 viewer_history::positively_engaged_author_ids는 Arrow IPC 스트림에서 positive-engagement 작성자 집합을 추출한다. 파일은 home-mixer/util/viewer_history.rs(61줄 신규)다. 이 함수는 요청의 ScoredPostsQuery.columnar_scoring_sequence(유저 최근 상호작용 이력의 Arrow columnar batch)를 연다. 그중 authorId > 0이고, actionNameMultiHot FixedSizeList에서 아래 18개 positive action 가운데 하나 이상이 켜진 행의 authorId를 모아 반환한다.
리포트, 뮤트, 블록, not_dwelled 같은 부정 신호와 dwell·quality view 같은 노출성 신호는 의도적으로 제외됐다(video quality view는 명시적으로 positive). 이 세트는 X 공개 코드의 다른 어느 곳에서도 나온 적이 없는 새 상수 집합이다. "적극적 참여로 관계가 형성된 작성자"의 정의를 처음 코드로 고정한다.
C의 AUTHOR_NOT_ENGAGED_BY_VIEWER bit 하나가 이 함수의 소비자이지만, "뷰어와 관계 형성된 작성자" 개념 자체가 다른 파이프라인(콜드스타트, cold author fatigue 감쇠 등)에서 재사용될 수 있어 별도 utility로 분리된 것으로 읽힌다 추정. 확인 — home-mixer/util/viewer_history.rs.
E. Phoenix proto에 광고 웹 클릭 뒤 실제 구매 금액을 예측하는 head와 요청 필드가 생겼다.
새 continuous head는 ADS_WEB_CT_PURCHASE_VALUE = 5이고, 새 요청 필드는 PurchaseValueBaseline이다. 이 head는 ContinuousActionName enum의 다섯 번째 continuous head다. 기존 4개는 DWELL_TIME=1·CLICK_DWELL_TIME=2·ACTIVE_SECS_5M_RESIDUAL_NORM=3·BRIDGE_PROBABILITY=4다. 새 head의 회귀 target은 웹 클릭-스루 컨버전 후 발생한 구매 금액이다(CT = Click-Through).
각 baseline은 (impression_id, advertiser_account_id, 광고주의 최근 28일 평균 구매 금액 USD) 튜플이다. Rust 헬퍼 phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs(114줄 신규)도 함께 들어왔고, 다음 셋을 담고 있다.
is_valid_baseline: 기준은 positive id와 positive finite USD다.
F. two-tower 설정의 H100 학습 attn 커널이 GB300과 같은 커널로 통일됐다.
phoenix/xrex/configs/xrecsys_two_tower.py의 _H100_OVERRIDES.attn_impl이 pallas_ranker_varlen_attn에서 cutedsl_ranker_varlen_attn으로 바뀌었다. cutedsl_ranker_varlen_attn은 4세대 공개(47c1bcd)부터 _GB300_OVERRIDES에 있었다(xrecsys.py:308). 2026-09-08 커밋(4세대+17, 49815da)에서는 SM80·SM90 forward·backward 커널 파일이 phoenix/xrex/cutedsl/ranker_fa4/에 새로 공개됐다(49815da). NVIDIA compute capability 기준으로 SM80은 A100(Ampere), SM90은 H100(Hopper)이고, GB300은 Blackwell 세대다. 이번 변경은 SM90 커널이 공개된 뒤 H100 설정도 GB300 설정과 같은 CUTe DSL 구현을 쓰게 한 후속 조치로 본다 추정. GB300과 H100 두 GPU generation의 학습 code path가 이제 attn 층 시맨틱까지 같아졌다. 4세대+11(bc8e5f0)에서 관측된 seqpack과 FA4 조합의 NotImplementedError 제약이 이 커밋으로 풀렸는지는 확인되지 않았고, 후속 관측 대상이다. 확인 — phoenix/xrex/configs/xrecsys_two_tower.py (+1/−1).
_init_otel_metrics의 default export_interval_ms와 PhoenixKafkaDataset.metrics_export_interval_ms가 둘 다 30_000 → 120_000으로 바뀌었다. OTel 지표를 4배 덜 자주 내보낸다. 학습 잡의 관측 tail 분해능은 낮아지는 대신, collector 부담은 줄어든다. 이 변경은 4세대+20(6bb4594, 2026-09-12)의 H인 Kafka mTLS 연결과 짝으로 읽힌다. mTLS는 인증 오버헤드를 늘리고, export 완화는 지표 부하를 줄인다. 지표 host cost가 실측에서 부담이 됐다는 방증이다 추정. 확인 — phoenix/xrex/data/streaming/kafkaloader.py (+2/−2).
H. Grox의 coordinated spam 필터가 팔로워 임계를 10k에서 30k로 올렸다.
바뀐 값은 grox/flows/reply_spam/task_filter.py에 있는 TaskCoordinatedSpamFilter의 FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION 상수다. Grox reply-spam 파이프라인에는 팔로워 임계가 두 개 따로 있다.
TaskSpamFilter·TaskReplyRankingFilter: 15k → 30k(4세대+1) → 40k(4세대+2) → 60k(4세대+3) → 80k(4세대+6) → 100k(4세대+7) → 120k(4세대+8) → 150k(4세대+17)로 일곱 번 상향됐다. 올릴수록 스팸 판정은 더 큰 계정의 스레드까지 넓어지고, 답글 랭킹 채점은 그만큼 좁아진다 확인 — task_filter.py:77-92, 214-225.
TaskCoordinatedSpamFilter: 1k에서 5k(4세대+15), 10k(4세대+17)를 거쳐 이번에 30k가 됐다. 스레드 root의 팔로워가 임계보다 적으면 low_blast_radius로 건너뛰므로, 상향은 작은 계정이 root인 대화를 판정 대상에서 빼는 쪽이다 확인 — task_filter.py:157-162.
home-mixer/params/param.rs의 feature-switch defaults 미러 timestamp가 2026-09-11T16:22:45Z → 2026-09-14T16:22:15Z로 바뀌었다(mirror sync tick). 실질 변경은 위 A의 EnableResponseDiversityStatsExperimentBucket 신설이다.
미공개: 아래 항목은 이번 커밋에서 드러나지 않았다.
ADS_WEB_CT_PURCHASE_VALUE가 어떤 학습 config·loss term에 등록되는지(baseline offset vs raw dollar)
PurchaseValueBaseline 매일 갱신 파이프라인의 위치
TweetTypeMetrics 신규 12개 bit(239-244, 315-320)의 학습 config 활성 시점
ResponseDiversityStatsSideEffect의 실측 활성 실험 결과와 pre_heuristic vs final 갭
positively_engaged_author_ids의 18개 action 세트가 phoenix/runners.py positive engagement head 정의와 정확히 매칭되는지
seqpack과 FA4 조합 제약의 해소 여부
4세대+22 (2026-09-16, fad2f71) — Phoenix 광고 구매액 loss 정식 배선 + binary continuous loss type 신설(첫 사용 = click-dwell BCE, default ON) + XAUC clicked-only 메트릭 + user_gender 카테고리컬 인덱스 침묵 재매핑 + H100 attn override CUTe DSL flip + VMRanker DSCP + EnableAdsBrandSafetyVerdictV2 롤백 + grox 리플라이 스팸 모델 스레드-follower 이원화
클릭 뒤 dwell 시간 head를 '10초 이상 머물렀는가'의 이진 분류로 학습하는 설정이 기본으로 켜졌고, 서빙에서 이 head는 확률 그대로 남는다 확인. 광고 구매액 head는 학습 loss까지 이어졌지만 가중치 기본값이 0이라 기본 설정에서는 꺼져 있고, 광고 브랜드 안전 V2 판정 스위치의 미러 값은 false로 돌아갔다 확인(daily).
ADS_WEB_CT_PURCHASE_VALUE Huber ratio loss가 정식으로 연결됐다(가중치 기본 0, 옵트인). 4세대+21에서 wire(proto·요청 필드·Rust 헬퍼)만 열렸던 광고 구매액 continuous head가 이번에 학습 loss까지 이어졌다. phoenix/xrex/models/loss_recsys.py(+91)에 두 함수가 신설됐다.
purchase_value_valid_mask는 유효 mask를 여덟 항의 AND로 만든다.
식은 label_valid & padding & !negative_sample & delayed_mask & has_click & has_purchase & keeper_mask & (isfinite && positive) on ratio·baseline이다.
표본이 loss에 들어가려면 라벨이 실측이고, 패딩이 아니고, negative sampling 표본이 아니어야 한다.
또 지연 보상 창이 닫혀야 한다(delayed_mask가 True면 라벨 수렴 완료).
실제로 클릭했고 실제로 구매까지 갔으며, 학습 대상 후보(trained_candidate_mask)여야 한다.
4세대+24(2026-09-18) 갱신: delayed_mask 항이 sample_source > 0에서 sample_source == EARLY_RELABEL_STREAM_ID로 좁혀졌다. Late-split 스트림에서는 이 loss가 학습되지 않는다. Stat 이름도 purchase-value-*에서 purchase-value_delayed_clicked-*로 리네임됐다.
B. 새 continuous loss type이 생겼고, 클릭 뒤 dwell 시간 head가 이를 기본으로 쓴다.
새 type binary는 sigmoid, BCE, threshold의 조합이다. 첫 사용처는 click_dwell_time이고, threshold=10s로 기본 학습이 켜졌다. ContinuousActionLossConfig.loss_type은 기존 {mse, mae, huber, tweedie}에 더해 binary를 받는다. 새 필드는 binary_threshold: float = 10.0이다. 활성화 함수는 assert로 sigmoid가 강제된다. output_cap은 확률 [0,1]이므로 무의미하고, assert로 금지된다.
새 함수 binary_threshold_loss_compute는 원 continuous 값 gt_raw를 threshold 기준으로 {0,1}로 이진화한다(gt_binary = (gt_raw > threshold)). 그다음 sigmoid binary cross-entropy를 계산한다. mask는 기존 continuous loss와 같은 valid_mask & !negative_sample_mask 게이팅을 쓴다.
decode_continuous의 signature가 jax.Array → (jax.Array, dict[int, jax.Array])로 변경됐다. 두 번째 반환값 head_logits_by_index가 이번 커밋의 배관이다. binary loss에는 sigmoid 이전의 raw logit이 필요하기 때문에(sigmoid와 BCE를 logit에서 함께 계산하면 numerically stable하다), binary-typed 헤드 인덱스마다 raw logit tensor를 함께 반환한다. Loss 루프는 head_logits_by_index[loss_config.action_index]로 이 logit을 소비한다. 서빙 forward path의 반환값(비학습 경로)도 3-tuple로 바뀌지만, head_logits는 서빙에서 사용하지 않고 버려진다.
xrecsys.py의 default _home_direct_packed_base에서는 기본값 두 개가 바뀌었다.
click_dwell_loss_weight: 1.0: mparams 기본값을 1.0으로 정했다. 이 커밋 전에는 공개 학습 코드(phoenix/xrex)에 click-dwell 손실 설정 자체가 없었다 확인 — 2d4a03c grep. 이제 "클릭 후 60초 창 중 10초 이상 dwell 여부"의 이진 분류가 default 학습 target이다.
새 ContinuousActionLossConfig가 append됐다: action_index=CLICK_DWELL_TIME(=2), metric_name="click-dwell-binary".
나머지 설정은 loss_weight=mparams.get("click_dwell_loss_weight", 0.0), loss_type="binary"다.
enable_click_dwell_time: True(feature-prep): 히스토리 인코더에서 click_dwell_time 히스토리 특징이 기본으로 켜졌다. recsys_feature_prep.py(+12)에서는 enable_click_dwell_time=True이면 history_seq.continuous_actions[..., 2](slot 2)를 정규화한다. 식은 log1p(clip(x, 0, 60)) / log1p(60)이다. 이 값을 hist_click_dwell_time_vec 임베딩과 곱해 히스토리 벡터에 더한다. 기본값은 click_dwell_time_norm_scale=60.0이다.
의미: 첫째, click_dwell_time 헤드의 학습 target이 이진 분류로 처음 생겼다. 목표는 "10초 이상 dwell할 확률"이다. Netflix N-tower·YouTube dwell 계열은 "dwell 여부(binary)"와 "dwell time(regression)"을 별도 head로 유지하는데, X는 이와 대비되게 하나의 head를 이진 분류로 통일했다. 둘째, 히스토리 인코더가 유저 이력의 클릭 후 실 dwell 시간을 이제 참조한다(이전에는 enable_dwell_time만 있었다). 셋째, 서빙과 학습의 대칭성이 흔들린다. _rescale_sigmoid_heads_for_inference는 sigmoid 헤드에 norm_scale을 곱해 원단위로 되돌린다. 하지만 binary loss는 이 재스케일에서 명시적으로 제외된다(if loss_config.loss_type == "binary": continue). 서빙 시 이 헤드는 확률 [0,1] 그대로 남는다. 랭킹 스코어러는 ContClickDwellTimeWeight(param.rs)를 이 확률에 곱한다. 이 가중은 이 커밋 시점에 0.0이었고, 2026-09-29 커밋(4세대+31)에서 0.4가 됐다 확인(가중치 표). 확인 — phoenix/xrex/models/loss_recsys.py, phoenix/xrex/configs/xrecsys.py (+13/−1), phoenix/xrex/models/recsys_feature_prep.py (+12).
C. 클릭된 후보끼리의 순서 품질을 재는 XAUC 메트릭이 생겼다.
XAUC는 clicked-only pairwise concordance 메트릭이고, 새 함수는 metric_xauc(pred, gt, valid_mask, max_samples=2048)다. 값을 flat reshape한 뒤 stride 샘플링(stride = max(N // 2048, 1))으로 최대 2048개까지 줄인다. 그다음 2048×2048 outer difference 행렬로 pairwise concordance를 계산한다.
xauc_mask = _cont_loss_mask & (xauc_gt > 0)로 positive-only 표본만 쓴다. 즉 클릭이 발생한 후보끼리의 순서 품질만 잰다.
Stat 이름은 {metric_name}-xauc-clicked다.
binary loss type에서는 xauc의 gt가 clip(gt_raw, 0, norm_scale)로 원단위를 유지한다. 이진화 이후가 아니라 이전 값으로 재구성해서 sample별 rank 신호를 유지한다. binary 헤드에서는 RCE(relative cross entropy)와 ratio-pos도 추가로 발행한다.
의미: dwell·purchase-value 같은 continuous 헤드의 랭킹 품질을 예측값의 magnitude가 아니라 순서 concordance로 잰다. 광고 value optimization에서는 "매출 예측 절대값의 정확도"보다 "매출 큰 순서를 맞히는 능력"이 옥션 성능과 직결되기 때문이다. Stride 샘플링이라 랜덤이 아니라 시퀀스 패킹 순서에 종속된 편향이 있다. 그래도 각 학습 스텝에서 2048² pair를 초과해 계산하지 않겠다는 예산 제한이 우선이다. 확인 — phoenix/xrex/models/recsys_model.py.
D. 서빙에서 sigmoid 헤드의 예측을 원단위로 되돌리되, binary 헤드는 제외한다.
이 일은 _rescale_sigmoid_heads_for_inference가 맡는다. 서빙 forward 경로에서 continuous_action_losses 가운데 activation == "sigmoid" && loss_type != "binary"인 헤드의 예측값에 norm_scale을 곱해, 학습 시 정규화 이전의 스케일로 되돌린다. 즉 학습에서 log/scale 정규화된 continuous 라벨을 sigmoid로 회귀했다면, 서빙에서 원단위로 다시 늘려 준다. Binary 헤드는 예외로 확률 [0,1] 그대로 남는다. 확인 — phoenix/xrex/models/recsys_model.py.
E. 성별 카테고리컬 임베딩의 인덱스 매핑이 예고 없이 바뀌었다.
user_gender 인덱스가 Male=2, Female=1, other=0 하드코딩에서 proto enum 정수를 그대로 쓰는 방식으로 바뀌었다. 파일은 phoenix/crates/common/xai-recsys/src/util.rs(+17/−5)다.
// before
categorical_features[USER_GENDER] = match pb::Gender::try_from(ctx.user_gender) {
Ok(pb::Gender::Male) => 2,
Ok(pb::Gender::Female) => 1,
_ => 0,
};
// after
categorical_features[USER_GENDER] = ctx.user_gender as i16;
새 회귀 테스트 user_gender_is_raw_proto_enum은 세 값(Male·Female·Unknown)이 g as i16와 정확히 일치해야 한다는 것을 고정한다.
의미: proto Gender enum이 UNKNOWN=0, MALE=1, FEMALE=2(typical proto 관행)라면 Male과 Female의 임베딩 인덱스가 swap된다. 이전에 학습된 임베딩 테이블은 인덱스가 어긋난 채로 재해석되므로, 재학습 전에는 서빙에서 성별 시그널이 부호가 뒤바뀐다. 또 이전에는 non-binary 값이 무조건 0으로 뭉쳐졌지만, 이제는 proto enum이 정의한 인덱스를 그대로 갖는다. 오픈소스 미러 시점에서 proto 정의를 다시 확인할 필요가 있다. 이런 silent embedding index change에서는 재학습이 병행됐는지가 관심사다. 4세대+22 자체는 이 마이그레이션을 감안한 학습 리셋을 강제하는 코드 변경이 아니어서, 실측 배포 시점이 관측 대상이다 추정. 확인 — phoenix/crates/common/xai-recsys/src/util.rs.
F. 랭킹 config의 H100 학습 attn 커널 override도 최종적으로 CUTe DSL 커널로 바뀌었다.
phoenix/xrex/configs/xrecsys.py의 _H100_OVERRIDES.attn_impl 값이 pallas_ranker_varlen_attn에서 cutedsl_ranker_varlen_attn으로 바뀌었다. 4세대+21(2d4a03c)에서 xrecsys_two_tower.py의 H100 override는 이미 CUTe DSL로 통일됐다. 이번에는 xrecsys.py(랭킹 config 계보)의 override까지 cutedsl_ranker_varlen_attn으로 정리했다. 4세대+15(9b0dc31)에서 H100용 SM80 forward·backward가 오픈소스화됐고, 그 뒤로 두 config 계보 모두 H100 커널 override가 GB300과 같아졌다. 확인 — phoenix/xrex/configs/xrecsys.py (+13/−1).
G. VMRanker gRPC 클라이언트에 DSCP=InferenceCritical이 설정됐다.
home-mixer/clients/vm_ranker_client.rs(+2/−1)의 LoadBalancedChannel::builder 체인에 .dscp(Some(Dscp::InferenceCritical)) 한 줄이 추가됐다. DSCP(Differentiated Services Code Point)는 IP 헤더의 QoS 필드로, 네트워크 경로에서 트래픽 분류·우선순위 지정에 쓰인다. InferenceCritical은 이번 커밋에서 처음 등장했다(다른 클라이언트는 미확인).
의미: VMRanker 호출을 데이터센터 네트워크의 최우선 tier로 태그해, 혼잡이 생겨도 지연 팽창을 억제하겠다는 설정이다. 최근 4세대+8에서 VMRanker 경로가 rank server 결과를 그대로 통과하는 얇은 층으로 정리된 것과 짝을 이루며, VMRanker가 "홈 랭킹의 tail latency에 크리티컬"하다는 판단이 배포됐다는 방증이다 추정. 확인 — home-mixer/clients/vm_ranker_client.rs.
H. 광고 브랜드 안전 V2 판정 게이트가 미러에서 롤백됐다.
home-mixer/params/param.rs의 feature-switch mirror에서 EnableAdsBrandSafetyVerdictV2가 true → false로 바뀌었다. 그동안 V2는 프로덕션 판정으로 편입되는 흐름에 있었다. 4세대+2(2026-08-17)에서 compute_verdict_v2가 신설됐고, 4세대+7(2026-08-24)에서 BrandSafetyVerdict::HighRisk가 신설됐다. 이번 전환은 그 확장이 실측에서 문제를 냈거나 재검토 단계에 있음을 시사한다. 4세대+7(2026-08-24)의 ads_injection_logging_side_effect.rs가 이미 EnableAdsBrandSafetyVerdictV2 실험 버킷을 로깅에 노출했었으므로, 버킷별 실측 지표가 V1으로의 회귀를 낳았을 개연성이 있다 추정. Mirror sync tick도 2026-09-14T16:22:15Z → 2026-09-15T16:25:02Z로 갱신됐다. 확인 — home-mixer/params/param.rs (+2/−2).
I. Grox 리플라이 스팸 분류기가 스레드 팔로워 수에 따라 모델을 둘로 나눠 쓰고, task 임계도 올렸다.
세 변경이 함께 들어왔다.
grox/flows/reply_spam/constants.py(+3)에 상수가 생겼다.
GEMMA_REPLY_SPAM = "oai-gemma4-26b-reply-spam": 전용 파인튜닝 변종으로 추정된다.
GEMMA_REPLY_SPAM_MIN_FOLLOWERS = 150_000: 모델 라우팅 임계
GROK_GEMMA_FOLLOWER_SPLIT = 180_000: task 자격 임계
grox/flows/reply_spam/classifier_simple_reply_scorer.py(+22/−4)에서 SimpleReplyScorer는 oai_gemma4와 oai_gemma4_reply_spam 두 sampler를 갖는다.
_sampler(post)는 스레드 root인 post.ancestors[0]과 스레드 tail인 post.ancestors[-1]의 팔로워 max를 본다. 이 값이 150k 초과면 전용 oai_gemma4_reply_spam을, 그 이하면 default oai_gemma4를 선택한다.
즉 화제 큰 스레드일수록 전용 파인튜닝 모델로 라우팅된다.
grox/flows/reply_spam/task_filter.py(+3/−2)에서는 두 task 임계가 모두 150000 → GROK_GEMMA_FOLLOWER_SPLIT(180000)으로 바뀌었다.
대상은 TaskSpamFilter.FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION과 TaskReplyRankingFilter.FOLLOWER_COUNT_THRESHOLD_FOR_REPLY_RANKING이다.
직전 값은 2026-09-08 커밋(4세대+17)의 150k다. 4세대+7의 100k에서 보면 80% 상향이다.
두 임계가 서로 다르다는 사실이 중요하다.
task 자격은 180k다. 답글 대상과 스레드 root의 팔로워가 모두 180k 이하인 스레드만 스팸 판정 파이프라인에 들어간다 확인 — task_filter.py:78-85.
J. Grox가 LLM에 넘기는 인용 포스트 문구에 인용 대상의 handle과 자기 인용 표시를 붙인다.
grox/core/lm/post.py(+10/−1)에 by @handle 표기와 self-quote 마커가 들어왔다. LLM 입력으로 넘어가는 스레드 렌더러가 인용 포스트의 시작 문장을 이렇게 개선한다.
인용 대상 유저의 handle이 있으면 "This Post quotes Post <id> by @<handle>; the quoted content below is embedded within this Post"로 쓴다.
인용자가 인용 대상과 같은 handle이면 "...by @<handle> (this Post's author, quoting their OWN post)"처럼 표시를 추가한다.
의미: 리플라이 스팸, PtOS 같은 grox 판정기가 "인용이지만 실은 자기 인용(self-QT)"과 "타인 인용"을 의미상 구분하도록 프롬프트 층에서 힌트를 준다. self-QT는 스팸 트래픽에서 관찰되는 패턴 중 하나이므로(리치 확대 목적의 자기-부스팅), 이 마커가 판정 정확도를 올릴 것으로 기대된다. 확인 — grox/core/lm/post.py (+10/−1).
K. AES의 판정 outcome이 감사 추적용으로 스코어 실행 식별자를 함께 싣는다.
AES decision_outcome에 score_id를 전달하는 변경이고, 바뀐 곳은 abuse-enforcement-service/service-lib/src/lib.rs(+20/−1)다. decision_outcome()은 ScoreResult.score_id(스코어 실행 식별자)를 outcome JSON payload에 그대로 넣어 emit한다. 테스트 두 개가 이 동작을 고정한다. decision_outcome_json_mirror_carries_funnel_fields에 score_id 필드 assertion이 추가됐고, legacy 빈 문자열도 통과한다.
대상 enum: RequestOutcome·SourceOutcome·CacheTier·CacheResult·ManhattanResult·BatchStage·ConnEvent·HydratorOutcome·SafetyLevel·LabelSource·FallbackReason·FailureKind·WarmKeyResult
그 자리는 #[derive(strum::IntoStaticStr)]와 #[strum(serialize_all = "snake_case")]가 대신한다. 호출 지점은 .as_str()에서 .into() 또는 <&str>::from(...)로 바뀌었다. 순수 리팩터로, 관측 지표 라벨 문자열은 변함없다(직렬화 규칙이 snake_case로 동일). 확인 — visibility-filtering/safety_label_source/metrics.rs.
미공개: 아래 항목은 이번 커밋에서 드러나지 않았다.
purchase_value_loss_weight가 mparams에서 실제로 0 이상으로 설정되는 config의 등장 시점
enable_click_dwell_time=True와 click_dwell_loss_weight=1.0 기본값 전환에 맞춰 서빙 랭킹의 ContClickDwellTimeWeight 값이 조정됐는지(현재 param mirror는 무변화)
4세대+23 (2026-09-17, 42266f3) — FavHoldoutFilter 신설 + click-dwell 스코어링이 저조 fav 페널티 → dwell × click 곱으로 재정의 + cold-start Treatment 팔이 MoE 게이트 제거·max age 24h→48h + Phoenix proto LLM_JUDGE_* 4일 만에 vacate하고 CLIENT_TWEET_LIVE_STREAM_REPLY 신설 + 광고 delayed conversion Tier 2/3(20종) 추가 + VF 자체 twemcache(2900줄) 제거·통합 xai_cache 이관 + VF evaluate_tweets RPC와 Thrift Action 응답 디코더 신설 + UTH 일일 takedown 라벨 재현 인프라
이 커밋으로 홈믹서에 포스트의 fav(좋아요) 수 구간에 따라 최대 15%의 뷰어에게서 그 포스트를 빼 두는 홀드아웃 필터가 생겼고, 랭킹의 저조 fav 페널티 자리에는 체류 예측에 클릭 예측을 곱하는 옵션이 들어왔다. 새 필터와 곱 옵션은 둘 다 기본값이 꺼져 있다 확인(daily).
변경은 +2288/−3627, 파일 64개이고, 삭제가 크게 많은 것은 VF twemcache 서브트리(2,900줄 남짓)를 전부 지웠기 때문이다 확인 — xai-org/x-algorithm@42266f3.
fav_count <= 1이면 0%로, 뷰어 전원에게 노출된다. 그 위로는 계단식으로 올라 10K+ fav에서 최대 15%가 된다. 홀드아웃 판정은 InventoryHoldoutFilter::is_held_out(post_id, viewer_id, percent)을 재사용한다. 이 함수는 splitmix64 이중 mixing으로 (post_id, viewer_id)의 100-modular 버킷을 구하고, 그 값이 < percent이면 홀드아웃한다. 즉 같은 뷰어에게는 같은 포스트가 결정론적으로 홀드아웃되고, 같은 포스트라도 뷰어마다 다른 부분집합만 걷어낸다. 기본값은 Param EnableFavHoldout=false로 꺼져 있다.
B. InventoryHoldoutFilter의 홀드아웃 비율 params가 확인됐다. 오래 미공개로 남아 있던 항목이 해소됐다. home-mixer/filters/inventory_holdout_filter.rs는 InventoryHoldoutOriginalsPercent·InventoryHoldoutRepliesPercent·InventoryHoldoutRetweetsPercent 3개 param을 참조한다. 포스트 종류의 매핑은 retweeted_tweet_id → Retweet, in_reply_to_tweet_id → Reply, 그 외 → Original이고, quoted post는 Original로 취급한다. 게이트는 EnableInventoryHoldout=true이면서 세 값 중 하나라도 >0일 때 열린다. 리트윗은 원본 tweet_id에 대해 판정하므로 원본과 홀드아웃 결정을 공유한다.
C. 랭킹 스코어에서 EnableClickDwellLowFavRatePenalty 계열이 삭제되고 EnableCdwellOnImpr(dwell × click 곱)가 들어왔다. 바뀐 파일은 home-mixer/scorers/ranking_scorer.rs(+10/−32)와 home-mixer/params/param.rs다. 이전에 ScoringWeights가 들고 있던 5개 필드와 대응 param 5개가 삭제됐다. 이 필드들은 4세대 공개(47c1bcd)부터 있었다 확인 — param.rs:387-416. 삭제된 필드는 enable_click_dwell_low_fav_rate_penalty와 baseline/alpha/floor/cap이고, 그 자리에 enable_cdwell_on_impr: bool 하나가 들어왔다.
이전 함수 low_fav_penalized_click_dwell은 fav 비율이 낮을 때 dwell 예측을 heuristic penalty로 낮추던 계산이다. 이 함수는 click_dwell_term으로 이름이 바뀌었고, 계산은 click_dwell_time × click_score로 단순해졌다. 게이트 관측 라벨도 gate.click_dwell_low_fav_rate_penalty → gate.cdwell_on_impr로 바뀌었다. 기본값은 Param EnableCdwellOnImpr=false로 꺼져 있다.
이전에는 ViewerArm::Treatment 자격이 corpus == Treatment && is_phoenix_moe(c)였다. 4세대+23에서 is_phoenix_moe(c) AND 조건이 빠져, 이제 corpus == Treatment만 확인한다.
Treatment 팔에만 age 상한을 걸던 별도 함수 cold_start_freshness_eligible(arm, c, max_age)가 삭제됐다. 이제 apply_cold_start가 모든 팔에 duration_since_creation_opt(c.tweet_id).is_some_and(|age| age <= params.max_post_age) 필터를 건다.
E. Phoenix proto ActionName의 LLM_JUDGE_* 두 개가 5일 만에 비워졌다(vacate). 바뀐 파일은 phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto와 그 파이썬 미러다. 4세대+20(6bb4594, 2026-09-12)에서 도입된 LLM_JUDGE_RELEVANT_TO_SEARCH=107·LLM_JUDGE_NOT_RELEVANT_TO_SEARCH=108이 삭제됐다. 그 자리에 CLIENT_TWEET_LIVE_STREAM_REPLY=107·RESERVED_108이 들어왔다.
의미 검색 관련성 축에 LLM judge 라벨을 별도 action으로 이식하려던 방향이 재조정됐다. 프로덕션 적용까지 이어지지 않은 것으로 봐야 한다 추정.
F. 광고 delayed conversion attribution이 3-tier로 확장됐다. 같은 proto에서 reserved 214 to 252가 reserved 234 to 252로 좁혀지고, 214-233에 tier 2·3 각 10종이 들어갔다. 세부 내역은 광고 conversion 액션 절의 4세대+23 항목에 정리했다. Tier 2와 tier 3의 시차 정의와 loss weight는 이번 커밋에 명시되지 않았다.
G. VF 서브레포의 자체 twemcache 클라이언트가 전부 제거됐다.visibility-filtering/twemcache/ 아래 11개 파일, 총 2,900줄 남짓이 삭제됐다. safety_label_source/twemcache.rs의 상단은 use crate::twemcache::{Key, TwemcacheClient, TwemcacheError, Value} → use xai_cache::{CacheClient, KVCacheError, Key, Value}로 바뀌었다. FallbackReason 매핑은 KVCacheError::Timeout(_) → Timeout, Backpressure → Backpressure, _ → Other로 통합됐다. VF가 자체 유지하던 twemcache 프로토콜(accrual·discovery·host_pool·ring 포함)을 회사 공통 KV 캐시 abstraction으로 옮긴 것으로 읽힌다.
H. VF 서비스에 evaluate_tweets RPC와 Thrift Action payload 디코더가 생겼다. 새 파일은 visibility-filtering-client/evaluated.rs(122줄 신규)이고, vf_client.rs·tweet_safety_label.rs의 트레이트에는 async fn이 추가됐다. 클라이언트 테스트용 스텁은 Err(Status::unimplemented("unused"))를 내고, VF 서버의 evaluate_tweets도 Err(Status::unimplemented("EvaluateTweets is not served yet"))를 낸다 확인 — visibility-filtering/server.rs:50-56, vf_client.rs:958-970. 그래서 인터페이스만 열어 두고 실제 구현은 대기 중이다. evaluated.rs는 다음을 정의한다.
의미 VF 응답 인터페이스가 sigil enum(FilteredReason)뿐 아니라 완전한 Thrift Action 오브젝트를 클라이언트에 반환하는 방향으로 확장되기 시작했다 추정. 4세대+20의 xDS LDS listener 마이그레이션과 짝지어 보면, VF는 얇은 필터에서 더 풍부한 정책 판정 서비스로 옮겨 가는 중이다.
I. Under-the-Hood의 일일 takedown 라벨 재현 잡 스택이 생겼다. 이 스택은 Under-the-Hood 툴의 감사 라벨을 매일 재계산해 Strato 컬럼에 반영한다. under-the-hood/scalding/에 새로 생긴 Scala 파일은 네 개다.
함께 바뀐 파일은 underTheHoodLabels.strato +164, UthPctdAccountTakedownEventsJob.scala +392/−63, UthUserMonthMhPublisherJob.scala +140/−34다. 정본에서는 랭킹이 아니라 감사 툴 서빙이므로 이 라인은 언급만 하고 상세 분석은 하지 않는다.
J. Takedown 사유를 편집하는 필터가 생겼다. 이 필터는 미집행 상태의 takedown 사유가 read 경로에서 노출되지 않도록 편집한다. 관련 파일은 takedowns/gizmoduck/RedactUnenforcedTakedownsFilter.scala(81줄)과 ReadFilters.scala(39줄)다. 그 밖에 tweet-entity-service/TakedownReasonsTransform.scala(73줄)와 TakedownReasonsColumn.scala(25줄)도 있다.
미공개 다음은 아직 공개되지 않았다.
EnableFavHoldout·EnableCdwellOnImpr의 미러 값이 바뀌는 시점과 실제 배포 실험의 버킷 정의
CLIENT_TWEET_LIVE_STREAM_REPLY(107)가 어느 학습 config의 target head에 편입되는지(홈 vs 검색)
delayed conversion tier 2/3의 시차 창 정의와 학습 loss weight
VF evaluate_tweets RPC의 서버 측 구현 시점
cold-start Treatment 팔 게이트 완화가 실측 A/B 결과에서 촉발된 것인지, 다음 실험 셋업의 준비인지
4세대+24 (2026-09-18, c279172) — 광고 delayed feedback 학습이 sample-source 3-stream 마스킹으로 통합 + purchase-value loss가 early-relabel 슬라이스로 좁혀짐 + Phoenix wire proto에 EngagementCounts(user×ads window counts, ad×window counts)와 SearchLexicalMatch(matched-word fraction·exact phrase) 신호 추가 + grox에 참조 영상 번들 대 후보 영상 VLM 비교 spam-clone 탐지 흐름 신설 + abuse-enforcement Kafka 토픽별 cluster/zone override + Phoenix bundle export의 request-vs-template 계약 분리
광고 랭킹 학습에서 delayed feedback 관련 설정 세 개가 기본값이 꺼진 스위치 하나로 합쳐졌고, 이 스위치를 켜면 헤드마다 fresh, early-relabel, late-split 세 표본 흐름 가운데 학습할 흐름이 정해진다. 구매액 회귀 loss는 early-relabel 표본에서만 학습하도록 좁혀졌고, grox에는 참조 영상 번들과 후보 영상을 VLM으로 비교해 스팸 클론을 가려내는 흐름이 생겼다 확인(daily).
A. 세 config 옵션이 ads_head_masking 하나로 합쳐지고, sample-source가 FRESH·EARLY_RELABEL·LATE_SPLIT의 3-stream으로 나뉜다. 세 config 옵션 condition_conversion_on_click·train_view_through_heads·split_head_training_by_source가 모두 삭제되고, ads_head_masking: bool = False 하나로 대체됐다. RecsysAggregatedModelConfig·build_metric_masks·trainer_recsys 세 지점에서도 세 옵션 참조가 모두 제거됐다. ads_head_masking=True이면 새 함수 ads_head_masking_factor(targets, source_id, num_actions)가 아래 규칙대로 loss·metric 마스크를 조립한다.
Engagement 헤드(CT·VT·twin 인덱스를 제외한 나머지)는 FRESH_STREAM_ID(=0) 샘플에서만 학습한다.
CT 헤드(CLICK_CONDITIONED_ACTION_INDICES)는 EARLY_RELABEL_STREAM_ID(=1) 샘플에서 클릭이 있을 때만 학습한다.
VT 헤드(VIEW_THROUGH_ACTION_INDICES)는 EARLY_RELABEL_STREAM_ID 샘플에서 클릭이 없을 때만 학습한다.
Twin 헤드(신설 ADS_LATE_WINDOW_TWIN_HEAD_INDICES, 10개 delayed action)는 LATE_SPLIT_STREAM_ID(=2) 샘플에서 클릭이 있고 fresh 카운터파트가 없을 때만 학습한다.
Twin 헤드 목록은 4세대+12가 2026-09-01에 도입한 base *_DELAYED 10종(204-213)이다. 4세대+23이 2026-09-17에 새로 만든 tier 2의 214-223과 tier 3의 224-233은 아직 twin 목록에 편입되지 않았다.
함께 생긴 ADS_LATE_WINDOW_TWIN_SUPPRESSOR 딕셔너리는 twin 헤드마다 fresh-stream 카운터파트 인덱스를 지정한다. 예를 들어 ADS_PURCHASE_CONVERSION_DELAYED → ADS_PURCHASE_CONVERSION, ADS_WEB_CONVERSION_DELAYED → ADS_ATTRIBUTED_CLICK_CONVERSION이다. web-conversion delayed의 억제자가 attributed-click-conversion이라는 점에 주의한다. ads_head_masking_factor는 no_early_counterpart = 1 - targets[:, :, suppressor_index]로 이 억제자 값을 조회해 late-split twin 마스크에 곱한다. 같은 (유저·광고) 샘플에서 fresh 스트림 카운터파트가 이미 관측됐으면 late-split의 twin 헤드는 학습에서 제외되어 중복 관측을 피한다.
의미 delayed feedback 라벨링을 3-스트림(fresh·early·late)으로 나누는 학습 코드 경로가 들어왔다 확인. 다만 ads_head_masking 기본값이 False이고 이 커밋의 공개 config 가운데 켜는 곳이 없어, 프로덕션 학습에 적용된 시점은 알 수 없다 확인 — c279172 phoenix/ grep. 세 boolean 가운데 condition_conversion_on_click은 4세대 공개부터 있었고, train_view_through_heads와 split_head_training_by_source는 4세대+6이 더했다(47c1bcd, 28e414f의 recsys_model.py). 세 boolean의 실효 조합이 단일 카테고리컬 sample-source의 매핑이라는 사실을, 이번 커밋이 정리한 셈이다. train_view_through_heads=False 기본값으로 view-through 8종을 학습에서 빼 두던 이전 관행은 사라졌다. ads_head_masking=True이면 view-through 헤드가 자동으로 early-relabel 스트림의 "클릭 없음" 슬라이스에서 학습된다.
B. Purchase-value loss가 EARLY_RELABEL 슬라이스로만 학습되도록 좁혀졌다.purchase_value_valid_mask가 sample_source > 0에서 sample_source == EARLY_RELABEL_STREAM_ID로 좁혀졌다. Late-split 스트림에서는 구매액 회귀가 학습되지 않는다. Loss stat 이름도 purchase-value-*에서 purchase-value_delayed_clicked-*로 바뀌었다. 슬라이스 범위(delayed sample 중 clicked)를 스탯 이름에 담은 것이다.
새 함수 purchase_value_smoothed_stats는 EMA 스무딩된 loss·ratio-mae·target-ratio·baseline-mean-usd·usd-mae·calib을 발행한다. 기본 스무딩 창은 (1 << 28, 1 << 30)이고, 각각 2^28 ≈ 2.68억 샘플과 2^30 ≈ 10.7억 샘플이다. trainer_recsys._purchase_value_ema_keys는 이 두 창에 대해 shape=(10,) 텐서를 EMA state로 유지한다. 체크포인트를 다시 읽을 때 shape가 맞지 않으면 zeros로 재초기화한다.
purchase-value_delayed_clicked-usd-mae는 달러 단위 MAE다.
purchase-value_delayed_clicked-calib는 예측 ratio 총합 ÷ 실제 ratio 총합으로, 광고주 baseline 기준 캘리브레이션이다.
purchase_value_loss_weight=0.0 기본값이 유지되므로 실제 활성화는 여전히 옵트인이다 확인(같은 출처).
의미 4세대+22의 광고 매출 회귀 loss는 "지연 관측 창 안"이면 모두 학습되던 관행에서 "짧은 지연(early) 슬라이스만" 학습되는 관행으로 좁혀졌다. Late-split 슬라이스는 twin 분류 헤드만 학습하고 회귀는 하지 않는다. X 광고 랭킹은 두 갈래 스택으로 정착했다. 짧은 시차 관측에서는 구매액을 회귀로 학습하고, 긴 시차 관측에서는 컨버전 여부만 분류로 학습한다.
C. Phoenix wire proto에 EngagementCounts와 SearchLexicalMatch 두 채널이 새로 생겼다. 두 채널은 두 proto 미러(phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto·phoenix/python/common/xai-proto/proto/recsys.proto)에 똑같이 반영됐다.
D. grox 안전 파이프라인에 새 흐름 safety_ptos_media_injected_adult_infrared_video_spam_detection이 생겼다. 새 파일 4개(146+188+55+44 = 433줄)가 생겼고, 상수·프롬프트·rate-limit·generator 파일에도 조금씩 추가됐다. 이 흐름은 Kafka 토픽 safety-ptos-media-injected-adult-infrared-video-spam-detection을 소스로 하는 streaming task다.
Eligibility 필터는 아래 조건을 모두 만족하는 포스트만 통과시킨다.
유저가 있다.
포스트나 인용 포스트에 비디오가 있다.
fav count가 media_hydration.deluxe_fav_count_threshold 이상인 고참여 포스트다.
이미 NSFW로 플래그되지 않았다.
통과한 포스트에 대해서는 MediaReferenceBundleLoader.current("media_injected_adult_infrared_video_spam")으로 blobstore의 참조 영상 번들을 로드한다. 번들 규격은 다음과 같다.
최대 10개 영상 × 각 최대 10 프레임 × 프레임 최대 2 MiB
refresh_interval_s=900(15분) 주기 리프레시
SHA-256 digest 12자로 identity 관리
VLM으로는 GROK_4_1_FAST_HEDGEHOG_CRITICAL(vision sampler)이 60초 timeout으로 호출된다. 프롬프트는 참조 영상 스토리보드 여러 개를 인터리브해 넘긴 뒤 후보 포스트를 PostRenderer로 렌더한다. 그리고 <json>{"violation": bool, "reason": ...}</json> 형식의 판정을 요구한다.
의미 Near-duplicate video 탐지는 임베딩 매칭이 표준인데, X는 임베딩 대신 VLM 프롬프트에 참조 스토리보드 프레임을 직접 넣어 시각적 매칭 판정을 맡긴다. Reference bundle은 15분마다 blobstore에서 리프레시된다. 그래서 운영 팀이 새 클론 후보 영상을 번들에 추가하면 별도 재배포 없이 판정에 반영된다. infrared는 특정 촬영 모드(예: 야시경으로 촬영된 성인 영상 클론)를 가리키는 것으로 읽히지만, 정확한 카테고리 정의가 담겼을 프롬프트 템플릿은 공개되지 않았다 추정.
E. Phoenix recsys_bundle_export에서 request 계약과 template 계약이 분리됐다. 새 상수 REQUEST_BATCH_KEYS(약 30개 배치 키의 집합, user_*·history_seq.*·candidate_seq.* 계열)가 정의됐다 확인 — phoenix/xrex/train/recsys_bundle_export.py (+104/−7). _input_spec은 각 배치 키를 세 종류로 분류한다 확인(같은 파일).
_collect_urls에서 유저 URL이 카드 URL 뒤로 밀렸다. 도메인 dedup은 set에서 dict[str, None]로 바뀌어 삽입 순서를 유지한다. 그래서 다운스트림 판정에서 도메인 노출 순서가 안정된다.
eapi_sampler의 web_search 툴에서 allowed_domains가 상위 5개로 잘린다(MAX_SEARCH_DOMAINS = 5).
리플라이 스팸 로그 라벨의 이름이 "lower than 1000 follower bucket"에서 "Gemma bucket"으로 바뀌었다. 4세대+22의 follower 이원 라우팅 이후 Gemma 스코어=0 판정을 가리키는 표기를 통일한 것이다.
H. Brazil2026 election filter가 유지 관리 차원에서 갱신됐다. 신규 계정 12개가 하드코딩됐고, "no live account found" 주석 4건이 갱신됐다. 필터 시맨틱은 바뀌지 않았다. TSE dadosabertos 갱신 관행이 이어진다. 확인 — home-mixer/filters/brazil_2026_election_filter.rs (+143/−4).
I. Phoenix Cargo의 thrift 소스가 git tag에서 crates.io registry로 옮겨졌다. 버전은 0.24.0으로 같지만 checksum(2b29bdae81cc2dbc498a720e2734e08f26e388c791fca6e95464d41001913714) 잠금이 붙어 공급망 재현성이 강화됐다. 확인 — phoenix/Cargo.toml, phoenix/Cargo.lock.
미공개 다음은 아직 공개되지 않았다.
ads_head_masking=True mparams 전환 시점(어느 학습 config에서 먼저 활성화되는지)
tier 2/3 delayed action이 twin 마스킹에 편입되는 시점
EngagementCounts 소비 지점(어느 임베딩 layer로 통과하는지)
SearchLexicalMatch가 xrecsys_search의 어느 head나 스코어에 붙는지
media_injected_adult_infrared_video_spam_detection 흐름의 실측 배포 시점과 infrared 카테고리의 정확한 정의
purchase-value EMA 스무딩 창(2^28·2^30)이 실제 대시보드에 노출되는지
4세대+29 (2026-09-25, bf7db1b) — VF hydration이 데이터 기반 Plan/Sources/Execute 3계층으로 재작성 + Hydrator enum 7 → 17 variant + about_this_account/tfe_top_country Strato 컬럼이 hydration 예산에 편입 + rules/golden_corpus 1,493줄 단일 파일이 15개 카테고리 파일로 분할 + models/region.rs(97줄) 9-지역 카탈로그 신설
visibility-filtering의 하이드레이션(판정에 필요한 원본 데이터를 백엔드 서비스에서 배치로 조회해 채우는 단계)이 hydrator마다 함수를 두던 구조에서 데이터 테이블과 스케줄러로 실행하는 구조로 다시 쓰였다. 같은 backend에 같은 입력을 쓰는 조회는 한 RPC로 자동 병합되고, 뷰어 국가는 About This Account 데이터를 직접 조회해 conversation-control 국가 허용 목록 판정에 쓴다 확인(daily).
A. Hydration이 함수 계층에서 데이터 테이블과 스케줄러로 다시 쓰였다. 어제(4세대+28)까지는 hydrator variant마다 별도 .rs 파일에 자기 함수가 있었다. conversation_control_hydrator.rs·exclusive_content_hydrator.rs·socialgraph_hydrator.rs 등이 그런 파일이다. 이 구조가 전부 삭제되고, 실행은 다음 3파일로 옮겨졌다.
Part는 그 backend 안의 무엇을 읽는지이고, Column·Fields(&[QueryFields])·Edge(Graph, EdgeDirection)가 있다.
KeyOrigin은 입력 키의 출처이고, RequestTweets·Viewer·PureCoreAuthor·PureCoreReplyRoot·ExclusiveConversationAuthor·ConversationRoot(&[ConversationControlArm])·ViewerForCoAllowedList가 있다.
HydrationPlan::new(level, hydrators)는 요청된 hydrator 집합을 (source, input) 쌍으로 묶어, 같은 backend와 같은 입력 dependency의 hydrator들을 한 RPC로 병합한다.
Group::fields()는 필드 집합을 합집합으로 만들고, Group::edges()는 socialgraph edge query를 병합한다.
ProdSources 구조체는 TES·composite TES·gizmoduck·socialgraph·about_this_account·safety_labels·PureCoreFallbackCache·AuthorFallbackCache를 가진다.
Hydrator enum은 7 variant(4세대+28)에서 17 variant로 늘었다. 새 이름 13개가 생기고 ExclusiveContent·Author·Relationship 3개가 없어져 순증은 10개다 확인 — 44d37eb와 bf7db1b의 hydration/mod.rs. 아래 목록에서 굵게 표시한 것이 이번에 새로 생긴 variant다. Hydrators bitset은 u32 하나로 유지되고, assert가 17 ≤ 32를 검증한다.
B. Plan은 SafetyLevel별로 정해지고 골든 테스트에 pin된다.RuleEngine::plan(level)이 SafetyLevel마다 만드는 계획은 골든 문자열로 잠긴다 확인plan.rs:422-451. SafetyLevel별 호출 수는 다음과 같다 확인(같은 파일).
filter_all 1 call
timeline_home 7 calls
timeline_home_recommendations 7 calls
timeline_home_hydration 10 calls
immersive_expanded_recommendations 7 calls
예를 들어 timeline_home_hydration의 10-call 그래프에서 각 호출은 아래 조건이 채워지면 실행된다 확인(같은 파일).
tes/get_tweet_core_datas·tes/get_tweets_for_visibility·conversation_control/get_conversation_controls·safety_labels/get·gizmoduck/get_viewer_data 다섯은 요청 tweet id와 viewer id만 있으면 바로 시작한다.
gizmoduck/get_users는 pure_core가 완료된 뒤에 실행된다. 이 호출은 author_safety와 labels 필드의 합집합을 조회한다.
blocked_by/batch_check_blocked_by는 pure_core가 완료된 뒤에 실행된다.
exclusive_content/batch_check_super_follows는 tweet이 완료된 뒤에 실행된다.
conversation_control/batch_check_followed_by와 batch_check_super_follows를 병합한 호출, 그리고 conversation_control/tfe_top_country는 conversation_control이 완료된 뒤에 실행된다.
C. Strato 컬럼 about_this_account/tfe_top_country.User를 읽는 About This Account 클라이언트(166줄)가 생겼다.visibility-filtering/clients/about_this_account_client.rs의 AboutThisAccountClient::tfe_top_country(user_id)가 유저의 weighted top country를 반환한다. 이 결과는 ViewerCountry hydrator를 거쳐 conversation-control country allowed-list 판정에 공급된다. "About This Account"는 X 앱 계정 프로필 하단의 투명성 UI(계정 생성일·top country·이름 변경 이력)다. 이제 VF가 그 데이터 원천을 직접 소비한다.
파일별 줄 수는 age_gating.rs 267·author_state.rs 119·baseline.rs 56·builders.rs 63·conversation_control.rs 257·exclusive_content.rs 61·interstitial.rs 334·mod.rs 173·oon_media.rs 218·oon_tweet_label.rs 133·oon_user_label.rs 234·relationship.rs 122·takedown.rs 224·tweet_label.rs 195·tweet_state.rs 90이다.
E. ISO country code를 10개 지역으로 묶는 models/region.rs가 생겼다.visibility-filtering/models/region.rs는 97줄이고, 지역은 10개, 국가 코드는 중복 없이 250개다 확인. 4세대+12(2026-09-01)의 NSFW 게이팅 16개국이 국가 단위였다면, 이제 지역 단위 rule도 표현할 수 있게 된다. 실제로 이 카탈로그를 쓰는 rule은 이번 커밋에 명시적으로 나오지 않는다.
F. rules 카탈로그가 크게 줄었다. 파일별 변화는 tweet_rules.rs −575줄(692 → 117), author_rules.rs −160줄(182 → 22), registry.rs +226/−58, rule_spec.rs +141/−161이다. 4세대+26(2026-09-22)이 도입한 이원 Verdict 트리(Withheld{Drop|Tombstone}|Shown{media?,engagement?})와 데이터 기반 RuleClause 구조가 유지되면서, rule 자체가 더 데이터로 이관되는 방향으로 보인다. 세부는 후속 배치에서 추적한다.
G. 부수적인 변경도 있었다.
home-mixer/filters/brazil_2026_election_filter.rs(+22/−2)에서 2026-08-14에 신설된 브라질 선거법원 조치 계정 목록이 조금 갱신됐다. 근거는 TSE Electoral Resolution No. 23.610 Art. 28 §1º-A다.
Phoenix proto의 recsys.proto 양쪽 미러에 34줄이 동시에 새로 들어갔고, phoenix/xrex/models/transformer.py +42/−5, recsys_model.py +5/−1이 바뀌었다. 새 필드가 무엇인지는 이번 배치에서 추적하지 않았다.
grox에 frame_sampler.py 124줄과 embedding_ranked_key_frames.py 193줄이 새로 생겼다. 함께 key_frames.py +14/−53·media_loader.py +62/−34·convo.py +112/−61이 바뀌었다. 어제 열린 32×32 grayscale scene-cut 파이프라인은 embedding 기반 랭킹으로 확장됐다.
under-the-hood/jetfuel/에 관측 UI 프론트엔드 8파일, 약 600줄이 새로 생겼다. 랭킹·retrieval 메커니즘 자체와는 관계없다.
의미 VF hydration은 24시간 반감기로 재작성됐다. 어제 신설된 conversation_control_hydrator.rs(310줄)가 오늘 삭제됐다. 어제 도입된 "predicate가 const fn hydrators()로 요구 hydrator를 선언 + RuleEngine::hydrators_for(safety_level)가 조합" 패턴이 NodeSpec 데이터 테이블과 execute.rs 스케줄러로 재작성됐다. 실질적인 변화는 3가지다.
인터페이스가 함수에서 데이터로 바뀌었다. 어제 구조에서는 실행이 여전히 hydrator variant마다 별도 함수나 파일이었지만, 오늘은 실행도 데이터가 됐다. rule을 추가할 때 hydrator 함수를 새로 쓸 필요 없이 NodeSpec 테이블에 한 줄을 더하면 된다.
RPC 그룹 병합이 자동화됐다. 같은 backend와 같은 입력을 갖는 hydrator들은 자동으로 한 RPC로 병합된다. gizmoduck의 SAFETY와 LABELS 필드 조회, socialgraph의 Follows·Blocks·Mutes·MuteRetweets 4-edge 조회가 그런 예다. hydration 예산이 gizmoduck·socialgraph 같은 fan-out 서비스에 몰리는 X 구조에서는 이것이 P99에 직접 영향을 준다.
ViewerCountry가 hydration 예산에 정식으로 들어왔다. 이전에는 country 정보를 요청 컨텍스트에 실어 왔지만, 이제는 hydration이 about_this_account/tfe_top_country.User Strato 컬럼을 직접 조회해 채운다. About This Account 투명성 UI의 백엔드가 VF 판정 경로에 붙은 것이다.
4세대+30 (2026-09-26, 4c5cfe8) — abuse-enforcement에 overturn-hold 게이트 신설 (5,270줄) + VF hydration이 execute/store 분리 + safety_label_hydrator.rs·tes_hydrator.rs 완전 삭제 + wingman follows 클라이언트 신설 + rules registry가 static Policy로 잠김 + Phoenix 광고 구매액 target 카탈로그 (event × level 6조합) + Phoenix Cold retrieval 채널이 cold-start arm-gate에 편입
abuse-enforcement 서비스에 overturn-hold 게이트가 생겼다 확인 — daily. 사람 검토자가 이의제기에서 뒤집은 정지나 라벨을, hold가 유효한 동안 자동화 판정이 다시 걸지 못하게 막는 마지막 프리체크다. 피드 쪽에서는 Phoenix retrieval의 Cold 채널 후보가 cold-start 실험의 Treatment 팔에만 노출되기 시작했다 확인(같은 daily).
A. abuse-enforcement에 마지막 프리체크인 overturn-hold 게이트가 생겼다.
abuse-enforcement-service/service-lib/src/overturn_hold.rs가 5,270줄로 새로 들어왔다. 목적은 사람 검토자가 이의제기 처리 중 뒤집은(overturned) 정지나 라벨을, 그 hold가 유효한 동안 자동화 판정이 다시 걸지 못하게 막는 것이다. 파일 상단 주석이 이 목적을 그대로 밝힌다: "the last precheck before a suspend (or a gated label) is dispatched. It exists so we do not re-apply an enforcement that a human reviewer already overturned on appeal".
게이트는 모드, 종류, 원격 ledger 조회, 회로 차단기로 이뤄진다.
게이트 모드는 세 가지다(GateMode::Off | Shadow | Enforce). Shadow는 판정만 실행하고 조치를 dispatch하지 않으며, Enforce는 실제로 차단한다.
게이트 종류는 정지 재적용을 막는 GateKind::Suspend와 라벨 재적용을 막는 GateKind::Label 두 가지다. 정지는 임시(GatedAction::Suspend)와 영구(GatedAction::PermSuspend) 두 종류를 따로 판정한다.
활성 hold는 원격 ledger에서 받는다. HTTP 클라이언트가 /v1/holds/lookup을 호출한다.
타임아웃은 CONNECT_TIMEOUT 200ms, DEFAULT_TIMEOUT_MS 200ms이고, 후자의 튜닝 범위는 10~5000ms다.
회로 차단기는 실패가 BREAKER_FAILURES=5에 이르면 열리고, 열린 뒤 DEFAULT_BREAKER_OPEN=10s 동안 기다린다. 도달 가능한 에러(예: HTTP 5xx)가 5회 연속 나면 REACHABLE_ERROR_BACKOFF=5s만큼 back-off한다.
시작할 때 HoldGate::from_url(cfg.overturn_hold_ledger_url, ..., cfg.overturn_hold_env)로 게이트를 만들고, Arc<HoldGate>를 EnforcementCtx에 주입한다.
실행 경로에서는 gated_actions(&specs, &cfg)가 dispatch 후보 가운데 게이트 대상 액션을 골라 반환한다. 또 strip_held_labels(&mut specs, &gate.strip_labels)가 이미 뒤집힌 라벨 이름을 spec에서 지운다.
판정 결과의 상태는 두 가지다.
STATUS_HOLD_OVERTURNED = "hold_overturned"는 hold가 활성이어서 실제로 차단한 경우다.
STATUS_HOLD_LOOKUP_FAILED = "hold_lookup_failed"는 ledger 조회에 실패한 경우다.
같은 outcome dict에는 overturn_hold_id·overturn_hold_mode·overturn_hold_head·overturn_hold_expires_at·overturn_hold_labels_stripped 5개 진단 키가 실린다. dedup retention 계층도 두 상태를 따로 다룬다 확인 — lib.rs:2469-2501. dedup_retention_for(STATUS_HOLD_OVERTURNED)와 dedup_retention_for(STATUS_HOLD_LOOKUP_FAILED) 항목이 새로 생겼다 확인(같은 파일).
B. VF hydration의 상태 관리가 실행 파일에서 별도 파일로 분리됐다.
상태와 실행을 함께 맡던 execute.rs에서 상태를 store.rs로 떼어 내고, execute.rs에는 실행만 남겼다. visibility-filtering/hydration/store.rs는 339줄로 새로 들어왔고, execute.rs의 줄 수 변화는 −141줄(+394/−535)이다. store.rs는 hydration 실행 중 in-flight 결과를 담는 Store 구조체를 정의한다.
Store::keys(origin: KeyOrigin) -> Vec<u64>는 하위 hydrator에 필요한 입력 키를 origin 축별로 뽑아 리졸브한다. origin 축에는 RequestTweets·Viewer·ViewerForCoAllowedList·PureCoreAuthor·PureCoreReplyRoot·ExclusiveConversationAuthor 등이 있다. Store::callable: Hydrators 필드는 이번 요청에서 실행할 수 있는 hydrator를 bitset으로 고정한다.
인터페이스는 WingmanClient::batch_exists_intersect(viewer_id, root_author_ids) -> Option<Vec<Exists>> 하나뿐이고, IntersectMode::FwdXRev를 쓴다. FwdXRev는 정방향(viewer → author)과 역방향(author → viewer)을 동시에 확인하는 모드다. 이 모드로 "reply root 작성자와 뷰어가 서로 팔로우인가"를 한 번의 RPC로 판정한다. socialgraph_client.rs(+43/−27)에서는 관련 코드가 줄었다. FOLLOWS_GRAPH=1 상수에서 wingman이 여러 그래프를 갖고 있음을 알 수 있지만, 2번 이후는 이번 공개에서 확인되지 않는다.
D. rules registry가 +206/−203으로 다시 쓰였고, rules 전용 metrics 모듈이 생겼다.
RuleEngine::select(level) -> &'static Policy는 요청된 SafetyLevel마다 5개 static 중 하나를 참조한다. Policy::with_additional(base, additional)은 두 슬라이스를 합쳐 파생 정책을 만든다. RuleEngine::wired_rule_names(level)은 정책에 연결된 rule 이름 목록을 반환한다 확인 — registry.rs:346-460. 골든 테스트는 등록된 다섯 SafetyLevel 각각에 대해 wired_rule_names가 반환하는 리스트를 assertion으로 고정한다 확인(같은 파일).
4세대+28(2026-09-24, 44d37eb)에서 Phoenix retrieval에 HOME_COLD=13 채널이 HOME_HOT=14 옆에 생겼고(13은 홈믹서가 4세대+25부터 cold 슬라이스 태그로 쓰던 값이다), 1fav_1day.parquet 소스를 fav<8 & views<500 마스크로 나눴다. 이번 커밋부터 이 Cold 채널의 후보는 cold-start 실험의 Holdout/Control/Treatment 세 팔 가운데 Treatment 팔에게만 노출된다. MoE와 같은 arm-gating이다. 새 테스트 cold_retrieval_is_gated_like_moe는 Cold 후보 점수가 Holdout·Control에서 0, Treatment에서는 5.0으로 남는지 검증한다.
G. 나머지 변경은 부수적이다.
Phoenix async embedding CUDA 계층이 줄었다: async_emb_api.cc +123/−191, async_emb_comm.cc +86/−63. gradient 교환 API surface가 데이터 중심으로 정리되는 방향이다.
의미 이 커밋의 진짜 헤드라인은 abuse-enforcement의 overturn-hold gate 신설이다. VF 개편의 24시간 반감기 패턴도 이어진다. 전날까지 함수였던 safety_label_hydrator·tes_hydrator가 이번에 삭제됐고, 전날 신설된 execute.rs의 상태 로직이 store.rs로 분리됐다.
자동화 판정과 사람 판정의 우선순위가 데이터로 고정된다. 자동화 dispatch 경로에서 정지 spec이 만들어질 때마다, hold ledger를 조회해 "이 조치가 사람 손에서 취소된 적이 있는가"를 확인하고 gate하는 마지막 문이 코드로 명시됐다.
정지의 취소가 지속 상태로 취급된다. hold는 만료 시각(expires_at)을 갖는 시간 창이다. 그동안 특정 대상(계정·라벨)에 대한 자동화 조치가 막힌다. 사람 검토자의 이의제기 승인이 "1회성 취소"가 아니라 "이후 시간 T까지의 재적용 금지"라는 정책 항목이 된다.
follows 조회가 wingman 경로로 옮겨 갔다. socialgraph 직접 호출이 wingman 경로로 옮겨지면서 hydration RPC의 fan-out 구성이 다시 정리된다. FOLLOWS_GRAPH=1이 상수로 고정된 것은 wingman이 여러 그래프(2·3···)를 갖고 있음을 시사하지만, 나머지는 이번 공개에서 확인되지 않는다.
Phoenix 광고 구매액 target이 데이터로 고정된다. ValueEvent 3 × ValueLevel 2 = 6 조합 가운데 REQUIRED 2개와 PUBLISHED 4개가 명시됐다. $100k 이벤트 상한도 학습·평가 계약에 고정된다.
Cold retrieval 후보가 cold-start 실험에 들어갔다. Phoenix retrieval의 새 Cold 채널이 cold-start 실험의 Treatment 팔로 흐르는 경로가 데이터(matches! 패턴)로 고정된다.
4세대+31 (2026-09-29, a707cc2) — overturn-hold 서버 abuse-ledger-service 공개 (12파일 2,254줄) + VF 레퍼런스 비교 대상에 Tweetypie 편입 + 홈믹서 프로덕션 크론에서 ClickWeight 0.4 → 0.3·ContClickDwellTimeWeight 0.0 → 0.4·NotInterestedWeight −43.2 → −47.52 + VF 리트윗 소스 병합이 별도 retweet.rs 모듈로 추출 + VF 클라이언트 스위치 6개가 params/client_switches.rs로 데이터 잠금 + age_verification·legacy_interstitial 골든 코퍼스 분할 + Phoenix 광고 PUBLISHED = ALL(4 → 6)
직전 커밋(4세대+30)의 overturn-hold 게이트가 호출하던 원격 ledger의 서버가 read-only 조회 서비스로 공개됐다 확인 — daily. 랭킹 쪽에서는 홈믹서 프로덕션 크론 미러의 클릭 가중치가 0.4에서 0.3으로 내려가고, 클릭 뒤 체류 시간 가중치가 0.0에서 0.4로 올라갔다 확인(같은 daily).
SELECT user_id, hold_id, head, expires_at, case_group_id, reason, action_kind, label
FROM ledger.enforcement_holds
WHERE user_id = ANY($1::bigint[]) AND cleared_at IS NULL AND expires_at > now()
AND (action_kind = 'suspend' OR (action_kind = 'label' AND label = ANY($2::text[])))
테이블은 ledger.enforcement_holds다 확인(같은 파일).
활성 조건은 cleared_at IS NULL AND expires_at > now()다 확인(같은 파일).
action_kind는 'suspend' | 'label' 두 값이고, Suspend는 라벨과 무관하게, Label은 요청한 라벨 집합에 속할 때만 반환한다 확인(같은 파일).
시작할 때 CONTRACT_SQL(SELECT ... LIMIT 0)로 컬럼 프로젝션 스키마를 검증하고, 헬스체크는 PING_SQL(SELECT 1)로 한다 확인(같은 파일).
테스트는 세 SQL에 INSERT/UPDATE/DELETE/ALTER/DROP/CREATE가 하나도 들어가지 않음을 assert한다 확인(같은 파일). 서버는 write를 하지 못한다 확인(같은 파일).
부팅할 때 boot::contract_check_until_ok는 스키마 검사가 성공할 때까지 AtomicBool<ready>를 false로 두고, 그동안 /readyz는 200을 돌려주지 않는다 확인(같은 파일).
TLS는 별도 파일 348줄로 구현한 rustls 기반 mTLS다. sslmode=require|verify-ca|verify-full을 파싱하고, CA/CRT/KEY 경로는 별도 환경 변수로 받는다. ledger 저장소는 mTLS로 보호되고, 이 서비스는 그 저장소의 read-only 프록시 자리에 있다.
B. VF 레퍼런스 비교 대상에 Tweetypie가 추가됐다.
비교 대상은 vf_service | tweetypie 두 값으로 넓어졌다. visibility-filtering/reference/mod.rs에 Reference::{None, VfService, Tweetypie} enum이 생겼다. 환경 변수 VF_REFERENCE가 4세대+7의 VF_DUAL_CALL_HARNESS_ENABLED를 대체하며, 옛 변수가 남아 있으면 기동이 실패한다. 어느 값이든 APP_ENV=prod에서는 기동을 거부한다(스테이징 전용).
reference/tweetypie.rs가 714줄로 새로 들어왔다 확인 — visibility-filtering/reference/tweetypie.rs. 호출 대상은 Strato 컬럼 tweetypie/getTweetFields.Tweet이고, 요청에 GetTweetFieldsOptions { visibility_policy: VISIBILITY_POLICY_USER_VISIBLE, ... }를 싣는다. 그러면 Tweetypie가 이미 tweet 단위 visibility 결정(Action::Allow | Drop(reason))까지 붙여 반환하고, 이것을 Rust VF의 자체 판정과 diff한다. 상수는 아래 여섯 개다.
TWEETYPIE_MAX_BATCH_SIZE = 250: 한 번에 250 tweet씩 배칭한다.
결과 클래스는 7가지다(Allow / BareDrop / Drop / Suppressed / NotFound / Failed / Missing). 실패 사유도 7가지다(NoValue / StratoError / RateLimited / DecodeError / RpcError / OverCapacity / TweetypieFailed). 둘은 각각 vf_tweetypie_reference_compare 메트릭의 라벨이다.
C. VF 리트윗 소스 병합이 별도 모듈로 추출됐다.
visibility-filtering/retweet.rs가 318줄로 새로 들어왔고, evaluate_tweets.rs는 +101/−102로 줄었다. 4세대+28에서 인라인으로 도입된 in-batch와 fetched 두 세트의 verdict 병합이, 이번에 evaluate_merging_sources 함수로 분리됐다. 구조는 같다. 새 메트릭 evaluate_tweets_retweet_sources { outcome=in_batch | fetched }로 별도 fetch가 얼마나 자주 필요한지 관측된다.
TwitterContextViewer에는 client_application_id, user_agent, country_code가 들어 있다.
E. golden corpus 분할이 이어졌다.
4세대+29에서 시작된 15-파일 분할의 후속으로 네 파일이 새로 갈라져 나왔다. golden_corpus/age_verification.rs는 705줄, legacy_interstitial.rs는 130줄이고, local_regulations.rs와 takedown.rs도 따로 갈라졌다. age_verification.rs는 D절의 AgeVerification(*) 스위치가 클라이언트 상태마다 만들어 내는 verdict를 role×tweet 매트릭스로 고정한다.
F. 홈믹서 프로덕션 크론 미러의 가중치 세 개가 바뀌었다.
home-mixer/params/param.rs 상단의 sync 주석이 2026-09-25T16:24:28Z에서 2026-09-28T16:00:35Z로 갱신됐고, 세 값이 함께 바뀌었다 확인.
ClickWeight: 0.4 → 0.3 (⚠ −25%)
ContClickDwellTimeWeight: 0.0 → 0.4 (⚠ 완전 비활성 → 활성)
NotInterestedWeight: −43.2 → −47.52 (정확히 ×1.1)
4세대+22(2026-09-16)에서 binary continuous loss type이 click-dwell BCE로 학습 loss에 편입된 뒤로, 학습된 head가 오프라인·홀드아웃을 통과해 서빙 가중합에 승격된 시퀀스로 보인다 추정. 클릭 자체의 무게가 4분의 1 빠지고, "클릭 뒤 dwell"이 새로 무게를 얻는 방향이다.
또 PhoenixRetrievalExcludeSeenPosts bool 파라미터가 새로 생겼고(기본 false, 아직 미활성), UseEngagementCounterViewCountForImpressionBoost 파라미터는 제거됐다.
H. Phoenix 광고 target이 PUBLISHED = ALL로 넓어졌다(4 → 6).
phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs의 PUBLISHED 배열은 4세대+30에서 [PURCHASE_CT, PURCHASE_VT, CONTENT_VIEW_CT, ADD_TO_CART_CT]로 고정됐다. 이번에 PUBLISHED = ALL로 넓어져 CONTENT_VIEW_VT·ADD_TO_CART_VT도 서빙 응답에 실린다. PurchaseValueBaseline 인코딩 크기 assert는 ≤64 바이트에서 = 73 바이트로 바뀌었다.
I. 나머지 변경은 부수적이다.
visibility-filtering/rules/tweet_rules.rs +904/−543, rules/registry.rs +543/−353, rules/rule_spec.rs +465/−33은 client_switches 도입에 맞춘 대규모 리팩터다. 이 커밋에서 Policy는 static 슬라이스 대신 실행 시점에 Vec<(&str, RuleClause)>로 만드는 구조로 바뀌었다 확인 — rules/registry.rs:28-55. 그 전의 Policy<'a> 구조체는 2026-09-22 커밋(4세대+26)부터 있었다 확인 — 3aa0fa3의 registry.rs:20-23. SafetyLevel마다 둔 static Policy는 2026-09-02 커밋(4세대+14)의 3개에서 4세대+29(bf7db1b)의 5개로 늘었다 확인 — 85ac72a의 registry.rs:79-102.
visibility-filtering/hydration/mod.rs가 +8/−2로 수정됐다 확인. 4세대+30에서도 이 파일은 삭제되지 않고 +22/−29로 수정됐다(4c5cfe8).
자동화 정지 정책의 신뢰 계층이 두 서비스로 물리적으로 분리됐다. abuse-enforcement가 dispatch를 결정하고, abuse-ledger-service는 사람 검토자의 결정(overturn)을 read-only로 조회할 수 있게 한다. abuse-enforcement는 ledger를 HTTP(POST /v1/holds/lookup)로만 조회하고, Postgres의 ledger.enforcement_holds에 접속하는 쪽은 abuse-ledger-service다 확인 — overturn_hold.rs:25, abuse-ledger-service/src/sql.rs:1-11. 후자의 SQL은 조회용 PROBE_SQL, 컬럼 계약 확인용 CONTRACT_SQL, 연결 확인용 PING_SQL 세 개뿐이고, 그 셋에 어떤 write verb도 들어가지 않음이 assert로 고정된다. dispatch 경로는 사람 판정 저장소의 write 권한을 원리적으로 갖지 않는다.
VF 레퍼런스 비교 대상이 자체 참조를 벗어나 storage 계층(Tweetypie)까지 넓어졌다. Rust VF ↔ Scala VF diff는 구현 drift에 편향된다. Tweetypie는 tweet 자체에 이미 붙여 둔 visibility 결정을 갖고 있으므로, 그것과 diff하면 "storage 축의 결정"과 "serving 축의 결정" 사이의 drift가 잡힌다. Safety Level을 TimelineHomeHydration으로 고정하고 prod 기동을 거부하는 것은, 이 harness가 안전망이 아니라 정확성 검증 도구임을 명시한다.
랭킹이 "클릭"을 덜 믿기 시작했다. ClickWeight 0.4 → 0.3과 ContClickDwellTimeWeight 0.0 → 0.4가 같은 크론 sync에서 함께 뒤집혔다. 서빙 가중합에서 click-dwell head 예측에 곱하는 가중이 0.0에서 0.4가 됐다 확인 — xai-value-model/scoring.rs:108. 4세대+22에서 학습 loss에 편입된 head가 검증을 거쳐 서빙에 쓰이게 된 순서로 본다 추정. "클릭했다"보다 "클릭한 뒤에도 머물렀다"가 프로덕션 목적에 더 부합한다는 방향이 코드에 고정된다. Not Interested의 정확한 ×1.1도 명시 부정 신호를 함께 강화하는 방향이다.
VF가 판정에 필요한 데이터를 채우는 hydration 단계가 키마다 요청 상태를 기억하는 구조로 다시 짜였고, 리트윗 원본 포스트도 한 번의 호출 안에서 함께 채우게 됐다 확인 — daily. 같은 커밋에서 Phoenix retrieval two-tower 설정은 preset 카탈로그로 정리됐고, 홈믹서 cold-start 파라미터는 크론 미러에서 한꺼번에 크게 바뀌었다 확인(같은 daily).
커밋 규모는 파일 69개, +3,306/−1,489다 확인 — xai-org/x-algorithm@77d431a. 4세대+31(a707cc2)의 하루 뒤에 나온 커밋이다. 4세대+29부터 +30과 +31을 거쳐 +32까지 이어진 나흘의 흐름이 VF hydration 재작성을 마무리한다.
Edge 8-variant(Follows / Blocks / Mutes / MuteRetweets / BlockedBy / SuperFollows / FollowedBy / SecondDegree)마다 Fetcher<bool>이 하나씩 있다.
이들은 배열 edges: [Fetcher<bool>; Edge::VARIANTS.len()]에 담긴다.
B. retweet.rs는 hydrate를 두 번 부르던 방식에서 한 번 부르고 in-flight로 확장하는 방식으로 단순해졌다. 4세대+31 시점의 evaluate_merging_sources가 다시 작성됐다 확인 — retweet.rs (+562/−110).
전날까지는 filter_tweets.hydrate(request)로 원본 배치를 먼저 채웠다. 이어서 source_ids로 리트윗 소스 후보를 뽑고, 요청 배치에 없는 소스에는 두 번째 별도 filter_tweets.hydrate(...) 호출을 보내 fetched hydration을 만들었다. 마지막으로 두 결과의 outcome을 병합했다.
이번 커밋부터는 filter_tweets.hydrate_with_retweet_sources(request)를 한 번만 호출한다. 이 호출은 내부에서 pure_cores가 land하는 순간 store.join_retweet_sources()를 실행해, pure_cores의 source_tweet_id 중 요청에 아직 없는 것을 request_tweets에 붙이고 Landing::SourcesJoined를 반환한다 확인 — store.rs:308-330.
execute.rs의 스케줄러는 Landing::SourcesJoined를 받으면 inputless() 그룹을 다시 ready에 넣어, fetched 소스에 대해서만 새 키를 클레임하고 확장 배치를 보낸다 확인 — execute.rs:112-118.
inputless() 그룹은 pure_core 의존이 없는 최초 그룹들이다.
Fetcher의 claim이 중복을 거르므로 이미 요청 배치에 있던 키는 다시 요청하지 않는다.
새 메트릭 RetweetSources::{NoSource, InBatch, Fetched}는 record_phase에 라벨로 붙는다 확인 — filter.rs:62-79. 테스트 each_request_kind_labels_its_phase_samples가 세 경우([2] → NoSource, [1,2,3,5] → InBatch, [1,2,3,4] → Fetched)를 고정한다 확인 — retweet.rs:721-738. 두 번째 호출(is_first=false)에서는 record_expanded_batch 메트릭도 남는다 확인 — execute.rs:76-79.
C. store.rs의 인터페이스가 다시 정리됐고, assemble의 결과는 Hydration/HydratedTweet로 바뀌었다.
CallRequest<'p> 구조체가 새로 생겨, 한 그룹을 실제로 호출할 때의 세부를 묶어 담는다 확인 — store.rs:37-46.
offer(&mut self, group) -> Option<CallRequest>는 그룹의 키를 claim해 CallRequest를 만들고 스케줄러에 넘긴다.
Landing::{Answered, SourcesJoined} enum도 새로 생겼다.
land는 pure_cores 회신을 받은 뒤 is_expanding_retweet_sources 플래그가 켜져 있으면 join_retweet_sources()를 실행한다.
assemble은 전날까지의 HydrationOutput 대신 새 Hydration 타입을 반환한다. Hydration의 필드는 viewer, tweets: HashMap<TweetId, HydratedTweet>, has_fetched_sources: bool의 3개다.
HydratedTweet은 candidate, has_failed_node, source_tweet_id, safety_labels를 가진다 확인. 기존 HydrationOutput은 사라지고 Hydration과 HydratedTweet이 그 자리를 대신한다.
D. plan.rs의 Part::Edge(Graph, EdgeDirection)이 8-variant enum을 받는 Part::Edge(Edge)로 통합됐다.확인 — plan.rs:8-20.
Edge::flock()은 flock RPC 파라미터를 Option<(Graph, EdgeDirection)>으로 반환한다. SecondDegree만 None이고, 이 edge는 Wingman으로 통합 라우팅된다.
전날까지 Part::Column이던 Hydrator::RootFollowsViewerSecondDegree는 이번에 Part::Edge(Edge::SecondDegree)로 승격돼, 다른 edge와 같은 fetcher array로 라우팅된다.
Group에 position: usize·readers: Vec<usize> 필드가 새로 생겼다 확인. 어떤 그룹이 land한 뒤 어느 그룹을 다시 시도할 수 있는지 그래프로 미리 계산해 두어, offer→land→ready 스케줄이 그룹마다 O(readers) 시간에 끝난다 확인 — plan.rs:172-207.
E. 새 파일 visibility-filtering/treatment.rs(NEW +119)가 rules의 판정을 thrift, proto, metric 표현으로 사영하는 일을 한곳에 격리한다.treatment.rs는 rules가 내놓은 순수한 Verdict(Withheld / Shown{media, engagement})를 세 종류의 다운스트림 표현으로 사영한다 확인.
decided_rows(&Verdict)는 verdict를 (rule, phase) 튜플 열로 펼치는 이터레이터다.
상수 AGE_VERIFICATION_OPTIONS = [SELFIE, PERSONA]도 이 파일에 고정됐다 확인 — treatment.rs:120-123. blurred_media는 blur prompt가 AGE_VERIFICATION_PROMPT일 때만 이 두 옵션을 응답에 넣는다. 테스트 dashboard_generator_pins_the_rpc_and_verdict_mix_labels는 scripts/dashboard.py를 실제로 읽어, FT_VERDICT_ACTIONS에 라벨 6개가 있는지 assert한다. 이 테스트로 메트릭 라벨과 dashboard가 골든 방식으로 묶인다.
F. VF 클라이언트의 응답 계약이 Thrift Action에서 TweetFieldsResultState로 승격됐다.확인 — evaluated.rs:22-33의 EvaluationResult::decode, vf_client.rs:35-41의 decode_response.
proto의 Outcome::ActionThriftCompact는 Outcome::ResultStateThriftCompact로 이름이 바뀌었다.
상수 MAX_ACTION_BYTES도 MAX_RESULT_STATE_BYTES로 바뀌었고, 값은 그대로 64 * 1024다.
이제 VF가 (Found | Filtered) 결과 상태 자체를 실어 보내, caller가 tweet-service 응답을 직접 구성할 수 있는 형태가 됐다.
응답의 tweet.id 순서 검증은 없어졌다 확인 — vf_client.rs:35-46. 전날까지는 tweet.as_ref() == Some(expected) assert로 순서와 quote context를 검증했다. 이번에는 그 줄이 사라지고 응답 길이만 검증한다. wire format 최적화다.
요청 헤더에 metadata: &tonic::metadata::MetadataMap 매개변수도 추가돼, chunk RPC마다 caller metadata(twittercontext 등)를 전달한다 확인 — vf_client.rs:8-25. 새 헬퍼 EvaluationResult::into_result_state(self, fetched: TweetFieldsResultState) -> Option<TweetFieldsResultState>도 생겼다 확인. 이 헬퍼는 fetched가 NotFound/Failed면 fetched를 우선하고, VF가 Evaluated면 VF 결과로 대체한다 확인 — evaluated.rs:35-47.
embedding 학습률은 emb_learning_rate=0.1(embedding rowwise adagrad)이다 확인 — 같은 파일.
u2u·i2i loss는 기본으로 꺼져 있다(apply_u2u_and_i2i_loss=False) 확인 — 같은 파일.
empty_history_user_dropout_rate=0.1로 설정돼 있다 확인 — 같은 파일.
Candidate max_posts는 combined가 28_672_000, immersive가 40_960_000이다. enable_linear_proj는 combined/immersive에서 False, single-tower nano에서 True다. apply_u2u_and_i2i_loss와 enable_candidate_tower_linear_proj 사이에는 상호 배타 assert가 걸려 있다 확인 — xrecsys_two_tower.py:611-618.
H. 광고 conversion 헤드가 web/app 축으로 나뉘었고, ads_head_masking은 JAX에서 numpy로 옮겨 갔다.phoenix/xrex/data/recsys/ads_head_masking.py(NEW +87)가 phoenix/xrex/models/ads_head_masking.py(−67)를 대체한다 확인. 삭제된 models/ads_head_masking.py patch는 import jax; import jax.numpy as jnp를 썼고, 새로 생긴 data/ads_head_masking.py는 numpy as np만 쓴다.
기존 마스크는 masks["clicked"] / masks["non_negative_clicked"]였다.
새 마스크는 website_clicked / non_negative_website_clicked / app_clicked / non_negative_app_clicked다.
새 검증 validate_ads_head_masking은 ads_head_masking and use_seqpack을 금지한다(packed layout에서는 per-candidate/per-head trained_candidate_mask가 정의되지 않는다) 확인 — recsys_model.py:610-614. ADS_FAMILY_SLICE_HEADS.items()를 순회해 4개 슬라이스 이름을 masks dict 키로 노출하고, 이로써 학습 loss 헤드가 4-way로 넓어진다.
recsys_batch.py도 이에 맞춰 바뀌었다 확인 — recsys_batch.py:471-479. 스키마 컬럼 conversionKeepMask: bool은 conversionKeepBits: uint8이 됐다. null일 때의 fallback은 True에서 CONVERSION_KEEP_WEB | CONVERSION_KEEP_APP = 0b11로 바뀌었다. sample_source 컬럼도 새로 받는다.
이 변경은 4세대+28에서 신설되고 4세대+30에서 cold-start arm-gate에 편입된 HOME_COLD=13 채널의 실제 프로덕션 튜닝으로 보인다 추정.
J. home-mixer/util/composition.rs(−63)가 완전히 삭제됐다. 파일 전체가 지워졌다 확인. 전날까지 이 파일은 diversity stats helper였다. 여기에 Composition {size, unique, max_count, hhi, entropy_norm} 구조체와 from_keys / from_counts / unique_ratio / max_share API가 있었다. response_diversity_stats_side_effect.rs의 사용부는 import path만 use xai_candidate_pipeline::component_library::utils::Composition;으로 바뀌었다. home-mixer/util/mod.rs에서도 pub mod composition;이 삭제됐다. candidate-pipeline 공용 유틸에 이미 같은 이름의 Composition이 있어서, 자기 사본을 지우고 공용 것을 쓰는 중복 제거다. rules/registry Policy(4세대+30)와는 무관하다.
K. Brazil 2026 election filter에 handle 9개가 추가됐다. list 크기와 assert가 함께 갱신됐다(2,786 → 2,795, assert_eq!(...len(), 2786) → 2795) 확인 — brazil_2026_election_filter.rs:5849-5852. 추가된 handle은 주석 기준으로 @linoaa, @LPVellozo, @erikakokay, @douglascamargoo, @Anadelso_p, @zeosmaruniao, @siandrade11777, @tiagopiaui22, @gloriabsoares다. 로직은 바뀌지 않았다.
의미 이번 커밋에는 네 갈래의 사실이 겹친다.
VF hydration이 도중에 다시 확장하는 능력을 갖췄다. 4세대+29부터 +32까지 네 커밋에 걸친 닷새 동안 데이터 기반 파이프라인 골격과 retweet.rs 모듈 추출이 완성됐고, 이번 커밋에서 Fetcher<V> trait이 더해져 claim이 idempotent해졌다. pure_cores가 land하는 순간 in-flight로 request_tweets를 확장하고 inputless 그룹을 다시 보내는 Landing::SourcesJoined 흐름이 성립했다. hydrate를 두 번 부르던 패턴은 한 번 부르고 in-flight로 확장하는 패턴으로 단순해졌다. NoSource / InBatch / Fetched 메트릭이 라벨로 잡혀, 확장 배치가 얼마나 자주 생기는지도 관측할 수 있게 됐다. VF 클라이언트 응답도 Thrift Action에서 TweetFieldsResultState로 승격돼, caller가 tweet-service 응답을 직접 구성할 수 있는 형태로 넘어왔다. treatment.rs가 rules 결과를 thrift/proto/metric으로 사영하는 일을 한 파일로 격리하고, dashboard.py와 6개 metric label을 골든 테스트로 묶은 것이 이 단순화의 마무리다.
Phoenix retrieval two-tower가 카탈로그로 승격됐다. 전날까지는 xrecsys_two_tower.py 파일이 있었고, 4세대+6에서 nano config에 qk_norm=True·attn_logit_cap=-1·right_anchored_rope=True를 넣고, 4세대+14에서 base 기본값을 qk_norm=True·attn_logit_cap=-1로 바꾸고, 4세대+21에서 H100 attn 커널을 pallas_ranker_varlen_attn에서 cutedsl_ranker_varlen_attn으로 바꾼 소품 수정이 있었다. 하지만 registry 카탈로그 자체는 고정되지 않은 상태였다. 이번 커밋에서 6개 preset(기본, immersive-only와 그 GB300판, combined의 H100판과 GB300판, nano)이 MODEL_CFGS에 고정되면서, 프로덕션 데이터 파이프에 연결되도록 이관됐다 추정. 특히 SID-only(포스트 임베딩 대신 6-level codebook hash), seqpack, H100/GB300 하드웨어 프리셋, Home과 Immersive의 두 head 공유가 한 파일에 정리됐다. 4세대+13에서 관측된 "DPA 광고 제품에도 sid_codes 배선"과 짝을 이루면, SID 어휘 체계가 유기 post, 광고 DPA, retrieval two-tower의 세 사용처를 하나로 묶을 잠재력이 커진다.
광고 conversion 헤드가 web/app 축으로 분화됐다. 4세대+6의 delayed feedback split과 4세대+24의 ads_head_masking 통합 노브가 이번 커밋에서 학습 그래프 안의 float multiplier에서 데이터 로더가 미리 계산한 boolean mask로 옮겨 갔다. 이 이동의 의미는 두 가지다.
그래프가 sample_source에 의존하지 않게 해 컴파일 안정성을 확보한다.
web conversion 헤드와 app(MMP) conversion 헤드를 분리 학습해, keep_web / keep_app 두 축의 sample subset을 각기 다르게 쓸 수 있다.
Mask semantics로 보면 late-window twin suppression 로직은 유지하고 계산 위치만 바꾼 정확한 리팩터다. use_seqpack과의 상호 배타 assert가 새로 걸린 것은 seqpack이 candidate 슬롯 대응을 무너뜨리기 때문이다. proto에 ADS_ATTRIBUTED_MACT_MMP_{CLICK, VIEW}_INSTALL = 194, 195가 새로 생기면서, MMP 측 app-install conversion을 학습 헤드에 정식으로 편입할 채널이 생긴다.
홈믹서 cold-start가 프로덕션 파라미터로 세팅됐다. 4세대+28에서 신설된 HOME_COLD=13 채널이 4세대+30에서 cold-start arm-gate에 편입된 데 이어, 이번 커밋에서 파라미터가 잡혔다. Phoenix cold retrieval 채널이 0에서 200 posts로 열렸고, MaxPostAge는 48h에서 2h로 극단적으로 짧아졌다. FollowerCap은 1000에서 50k로 사실상 해제돼 author 풀이 폭증했다. ImpressionThreshold는 1000에서 200으로 낮아져, 홈 노출이 200 미만인 포스트만 cold-start 슬롯 후보가 되도록 대상을 좁혔다. Thompson sampling도 켜졌다. 신선도는 2h로 극단적으로 엄격하고 author 풀은 넓게 열린 조합이 작동한다. 이 튜닝이 A/B에서 유지되는지가 다음 크론 sync에서 관측할 지점이다.
Brazil 2026 election filter에 handle 9개를 추가한 것은 로직 변경 없는 리스트 유지보수다. Composition.rs 삭제는 candidate-pipeline 공용 라이브러리 재사용에 따른 중복 제거다.
4세대+33 (2026-10-01, b79b947) — VF에 limited-actions policy 시스템 신설 (두 파일 703줄) + gRPC caller-identity 로깅 (126줄) + LimitedEngagement가 복수 reason 축적 구조로 승격 + abuse-enforcement-service 설정 변경 자가재시작 게이트 신설 (173줄) + Kafka producer가 boot config snapshot 기반으로 재배선 + phoenix-rankall 독립 CLI config 모듈 분리 (84줄, SID 백필 QPS 캡 800k) + VF brazil_election_legal/drop/undesirable 라벨 등록 + fallback_cache가 quick_cache sync + OptionsBuilder로 재작성
이 커밋부터 VF의 판정 응답에는 제한 사유뿐 아니라 사유마다 막을 액션 목록과 액션별 로컬라이즈 문구가 담기고, 한 판정에 제한 사유가 여러 개 쌓일 수 있다 확인. 클라이언트가 자체 액션 매핑과 로컬라이제이션을 유지할 필요가 없어졌다는 뜻이다. 이 커밋은 daily에서 다뤘다.
새 파일은 limited_actions_copy.rs(NEW +397)와 params/limited_actions_policy.rs(NEW +306)다. 전날 커밋에서 신설된 treatment.rs는 verdict를 Thrift Action으로 사영하는 일을 맡았지만, Thrift LimitedEngagements payload에는 사유 enum만 담겼다. 이 커밋에서 두 파일이 신설되면서 payload가 "사유 → 액션 리스트 → 로컬라이즈 Prompt"의 전개를 담게 된다.
LimitedActionsPolicies::resolve가 FeatureSwitches 4개 키로 reason → Vec<LimitedAction> 매핑을 조립한다 확인 — limited_actions_policy.rs:48-115.
키는 limited_actions_policy_limited_actions·limited_actions_policy_copy_namespace·limited_actions_policy_prompt_type·limited_actions_policy_prompt_learn_more_url이다.
즉 뷰어에게 BlockedViewer와 ReadonlyViewer가 동시에 걸리면 두 사유가 모두 payload에 실린다. BlockedViewer는 19개, ReadonlyViewer는 14개 액션을 제한한다. 이 커밋 전까지는 두 번째 이후의 engagement 매치가 dead-code였지만, 이 커밋부터는 실제로 축적된다. StaleTweet과 BlockedViewer, ConversationControl과 RootAuthorBlockedViewer 같은 다른 reason 조합도 똑같이 작동한다.
LimitedEngagementReason::limited_actions_string() const fn이 새로 생겨, 각 reason을 번들 key 접두사로 매핑한다 확인.
Verdict가 Shown { engagement: Some(..) }인 경로에서는 사유마다 액션 리스트와 Prompt를 조합해, LimitedEngagements payload에 액션별 로컬라이즈 Prompt를 담아 wire로 내보낸다. 과거 "사유 enum만 넘기던 payload"를 완전히 교체한 것이다.
D. VF가 gRPC 호출자가 어느 서비스인지 인증서로 식별해 기록한다.
새 파일 VF caller_identity.rs(NEW +126)는 gRPC 피어의 x509 cert로 S2S identity를 뽑아 로깅한다. 신설 메트릭 vf_requests_by_caller{caller_identity, rpc, deadline}을 발행한다 확인 — caller_identity.rs:1-56.
caller_identity는 피어 cert의 SAN URI(twtr:svc:<service>:<service>:<env>:<dc> 형식)가 1순위이고, 없으면 CN이 2순위다. 둘 다 twtr:svc: 접두사가 없으면 unknown이다.
deadline은 gRPC timeout 메타데이터가 있는지에 따라 present|absent다.
세 endpoint의 요청 핸들러는 맨 앞에서 caller_identity::record(Endpoint::XXX, &request)를 호출한다. 호출 위치는 evaluate_tweets.rs:46 외 2곳이다. 테스트 3개가 identity 추출 로직을 고정한다: SAN URI 우선, CN 폴백, non-S2S cert → None 확인.
E. EvaluateTweets 엔드포인트가 요청을 처리하면서 제한 액션 정책을 조립한다.
rules/registry.rs에 brazil_election_legal/drop/undesirable 라벨이 등록됐다. 이 라벨은 country_specific_nsfw_content_gating 정책 트리의 두 자리(일반 drop 리스트와 라벨 drop 리스트)에 추가됐다 확인 — registry.rs:513-517, 648-652.
전날 커밋(4세대+32)에서는 홈믹서의 Brazil 2026 handle 리스트가 2,786 → 2,795로 확장됐다. 이어 이 커밋에서는 VF가 라벨링 서비스에서 brazil_election_legal 라벨이 붙은 트윗을 받으면 drop 경로로 처리하도록 정식 등록했다. 홈믹서 쪽 handle 필터와 VF 쪽 label 필터라는 두 축의 공조가 코드로 명시됐다.
G. abuse-enforcement-service에 Kafka 설정이 바뀌면 스스로 재시작할지 판단하는 게이트가 생겼다.
Check::Invalid: 변경은 있으나 validate(live)가 실패한 상태다. 재시작하지 않고 에러 로그만 남긴다.
Check::Restart(Vec<String>): 변경이 있고 validate도 통과한 상태다. 변경된 경로 리스트를 반환한다.
기본으로 감시하는 JSON pointer 경로는 /kafka/consumer/topic_labels, /topic_labels, /kafka/producer 3개다. override key restart_on_config_change가 배열이면 그 경로들을 쓴다. 상태는 CONFIG_RESTART_PENDING{valid=true|false} 메트릭으로 보고한다.
H. Kafka producer가 기동 시점의 설정 스냅샷을 기준으로 만들어지게 됐다.
abuse-enforcement-service lib.rs(+325/−55)의 Kafka producer 바인딩이 boot config snapshot 기반으로 바뀌었다. 시그니처는 build_kafka_producers(cfg, dynamic_config) → build_kafka_producers(cfg, boot_config)로 바뀌었다 확인 — lib.rs:831-840, 1385-1392.
boot_config는 dynamic_config.config().unwrap_or(serde_json::Value::Null)로 캡처한 기동 시점 스냅샷이다. producer를 빌드할 때는 이 Value를 참조해 kafka_producers_from_config(Option<&Value>)로 producer spec을 추출한다. 이 함수는 growthbook.rs에 새로 생겼다.
이 변경의 의미는 Kafka producer가 boot 시점 설정에만 반응한다는 것이다. 운영 중 설정 변경은 영역에 따라 다르게 처리된다.
non-kafka 영역은 live reload로 반영한다.
kafka 영역은 restart_on_config_change 게이트로 서비스 재시작을 유도한다.
hot swap은 없다.
I. phoenix-rankall의 CLI 설정이 별도 모듈로 분리됐다.
새 모듈은 config/mod.rs(NEW +84)다. 바이너리 이름은 xai-recsys-rankall이고, env var로 백킹된 CLI 인자가 20여 개다 확인 — config/mod.rs:9-96.
눈여겨볼 상수는 다음과 같다.
KAFKA_BOOTSTRAP="/s/kafka/main-2:kafka-tls": wily service URL이면 /로 시작한다.
SCORE_PREFIX="phoenix.HomeExperiment1Lap7.": Phoenix 랭킹 서비스는 Home 지면 Experiment1 Lap7(실험 iteration 번호로 추정 추정)의 점수를 Kafka 토픽에 발행한다. phoenix-rankall은 이 접두사의 점수만 소비한다.
이 커밋 전까지의 캐시 구조가 quick_cache sync Cache와 OptionsBuilder 하나로 단순화됐다 확인 — fallback_cache.rs:1-50.
이전 구조는 64개 shard × Mutex-wrapped unsync Cache에 제너레이션 카운터와 CacheEntry 래퍼가 붙은 형태였다.
generic parameter도 FallbackCache<K, V>에서 FallbackCache<V>로 좁아졌고, 키는 u64로 하드코딩됐다.
이 리팩터로 매번 with_locked_shard 패턴으로 Mutex를 잡던 호출이 전부 걷혔다.
K. 나머지는 작은 변경이다.
VF rules/golden_corpus/oon_tweet_label.rs(+76/−3)에서 golden corpus가 소폭 확장됐다.
VF params/client_switches.rs(+63/−13)에서는 테스트 fixture가 scala_client_switches.yml과 limited_actions_policy.yml 두 파일을 로드하도록 확장됐다.
abuse-enforcement-service growthbook.rs(+132/−32)에 kafka_producers_from_config 함수가 새로 생겼다.
abuse-enforcement-service rules.rs(+65/−51)는 rules_cache.resolve(entity_type, rules_override.as_deref())를 as_ref()로 바꾼 lifetime 미세 조정이다. 의미 변화는 없다.
Phoenix python/training/xai-checkpointing/xai_checkpointing/orbax_encrypted.py(+42/−11)는 체크포인트 암호화를 리팩터했다. 피드 랭킹과 직결되는지는 확인되지 않았다.
home-mixer params/param.rs(+4/−4)는 cron sync 시각만 바뀌었고, 실질 값은 그대로다.
의미 이 커밋에서는 네 가지 사실이 겹친다.
"제한 사유" 결정권이 VF 안으로 끌어들여졌다. 이 커밋 전까지 VF가 Verdict의 engagement 축에 담은 정보는 사유 enum 하나였다. 액션 26종 가운데 무엇을 막을지, UI에 뭐라고 표시할지는 하류(home-mixer, 클라이언트)의 몫이었다. 이 커밋부터는 VF 안에서 다음 세 가지가 이뤄진다.
LimitedActionsPolicies로 사유 → 액션 리스트 매핑을 FeatureSwitches 기반으로 갖는다.
LimitedActionsCopy로 (액션, locale, country) → 로컬라이즈된 Prompt를 갖는다.
treatment.rs::thrift_action이 두 정보를 조합해, Thrift payload LimitedEngagements 안에 액션별 Prompt 리스트를 직접 담는다.
클라이언트는 자체 로컬라이제이션이나 액션 매핑 테이블을 유지할 필요가 없다. 사유 → 액션 → 문구의 모든 결정이 서버 쪽 데이터로 선언된다. 이 변화는 4세대+32에서 treatment.rs가 rules → thrift/proto/metric 사영 레이어로 격리된 것과 짝을 이룬다. 전날 커밋이 "wire 사영 지점을 한 파일로 모은" 것이라면, 이 커밋은 "그 사영 지점을 통과하는 데이터의 풍부함을 UI 수준까지 끌어올린" 것이다. 운영 상태(block·conversation control·stale)에 따라 누를 수 있는 UI 버튼이 달라지는 동작이, 이제 서버 주도 선언으로 완성됐다.
LimitedEngagement가 복수 사유 축적 구조로 바뀌면서, 같은 트윗이 여러 제한 사유를 동시에 가질 수 있는 상황이 명시적으로 모델링된다. 이 커밋 전까지는 rules가 engagement를 한 번만 결정하면 그다음 ActionSpec::LimitedEngagement 매치는 drop(dead-code 경로)이었다. 이 커밋부터는 rules가 engagement를 여러 번 결정하면 limit.value.add(*reason)로 Vec에 축적된다. 즉 BlockedViewer와 ReadonlyViewer가 겹친 뷰어라면 두 리스트를 모두 payload에 실어 하류가 선택하도록 결정권을 넘긴다. 이때 BlockedViewer는 19개, ReadonlyViewer는 14개 액션을 제한한다. ConversationControl과 StaleTweet, RootAuthorBlockedViewer와 BlockedViewer 같은 조합도 가능해진다. UI 입장에서는 "이 뷰어·이 트윗의 조합에서 Reply 버튼이 왜 비활성인가"라는 질문에 "BlockedViewer 때문이기도 하고 StaleTweet 때문이기도 하다"고 답할 수 있는 경로가 생긴다.
abuse-enforcement-service가 "boot 시점 설정 스냅샷"을 모델로 전환했다. Rust 재구성이 공개된 2026-09-23 이후 이 서비스는 DynamicConfig 객체로 live config를 써 왔다. 이 커밋부터 Kafka producer 바인딩은 boot 시점의 JSON Value 스냅샷에만 의존한다. 그 대신 운영 중 kafka 설정(토픽, SASL 인증, bootstrap 서버)이 바뀌면, restart_on_config_change 모듈이 서비스를 재시작시켜 새 설정을 반영한다. 이 방식이 하는 일은 셋이다.
kafka producer 라이프사이클을 간단하게 유지한다(hot swap 없음).
설정 변경을 로그/메트릭에 명확히 드러낸다(재시작 이벤트).
validate(live)를 통과하지 못하면 재시작하지 않는 fail-safe를 제공한다.
이 재시작은 핵심 설정 변경에만 적용되고, 그 밖의 설정은 여전히 live reload 경로로 반영된다.
phoenix-rankall이 실험 그룹 식별자와 QPS 상한을 공개했다. CLI config 모듈 분리는 조직적인 리팩터로 보이지만, 그 과정에서 두 수치가 노출된다. 첫째는 SCORE_PREFIX = "phoenix.HomeExperiment1Lap7."이다. Phoenix 랭킹 서비스가 Home 지면의 "Experiment1 Lap7"이라는 특정 실험 그룹의 점수를 Kafka 토픽으로 발행하고, phoenix-rankall이 그 특정 접두사의 점수만 소비한다는 뜻이다. "Lap7"이라는 명명은 실험 iteration 번호로 보인다 추정. 둘째는 SID_BACKFILL_QPS_CAP = 800_000이고, SID 백필의 QPS 상한이 800k라는 숫자다. SID 백필은 모든 포스트에 적용되지 않고, 특정 창(최대 1시간, 최소 3분)의 포스트만 처리하도록 설계돼 있다. 여기에 하루에 보여지는 포스트 수가 수억~수십억 규모라는 점을 더하면 한 그림이 선다. SID 임베딩 테이블이 최근 1시간 신선도의 포스트 중 QPS 800k 범위로 커버된 서브셋에만 유지된다는 그림이다. 신선도 창을 벗어난 포스트는 SID가 없거나 stale 상태로 남는다.
VF의 brazil_election_legal/drop/undesirable 라벨 등록은 전날 커밋의 홈믹서 쪽 Brazil 2026 handle 리스트 확장(2,786 → 2,795)과 짝을 이룬다. 홈믹서는 핸들 리스트로 작성자 축에서 걸러내고, VF는 라벨링 서비스(Grok 또는 사람 지정)의 신호로 라벨 축에서 걸러낸다. 선거 기간에 두 경로가 함께 작동한다는 선언이 코드로 명시된 셈이다. fallback_cache.rs의 재작성은 Mutex 접촉을 걷어내는 성능 리팩터다. 이는 shard 분배와 생성 로직을 라이브러리에 위임한 결과다.
4세대+34 (2026-10-02, 76843a5) — generative SID retrieval microservice 공개 (home-mixer SidSource + Phoenix 전용 Transformer 학습 config/모델/트레이너/러너 + row-sharded 임베딩 CUDA 커널 묶음 3,180줄) + Phoenix proto 재편(LLM_JUDGE_* 108/109 재등장, WEB_CONV_VALUE_USD/RATIO, viewer×advertiser engagement/conversion counts, Retrieve 요청에 exclusion bloom-filter 축) + VF vf_requests_by_caller에 outcome 라벨 추가 + home-mixer cold-start·engagement counts hydrator 축 단순화
X가 generative SID retrieval을 별도 microservice, Phoenix 전용 Transformer 학습·서빙 코드, home-mixer 후보 소스로 묶어 한 커밋에 공개했다 확인(daily). 새 후보 소스는 기본값이 꺼져 있다 확인(같은 daily).
마지막 조건의 has_post_signals와 post_signal_ids는 SimclustersSource와 완전히 같은 함수를 재사용한다 확인 — sid_source.rs:14. 두 OON retrieval 소스는 동일 seed 집합(유저의 positive engagement 포스트들)에서 분기한다.
인퍼런스 러너 sid_retrieval_runner.py는 두 축을 분리해 담는다. 하나는 beam search 기반 SID 생성이고, 다른 하나는 SidPostIndex/SidBeamResolver로 SID → post_id를 역해상하는 축이다.
러너 플래그 beam_width와 decode_levels가 각각 beam 폭과 생성 깊이를 제어한다.
decode_levels < 6이면 접두사 수준에서 중단하고 접두사 공유 집합을 반환한다(prefix retrieval).
이것이 home-mixer SidSourceMinPrefixDepth=3과 짝을 이룬다. 서버는 3~6단계까지 생성하되, 3단계에서 매칭된 포스트는 shared_prefix_depth=3을 score로 리턴할 수 있다.
_CorpusView는 post_ids_with_sentinel·author_ids_with_sentinel·sentinel_idx를 노출하고, 서빙 시점의 코퍼스는 사전 빌드된 SID 트라이와 leaf의 post_id 집합이며 beam search가 트라이 위를 돈다 추정.
이 설계는 Google TIGER(Rajput et al., Recommender Systems with Generative Retrieval, NeurIPS 2023, 논문) · Alibaba PailitaoGR(2025) · Kuaishou OneRec(2025) · Tencent BARGE(2026) · ByteDance GEAR(arXiv 2026-09-30) 라인과 자리가 겹친다. 이들과 비교한 X판의 특수성은 네 가지다.
6-level × 256-codebook을 쓴다. TIGER는 3-level × 256 codebook에 충돌을 가르는 네 번째 코드 하나를 더한다 확인 — arXiv 2305.05065 (논문, 2023).
1023토큰 액션 히스토리를 그대로 조건으로 삼는다.
shared_prefix_depth를 상위 랭킹 스코어로 넘긴다(TIGER는 생성 확률 사용).
vocab 설계를 이미 two-tower와 공유해, 동일 유저·아이템·작성자 임베딩 공간 안에 두 retrieval 축을 세웠다.
C. Phoenix에 행(row) 축으로 샤드된 임베딩 테이블을 다루는 CUDA 커널 묶음이 생겼다.
SidLookupService.LookupSids(repeated int64 post_ids) → repeated PostSids { repeated int32 codes }로 post_id → SID 코드 배열 매핑만 제공한다.
2026-09-04(4세대+15)에서 사라졌던 lookup 사이드카가 별도 서비스로 돌아왔다.
E. VF의 호출자별 요청 메트릭에 요청 결과 라벨이 추가됐다.
변경은 VF caller_identity.rs(+90/−15)에 있고, 새 라벨 축은 outcome이다. vf_requests_by_caller 메트릭은 어제의 {caller_identity, rpc, deadline} 3-라벨에서 이 커밋의 4-라벨 {..., outcome}로 확장됐다 확인 — caller_identity.rs:16-62.
Outcome { Success, Failure, Cancelled } 3-variant enum이 새로 생겼다.
record() 함수는 이제 CallerRequest 가드 구조체를 리턴하고, must_use 애너테이션이 붙었다.
핸들러가 mark_success()·mark_failure()를 호출하지 않고 drop하면 Cancelled로 집계된다.
메트릭은 Drop impl이 발행한다(RAII 패턴).
새 테스트 dashboard_generator_pins_the_requests_by_caller_metric은 대시보드 Python 스크립트와 Rust 상수를 함께 pin한다. 스크립트의 REQUESTS_BY_CALLER_METRIC·REQUESTS_BY_CALLER_NO_DEADLINE_FILTER·success/non-success filter 네 상수가 Rust 테스트의 통과 조건이다. 메트릭 정의가 Rust와 대시보드 두 축에서 동시에 drift 검사를 받는 설계다.
F. home-mixer의 cold-start 부스트와 engagement counts hydrator 쪽 조건이 단순해졌다.
Phoenix rowwise_adagrad.py(+14/−9)에는 row_emb 어댑터 변경이 들어갔다.
abuse-enforcement-service/overturn_hold.rs(+22/−22), thunder/post_store.rs(+20/−20), phoenix-rankall/store/base.rs(+12/−12)는 테스트와 어댑터를 조금 고쳤다.
home-mixer/params/param.rs(+31/−1)는 위의 SID source param 5개를 추가한 몫이다.
의미
이 커밋에서는 세 가지 사실이 겹친다.
X가 generative SID retrieval을 "별도 microservice + 전용 Transformer 학습·서빙 스택 + home-mixer 후보 소스 배선"의 완결된 세 축으로 공개한 첫 커밋이다.
지금까지 X의 SID는 간접적으로만 모습을 비쳤다. 하나는 two-tower 아이템 타워의 입력 피처(2026-09-30 공개된 xrecsys_two_tower.py의 use_post_sid 플래그)였다.
다른 하나는 phoenix-rankall의 백필 QPS 800k 상한(어제 4세대+33에서 SID_BACKFILL_QPS_CAP = 800_000)이었다.
그러나 지금까지 SID를 직접 생성해서 그 결과를 candidate로 리턴하는 라인은 공개되지 않았다.
이번 공개는 아래 세 가지로 이뤄진 전체 체인을 한 커밋에 묶었다.
Phoenix 쪽에 전용 Transformer 모델(8-layer, GQA 20:4, 1030-length context, 6-level × 256-codebook)의 config/모델/트레이너/러너 4-파일을 신설했다.
별도 microservice의 gRPC client와 request shape(seed_post_ids, prefix retrieval 축 min_prefix_depth=3), 응답 후보의 shared_prefix_depth 점수를 공개했다. 그 microservice는 xai-recsys-sid-retrieval.prod.fou.twitter.biz다.
home-mixer SidSource로 PhoenixMOESource와 같은 ServedType으로 라벨된 후보를 흘려보낸다.
EnableSidSource=false로 기본 off이므로, 실 트래픽 적용은 FeatureSwitches cron에서 ON 되는 날이 관측 지점이다.
two-tower retrieval과 SID generative retrieval이 "서로 대체"가 아니라 "동일 vocab 공간 위의 두 레이어"로 설계됐다.
두 모델은 같은 유저·아이템·작성자 hash vocab(100M · 30M · double hashing scales/biases 똑같음)을 공유한다. 이 사실은 xrecsys_sid_retrieval.py:30-45와 2026-09-30 공개된 xrecsys_two_tower.py의 수치를 대조하면 드러난다.
중요한 이유: generative retrieval은 SID 생성 시간 복잡도(beam_width × decode_levels × 코드북 크기)와 코퍼스 측 SID 인덱스 유지 비용이라는 두 비용 축에서 비싼데, two-tower가 ANN이 돌릴 수 있는 broad recall을 먼저 커버한 뒤 SID retrieval이 접두사 공유도 축에서 보강 recall을 담당하는 상보 구조를 선택한 자리로 보인다 추정.
같은 vocab이면 양쪽 모델이 공유 임베딩 테이블로 돌 수 있는데, 이 커밋 시점에 공개된 config는 양쪽이 각자 테이블을 가진다. 공유 임베딩으로 통합되는지는 다음 커밋에서 관측해야 한다.
row-sharded 임베딩 CUDA 커널 묶음은 Phoenix 임베딩 설계 자체의 2축 확장이다.
3,582줄짜리 row_emb가 generative retrieval 모델만을 위해 추가된 것은 아니다. 다만 이번 커밋 묶음의 맥락에서 보면, SID generative retrieval이 요구하는 거대 vocab 임베딩(100M · 30M · 6-level × 256 codebook)이 트리거라는 해석이 자연스럽다.
기존 async_emb는 column-sharded이고 신규 row_emb는 row-sharded인데, 공유 adapter(recsys_async_emb.py)로 두 샤딩 전략 중 하나를 선택하게 했다. 이로써 모델별로 최적 샤딩을 고를 수 있는 자리를 마련한 셈이다.
two-tower는 유저·아이템 타워 각각 column-shard로 돌 수도 있지만, SID generative 모델은 generation 축에서 각 토큰이 자기 vocab 범위만 참조하므로 row-shard가 더 compact한 통신 패턴을 낳는다 추정.
LLM_JUDGE_* action이 다시 나타난 것은 별개의 흐름이다. 15일 전 자리를 비웠던 두 action이 번호를 바꿔(107/108 → 108/109) 재등장했다. 이 사실은 "LLM judge를 action 축에 담아 학습 target 중 하나로 소비"하려는 방향이 재조정 후 다시 올라왔음을 뜻한다. 번호가 바뀐 이유는 그 사이 107 자리를 CLIENT_TWEET_LIVE_STREAM_REPLY가 영구히 차지했기 때문이고, 108도 함께 비웠다가 재사용했다. 모델 학습 config나 wire의 소비처는 이번 커밋에 나오지 않았고, proto만 자리를 선점했다. 어느 head 가중치에, 어느 config의 target list에 들어가는지는 다음 창에서 관측할 지점이다.
4세대+35 (2026-10-03, b412112) — SID retrieval serving crate 공개(xai-recsys-sid-retrieval 신규 Rust 크레이트 7-파일, parquet 스냅샷 폴링 + 6-level 트라이 + round-robin 인터리브) + home-mixer PopularPostsSource 신설(top-K 인기 저자 → Thunder in-network 조회 → OON 보강) + 광고 브랜드 안전 V2 verdict 기본 ON 플립(false → true) + author Grok 라벨 fallback 신설(EnableAdsAuthorBrandSafetyFallback, 기본 off) + VF staging 리플레이 서브시스템 신설(visibility-filtering/staging/* 2,350줄, recording · replay_corpus · capture · serve) + Phoenix row-emb 체크포인트 복구 유틸(row_embedding_restore.py 488줄) + Phoenix gRPC server 지연 메트릭 분리(grpc_server_latency.rs 361줄)
이 커밋으로 학습 코드와 클라이언트만 공개돼 있던 semantic ID 기반 후보 검색에, 실제 요청을 받아 응답하는 서버 크레이트가 붙었다 확인. home-mixer에는 인기 저자의 최근 포스트로 팔로우 밖 추천을 보강하는 소스가 기본 off로 생겼고, 광고 브랜드 안전 V2 판정 스위치의 미러 기본값은 false에서 true로 바뀌었다 확인(daily).
A. generative SID retrieval을 서빙하는 Rust 크레이트 phoenix/crates/serving/xai-recsys-sid-retrieval/가 파일 7개, 1,062줄로 새로 생겼다. 어제 공개(76843a5)에서는 Phoenix 쪽의 학습 config, 모델, 트레이너, 러너와 home-mixer 쪽 SidSource 클라이언트가 나왔다. 그러나 실제 요청을 받아 응답을 돌려줄 서버 코드는 비어 있었다. 오늘 그 자리가 공개됐다 확인 — phoenix/crates/serving/xai-recsys-sid-retrieval/.
파일별 규모는 src/service.rs:208 · src/index.rs:223 · src/loader.rs:170 · src/http_api.rs:109 · src/main.rs:115 · src/seed_sids.rs:114 · src/metrics.rs:79다. 별도 proto 크레이트 xai-recsys-sid-retrieval-proto(sid_retrieval.proto:46)도 있다.
// retrieve_for_seed(seed_post_id, seed, params, excluded):
// depth := max_prefix_depth..=min_prefix_depth 역순
// level_range := prefix_range(seed.key, depth) 안의 by_key 슬라이스
// consumed := 어제 더 깊은 depth에서 쓴 inner_start..inner_end 영역
// level := level_range \ consumed \ excluded(seed post_id들)
// if level.len() > remaining: select_nth_unstable_by(post_id DESC)로 자름
// level sort by post_id DESC
// emit Candidate(post_id, author_id, seed_post_id, shared_prefix_depth=depth)
// consumed := level_range
이 알고리즘에서 읽히는 성질은 세 가지다.
각 포스트는 "그 포스트가 공유한 가장 깊은 접두사 수준에서 한 번만" emit된다. 깊이 6에서 매칭된 포스트는 깊이 5~3 레벨에서 다시 뽑히지 않는다.
같은 depth 안에서는 post_id가 큰(=최근) 포스트를 우선한다.
seed post 자신은 전역 excluded set로 걸러진다(다중 seed일 때 seed₁의 결과에 seed₂가 섞이지 않는다).
seed가 여럿이면 각 seed의 후보 리스트를 round-robin(seed 1 → seed 2 → ... → seed N → seed 1 → ...)으로 번갈아 뽑으면서 전역으로 중복을 없앤다 확인 — src/index.rs:196-222. max_results에 이르거나 더 뽑을 후보가 없으면 멈춘다. 그 결과 포지션 바이어스가 특정 seed로 쏠리지 않도록 균등화된다.
스냅샷 로더는 parquet 스냅샷 경로를 주기적으로 확인하고, 경로가 바뀌면 인덱스를 전부 다시 적재한다 확인 — src/loader.rs:1-170:
입력은 Arrow Parquet 파일(post_sid_snapshot.parquet)이고, 컬럼은 post_id: int64, author_id: int64, post_sid: list<int64> 세 개다. 레코드 배치 크기는 BATCH_SIZE = 65_536이다.
요청의 seed_post_ids에서 중복을 없애고 max_seeds(기본 200)까지만 남긴다.
모든 seed에 대해 로컬 트라이에서 lookup(post_id)를 시도하고, 없는 것만 SidLookupService.lookup(missing)으로 batch 호출한다(병렬). 그 뒤 각 seed를 세 경로(Index / SidService / Missing) 중 하나로 분류해 메트릭 라벨로 발행한다.
B. home-mixer가 top-K 인기 저자를 Thunder in-network 조회에 "가짜 following"으로 넣어 OON 추천을 보강하는 소스를 새로 만들었다. 추가된 파일은 sources/popular_posts_source.rs(+116), util/popular_authors.rs(+229), clients/popular_authors_store_client.rs(+79)이고, 모두 신규다.
PopularAuthorsCache.maybe_spawn_refresh(now_ms)를 호출해 백그라운드 리프레시를 건다. 리프레시는 필요할 때만 돌고, token-held AtomicBool 하나로 중복 실행을 막는다.
top_author_ids(top_k)로 상위 K명(기본 1000)의 author_id를 뽑는다.
Thunder에 GetInNetworkPostsRequest { user_id, following_user_ids=[top K authors], max_results=500, per_author_limits: {3, 0}, exclude_tweet_ids=seen_ids, algorithm=ThunderAlgorithm, debug=false, is_video_request=false }를 보낸다. Thunder는 그 K명을 유저가 팔로우하는 것처럼 간주해, 자기 in-network 포스트 저장소에서 recency순으로 돌려준다.
응답 포스트에는 served_type=ForYouPhoenixRetrieval 라벨을 달아 PostCandidate로 돌려준다. 이 라벨은 PhoenixSource와 같은 라벨이다.
설계상 이 소스는 ForYouPhoenixRetrieval 라벨(= PhoenixSource의 라벨)을 재사용한다. 그래서 다운스트림 scorer·filter 입장에서는 "Phoenix two-tower retrieval이 추가로 돌려준 OON 후보"처럼 보인다. 그러나 메커니즘은 임베딩 유사도가 아니라 "follow graph의 global popularity centroid"다. 사실상 follow graph의 cold-start/exploration용 global fallback이다. "유저가 아무도 안 팔로우해도 Thunder가 뭔가 응답하도록" 또는 "유저 engagement 신호가 너무 적어서 다른 OON 소스가 결과를 못 돌려줄 때 공백을 메우도록" 쓰이는 전형적 global popularity backfill 패턴이다 추정.
EnableAdsBrandSafetyVerdictV2가 false → true로 바뀌었다. 이 스위치는 4세대+2(2026-08-17, b089ce6)에 false로 생겼고, 4세대+8(2026-08-25)부터 true였다가 4세대+22(2026-09-16)에서 false로 돌아갔다 확인 — b089ce6, 0d3cdd8, fad2f71의 param.rs. 이번이 두 번째 ON 전환이다. 이 값은 미러 sync 시점 last sync 2026-10-01T16:00:46Z → 2026-10-02T16:00:41Z에 올라왔다. 2026-10-02 중 어느 시점의 프로덕션 cron에서 올라간 상태를 미러한 것으로 보인다.
EnableAdsAuthorBrandSafetyFallback이 새로 생겼다(기본 off). 작성자 수준의 Grok 라벨(GrokSfa/GrokNsfaLimited/GrokNsfa)은 있는데 포스트 수준 라벨이 없을 때, 작성자 라벨을 포스트 라벨로 fallback 삽입하는 경로다.
Verdict 4-tier(Safe / LowRisk / MediumRisk / HighRisk)는 광고 블렌더 측 AdAdjacencyControl.brand_safety_risk와 대응하는 인접성 매트릭스에서 소비된다. 이 매트릭스는 4세대+10의 MultiRiskAdsBlender 항목에서 볼 수 있다. 즉 광고 지면의 "포스트 안전도 → 광고 삽입 가능 여부" 결정 축이 오늘부로 기본 소스로 켜졌다.
D. VF staging 리플레이 서브시스템이 새로 생겼다(visibility-filtering/staging/ 신규 6파일 +1,334줄). 파일별 변경은 recording.rs(+743) · replay_corpus.rs(+298) · capture.rs(+220) · reference/tweetypie.rs(+204/-81 renamed) · reference_compare.rs(+63/-39 renamed) · reference/mod.rs(+35/-72 renamed) · main.rs(+39) · serve.rs(+28) · mod.rs(+6)이다. 디렉터리 전체로는 +1,636/−192다 확인 — b412112의 파일별 통계. 어제 4세대+34까지는 visibility-filtering/staging/ 폴더가 없었다. 오늘 생긴 이 폴더는 메인 바이너리와 분리된 별도 crate 엔트리 포인트(staging/main.rs)를 가진다. VF를 실 트래픽과 완전히 분리된 리플레이·캡처 모드로 돌리기 위한 서브시스템이다. 공유 referential 비교(Tweetypie reference) 코드도 이쪽으로 옮겨 왔다. 심층 분석이 필요한 부분은 세 가지다.
recording.rs 744줄이 무엇을 어떤 포맷으로 기록해 두는지(parquet/jsonl 추정)
capture.rs가 라이브 트래픽을 어떻게 샘플링하는지
replay_corpus.rs의 재생 contract
E. Phoenix row-emb 체크포인트 복구 유틸이 새로 생겼다(phoenix/xrex/train/row_embedding_restore.py +488 신규). 어제 4세대+34에서 공개된 row-sharded 임베딩 CUDA 커널 묶음(cuda/row_emb/)의 체크포인트 복원 쪽이 Python으로 완성됐다. 공유 adapter(recsys_async_emb.py)와 복원 유틸이 함께 들어온 셈이다. 심층 분석이 필요하다.
F. Phoenix gRPC 서버의 레이턴시 측정이 별도 모듈로 분리됐다(phoenix/crates/serving/xai-recsys-engine/src/grpc_server_latency.rs +361 신규). 기존 inflight metrics와 분리된 서버 측 레이턴시 전용 모듈이다. 운영 관측을 넓히는 변경이고, 모델 쪽 변경은 아니다.
recsys_model.py(+786/-431)에서는 모델 코드가 재배치됐다. 어제 신설된 SID retrieval 모델이 공용 모델과 공유하던 유틸을 깨끗이 분리했다.
parquet_recsys.py(+325/-1)로 학습 데이터 파이프라인에 parquet 소스가 추가됐다.
어제까지 포스트 수준 라벨만 보던 ads_brand_safety_vf_hydrator.rs(+275/-20)가 작성자 수준 Grok 라벨의 fallback 분기를 받아들였다. 이 라벨은 새 파일 author_brand_safety_client.rs(+281 신규)를 거쳐 가져온다.
VF의 filter_tweets.rs(+94/-22), hydration/sources.rs(+125/-22), rules/tweet_rules.rs(+84/-94), rules/registry.rs(+4), rules/golden_corpus/trusted_friends.rs(+31 신규)·golden_corpus/mod.rs(+2)는 어제의 limited-actions 시스템에 이어 소폭 리팩터됐다.
vm-ranker/scoring/value_model.rs(+96/-5)와 scoring/mod.rs(+85/-2)는 VMRanker에 pacing-input 입력을 선언했다. 연계 param VMRankerSendPacingInputs는 기본 off다.
author_cold_start.rs(+53)에서는 어제 삭제됐던 is_arm_gated_retrieval이 작성자 cold-start 쪽에서 다시 쓰였다. cold-start 특유의 다른 분기로 되살아난 것이다.
SimClusters ANN 캐시 클라이언트가 home-mixer/clients/simclusters_ann_cache_client.rs(+211 신규)로 분리됐다.
의미
이 커밋에서는 세 가지 사실이 겹친다.
어제 공개된 generative SID retrieval의 세 축(home-mixer source, Phoenix 모델/트레이너, proto)에 "실제 서버 크레이트"가 오늘 붙었다. 어제까지는 학습 레시피와 홈믹서 소스 뼈대만 있었고, 서버는 비어 있었다. 오늘 공개된 xai-recsys-sid-retrieval 크레이트는 아래 세 가지를 명시해, 이 서버의 실제 쿼리 복잡도를 공개된 코드만으로 측정할 수 있게 했다.
parquet 스냅샷을 30초마다 전부 다시 적재하는 완전 정적 인덱스다. 실시간 업서트는 없고, 모델이 뽑은 코드 테이블을 배치로 밀어 넣는 패턴이다.
48-bit u64 key에 패킹된 6-level 트라이와 partition_point 기반 범위 검색을 쓴다.
깊이 역순과 round-robin 인터리브로 recall을 자르는 알고리즘을 쓴다.
EnableSidSource=false가 여전히 기본값이므로, 실 트래픽 적용은 FeatureSwitches cron에서 ON 되는 날이 관측 지점이다.
PopularPostsSource는 Thunder in-network 인덱스를 "global popularity" 관점에서 재활용하고, 결과를 ForYouPhoenixRetrieval 라벨로 담아 Phoenix two-tower retrieval 응답과 섞는다. 2026-10-16 커밋(4세대+43)에서 라벨이 ForYouPopularPosts로 분리됐으므로, 아래 서술은 그 전까지만 맞다. 라벨에서 PhoenixSource와 구분되지 않도록 설계한 점이 흥미롭다. scorer/filter 입장에서는 "Phoenix retrieval이 K명 더 돌려줬다"처럼 보이게 되어 있어, 상위 랭킹 단계에서 PhoenixScore와 같은 분포로 섞인다. 메커니즘으로 보면 임베딩 기반 retrieval이 아니다. follow graph의 global popularity centroid를 "가상 following"으로 Thunder에 넣는 복고적 접근이다. EnablePopularPostsSource=false가 여전히 기본값이므로, 실제로 켜지는 시점이 다음 창의 관측 지점이다.
광고 측 변경 두 가지는 모두 "SFA(Suitable For Advertising) 라벨링을 포스트 수준 → 작성자 수준으로 확장"하는 변경이다.EnableAdsBrandSafetyVerdictV2=true 전환과 EnableAdsAuthorBrandSafetyFallback 신설이 함께 올라온 것은 우연이 아니다. 포스트 수준 Grok 라벨이 아직 없는 포스트에 작성자의 과거 라벨 패턴을 fallback으로 쓰는 방향이다. 이는 Grok 분류기가 포스트 단위로는 커버가 성긴 상황(특히 신규 포스트)을 작성자 수준 집계로 보완하려는 신호다 추정.
4세대+36 (2026-10-06, e62790c) — 랭킹 value model에 "열람 후 체류 초" 항 3개 신설 (video continuation · 유저별 video continuation · profile visit secs, 기본 가중치 0) + PopularPostsSource가 조회수 기반 오프라인 잡 두 개로 재작성 + "Under the Hood" 파이프라인 트레이스 응답 신설 + VF 판정에 NOTICE 등급 추가
이 커밋에서 랭킹 value model에 확률 head와 조건부 체류 초를 곱하는 기대 체류 시간 항 3개가 생겼지만, 가중치가 모두 0이라 서빙에는 아직 영향이 없다 확인. 인기 포스트 소스는 요청 때마다 Thunder를 부르는 대신, 오프라인 잡이 조회수로 골라 둔 전역 목록을 읽게 됐다 확인(daily).
바뀐 파일은 103개, 변경 규모는 +6,916/−1,393이다 확인 — xai-org/x-algorithm@e62790c. 4세대+35(b412112, 2026-10-03)의 사흘 뒤다. 크론 미러 sync 시각은 2026-10-02T16:00:41Z → 2026-10-05T16:00:50Z다.
Phoenix proto ContinuousActionName에 HOME_PROFILE_VISIT_SECS = 0과 HOME_VIDEO_CONTINUATION_SECS = 3이 생겼다 확인 — recsys.proto:594-599. 0번은 INVALID_CONTINUOUS_ACTION_NAME, 3번은 ACTIVE_SECS_5M_RESIDUAL_NORM이 쓰던 자리다.
두 head는 확률 head와 곱해서 쓴다. 즉 "열 확률 × 열었을 때 이어 볼 초"라는 기대값 분해다. 기존 cont_dwell_time·cont_click_dwell_time은 head 값을 그대로 가중합에 넣었다 확인(같은 함수의 apply(scores.dwell_time, ...) 줄).
set_user_video_continuation은 요청 안 후보 전체에 대해 Σ P(open)·secs / Σ P(open)을 구해 모든 후보에 같은 값을 넣는다 확인. 결과적으로 이 항은 후보 간 순위를 바꾸지 않고, P(video_open)의 계수를 유저별로 키우거나 줄이는 효과를 낸다 추정. 근거는 값이 후보마다 상수이므로 이 항이 weight × user_secs × P(open) 형태가 된다는 점이다.
새 가중치 3개(VideoContinuationWeight, UserVideoContinuationWeight, ProfileVisitSecsWeight)의 크론 미러 기본값은 모두 0.0이다 확인 — vm-ranker/params.rs. home-mixer 쪽 weights_for는 세 값을 아예 0.0으로 고정한다 확인 — home-mixer/scorers/value_model.rs:37-39. 따라서 실제로 켤 수 있는 경로는 VM-Ranker 서비스뿐이다.
popular_authors_job은 기본 3600초 주기로 돈다. 이 잡은 BigQuery 테이블 twttr-bq-timelines-prod.pulse.popular_posting_authors에서 팔로워 순위 스냅샷을 읽고, 최근 7일 활동 작성자 수 × 0.00005(=상위 0.005%)명을 고른다. 스냅샷이 30시간보다 오래되면 거부한다(--popular-authors-max-snapshot-age-hours=30).
popular_posts_job(기본 300초 주기)이 그 작성자들을 20명씩 묶어 Thunder GetInNetworkPostsRequest로 포스트를 모은다(작성자당 50개, 요청당 최대 1,200개). 답글과 리트윗은 버린다(is_original).
24시간 이내 포스트에 대해 engagement counts 서비스의 view_count를 500개씩 받는다.
작성자당 상위 5개, 전체 상위 500개를 남겨 Manhattan에 쓰고, 다시 읽어 generated_at_ms가 같은지 확인한다.
서빙 소스는 60초마다 목록을 갱신하고, PopularPostsMaxResults(500)만큼 꺼내 seen_ids만 빼고 낸다. 목록 나이는 PopularPostsSource.ListAgeSecs 게이지로 보고한다.
4세대+35의 파라미터 PopularPostsTopAuthors(1000)·PopularPostsMaxPerAuthor(3)·PopularPostsMaxRepliesRepostsPerAuthor(0)는 삭제됐다 확인. 유저별 개인화 입력은 seen_ids 하나뿐이므로 이 소스는 전역 인기 목록이다 확인. 기본값은 여전히 off다.
포스트별 점수에는 weighted_score, 사용한 value model 이름(vm_ranker/weighted), Phoenix head별 점수 맵이 들어간다.
각 단계와 컴포넌트에는 https://github.com/xai-org/x-algorithm/blob/main/home-mixer/... 형태의 공개 저장소 코드 URL이 붙는다(code_url).
플래그 EnableUnderTheHood의 기본값은 false다.
공개 저장소 링크를 응답에 넣는 구조로 보아, 사용자나 내부 디버깅 화면에 "이 포스트가 왜 떴는가"를 보여 주는 기능으로 보인다 추정. 노출 지면은 이 저장소에 없다.
D. VF 판정 등급이 4개로 늘었다.확인 — README.md:205-232, visibility-filtering/models/verdict.rs. ALLOW·INTERSTITIAL·DROP에 NOTICE(포스트를 그대로 보여 주고 안내문을 붙인다)가 추가됐다. 내부 표현은 SoftIntervention { policy: AppealablePolicy, level, proactive, appeal_submitted }다. 홈믹서 입장에서는 interstitial과 마찬가지로 포스트가 피드에 남는다. 같은 커밋에서 VF는 article_client.rs·trusted_friends_client.rs·community_source.rs(커뮤니티 포스트 hydration)를 신설했다. 또 평가 결과가 Evaluation::{Complete, Partial, NotFound, Failed}로 나뉘었다.
X의 value model은 지금까지 "확률 head 가중합 + 연속값 head 그대로"였다. 이번에는 확률 × 조건부 기대 초라는 기대 체류 시간 분해가 처음 코드로 나왔다. 가중치는 0이므로 서빙에는 아직 영향이 없다 확인.
profile_click 가중치는 4세대 공개 시점부터 0.0이다(위 가중치 표). 프로필 클릭 확률 자체는 보상하지 않으면서, 클릭 후 머문 시간으로 보상하는 경로를 별도로 만든 것이다 추정. 근거는 같은 확률 head를 곱의 인자로만 쓰고 있다는 점이다.
PopularPostsSource는 공개 사흘 만에 "인기 작성자의 최신 글"에서 "인기 작성자의 글 중 조회 속도가 빠른 글"로 정의가 바뀌었다. 요청 경로의 Thunder 호출이 사라져 비용이 상수가 됐다 확인.
4세대+37 (2026-10-07, 78460ca) — PopularPostsSource 기본 on 전환 + 새-작성자 부스트 0.02 → 0.015(home-mixer 미러) + 검색 랭킹 negative 샘플의 렉시컬 매치 피처 교정 + Transformer 층별 활성 L2 정규화 옵션 + VF NotFound 판정 경로 정리
이 커밋에서 전역 인기 포스트 소스가 기본으로 켜져, For You의 팔로우 밖 후보에 개인화 입력이 이미 본 포스트 목록뿐인 소스가 처음으로 기본 경로에 들어왔다 확인. 같은 날 home-mixer 미러의 새-작성자 부스트는 0.02에서 0.015로 내려갔다 확인(daily).
바뀐 파일은 22개, 변경 규모는 +775/−681이다 확인 — xai-org/x-algorithm@78460ca. 4세대+36(e62790c, 2026-10-06)의 다음 날이다. 크론 미러 sync 시각은 2026-10-05T16:00:50Z → 2026-10-06T16:00:58Z다.
A. PopularPostsSource가 기본 on이 됐다.확인 — home-mixer/params/param.rs:944-948: EnablePopularPostsSource가 false → true로 바뀌었다. 소스는 !has_cached_posts && !in_network_only && EnablePopularPostsSource일 때 켜진다 확인 — home-mixer/sources/popular_posts_source.rs:27-31. README의 후보 소스 표와 OON 설명에도 "A shared list of popular recent posts, initially from the most-followed active accounts"로 처음 등재됐다 확인 — README.md. 소스 구조(오프라인 잡 2개, 조회수 외삽 점수, 최대 500개)는 4세대+36 절과 같다.
B. 새-작성자 부스트가 낮아졌다.확인 — home-mixer/params/param.rs:384-388: PostUnexploredWeight가 0.02 → 0.015로 바뀌었다. vm-ranker/params.rs:91-95의 같은 이름 파라미터는 0.02 그대로다 확인 — vm-ranker/params.rs. home-mixer 값은 weights_for가 로컬 가중합에 쓰는 값이다 확인 — home-mixer/scorers/value_model.rs:28. 4세대+28 이후 home-mixer는 로컬 점수를 먼저 계산하고 VM-Ranker RPC로 정제하므로, RPC가 응답하는 요청에서는 VM-Ranker 쪽 0.02가 최종값일 가능성이 있다 추정. 두 미러가 서로 다른 값을 가진 것은 이번이 처음 관찰된 사례다.
apply_negative_sampling은 배치 안의 다른 유저에서 negative를 만든다. 검색 쿼리가 있으면 아래 두 블록을 만들고, 쿼리 임베딩 코사인 유사도가 0.9 이상인 쌍은 버린다. negative 슬롯에는 ClientTweetRecapNotDwelled 라벨을 붙인다 확인 — 같은 파일 apply_negative_sampling.
블록 (1): 다른 유저의 포스트와 내 쿼리
블록 (2): 내 포스트와 다른 유저의 쿼리
포스트 피처는 post_src에서 전부 복사되므로, 원래 쿼리에 대해 계산된 두 피처가 다른 쿼리와 짝지어진 negative에 그대로 남아 있었다 확인(복사 코드와 이번 교정 코드). 두 피처는 exactPhraseSeq(쿼리 문구 완전 일치)와 matchedWordFractionSeq(쿼리 단어 일치 비율)다.
search_negative_clear_word_match(기본 False)를 켜면 negative 슬롯의 두 피처를 0으로 지운다 확인. 플래그가 켜져 있고 새 컬럼 lexicalTextSeq·lexicalQuerySeq·lexicalAuthorSeq가 있으면, 외부 라이브러리 xai_search_lexical_match.search_lexical_match로 새로 짝지어진 쿼리 기준으로 다시 계산한다 확인.
교정 전에는 블록 (2)의 negative가 "쿼리와 강하게 일치한다"는 피처를 가진 채 비관련 라벨을 받았으므로, 모델이 렉시컬 매치 피처를 덜 신뢰하도록 학습됐을 것이다 추정. 근거는 피처와 라벨이 체계적으로 어긋나는 구조라는 점이다.
D. Transformer 층별 활성 L2 정규화 옵션이 생겼다.확인 — phoenix/xrex/models/transformer.py, recsys_model.py:664, 3827-3830: 각 층의 attention 출력과 FFN 출력의 제곱 평균(attn_loss, ffn_loss, padding 마스크 반영)을 모아 per_layer_l2_weight × (ffn-loss + attn-loss)를 정규화 손실에 더한다. 기본값은 0.0이다. 같은 파일에서 손실 입력 구성 함수 4개(_candidate_inputs 등)가 loss_inputs_and_normalizers 하나로 합쳐졌고, sharding 제약이 with_sharding_constraint_unless_manual로 바뀌었다. 학습 동작 변화는 없는 리팩터링으로 보인다 추정.
4세대+35에서 생기고 4세대+36에서 오프라인 전역 인기 목록으로 다시 쓰인 소스가, 4세대+35로부터 나흘 만에 기본 on이 됐다. For You의 OON 후보에 개인화 입력이 seen_ids뿐인 소스가 처음으로 기본 경로에 들어왔다 확인. 이 후보들도 Phoenix 랭커를 거치므로 노출 여부는 랭킹이 정한다 추정. 근거는 소스가 PostCandidate만 만들고 점수를 매기지 않는다는 점이다.
같은 날 새-작성자 부스트를 25% 줄였다. 인기 작성자 목록이 들어온 날 신규 작성자 쪽 보정은 줄인 셈이다. 두 변경이 같은 실험의 일부인지는 코드로 확인할 수 없다 추정.
4세대+38 (2026-10-08, 35650fb) — For You 세로 영상 캐러셀 모듈 신설(기본 off) + 랭킹 dwell_time 학습 손실 Tweedie/MAE → CREAD 전환(기본 on) + 히스토리 체류 시간 버킷 임베딩 + two-tower retrieval에 Mixture-of-Logits(MoL)·다중 user query head 옵션과 CUDA 서빙 커널(기본 off) + topic 요청의 cold-start 제외
이 커밋으로 For You 피드에 세로 영상 캐러셀 모듈이 기본 off 상태로 들어왔고, 이 모듈은 value model 점수 대신 영상 열기 확률로 영상을 고른다. 랭킹 모델의 체류 시간 헤드는 기본 설정에서 CREAD(임계값별 분류 후 복원) 손실로 학습하게 됐다 확인(daily).
변경 규모는 파일 173개, +15,160/−2,465다 확인 — xai-org/x-algorithm@35650fb. 크론 미러 sync 시각은 2026-10-06T16:00:58Z → 2026-10-07T16:00:42Z이고, 미러에서 바뀐 값은 신설 파라미터 3개뿐이다. 증가분의 상당 부분은 서드파티 CuTe DSL 유틸(phoenix/xrex/cutedsl/quack/*, 약 3,000줄)과 VF hydration·리플레이 코드다. 이번 절은 피드 동작과 관련된 부분만 다룬다. "심층 분석 필요" 항목은 열린 질문 절에 남겼다.
1단계에서는 TopKScoreSelector가 점수 상위 50개(TOP_K_CANDIDATES_TO_SELECT)를 고른다. 그 뒤 나머지 후보 중 영상이고 답글이 아니며 video_open_score가 있는 것을 video_open 예측 확률 순으로 최대 30개(VIDEO_CAROUSEL_EXTRA_CANDIDATES) 더 뽑아 video_carousel_extra 표시를 붙인다.
2단계에서는 이 후보들이 VF·대화 dedup 필터를 함께 거친다. 그다음 마지막 필터 VideoCarouselFilter가 extra 후보를 본 피드에서 제거한다.
캐러셀 후보 풀은 "상위 50개 중 36위 이하(skip(RESULT_SIZE=35)) + 제거된 extra"다.
풀에서 세로 영상(가로/세로 비 < 1.0)만 남기고 video_open 순으로 최대 5개를 고른다. 3개 미만이면 모듈을 만들지 않는다.
가로/세로 비는 새 VideoAspectRatioHydrator가 TES 미디어 메타데이터에서 채운다.
블렌더가 모듈을 6번째 자리(VideoCarouselPosition)에 삽입하고, served history에는 VIDEO_CAROUSEL_MODULE 엔트리로 기록한다. URT 헤더 문자열 키는 VideoCarouselModule.header이고 테스트의 영문 값은 "Videos for you"다 확인 — util/urt/video_carousel_marshaller.rs.
캐러셀 영상은 본 피드에 보이는 상위 35개와 겹치지 않도록 설계됐다 추정. 캐러셀 후보 풀이 36위 이하와 extra로만 구성되기 때문이다. 다만 최종 응답 크기가 정확히 35에서 잘리는지는 이번에 대조하지 않았다.
캐러셀 선정은 value model 가중합이 아니라 video_open 단일 헤드 확률을 쓴다 확인. 본 피드 랭킹과 별개의 목적 함수로 슬롯을 채우는 첫 모듈이다 추정. 기존 WTF·프롬프트·프레임은 포스트 랭킹을 쓰지 않는 별도 후보였다.
_home_direct_packed_base()에서 dwell_tweedie_loss_weight·dwell_mae_loss_weight가 0.0, dwell_cread_loss_weight·dwell_cread_immersive_loss_weight가 0.1이 됐다. 이전에는 Tweedie(power 1.5, cap 300)와 MAE가 각각 0.1로 하드코딩돼 있었다.
CREAD 헤드는 임계값 30개마다 "체류가 이 임계를 넘는가"를 sigmoid BCE로 학습한다. 임계값은 2초부터 상한까지 로그 등간격이고, 마지막 값이 상한이다. 복원값은 Σ σ(logit_k) × (구간 폭_k)로 계산한다. 여기에 원단위 Huber 손실(δ=5초, 가중 0.5)을 더한다.
CREAD 손실은 지면에 따라 두 개로 나뉜다. 일반 지면은 상한 120초이고 PRODUCT_SURFACE_GALLERY_PAGE를 제외한다. 몰입형(gallery) 지면은 상한 240초다.
설정은 enable_dwell_bucket_table=True, 상한 240초, 임계 30개이고, 인접한 두 버킷을 선형 보간한다.
CREAD는 Kuaishou가 영상 시청 시간 예측용으로 발표한 방법이다(arXiv 2401.07521, 2024, 논문). X 코드의 이름과 구조(분류와 복원)가 논문과 같다 확인. X 코드는 논문의 핵심인 error-adaptive 구간 설계 대신 고정 로그 간격을 쓴다 확인(cread_log_thresholds).
user_query_heads=K이면 유저 시퀀스 출력을 학습된 query 벡터 K개로 attention pooling해서 유저 벡터를 K개 만든다. 각 head에는 최근성 감쇠 계수(recency_gamma)가 따로 있다.
mol_item_components=L이면 아이템 임베딩에 저랭크 어댑터를 적용해 정규화된 성분 벡터 L개를 만든다. K×L개 코사인 값을 작은 MLP gate(softmax)로 가중합해 최종 점수를 낸다. gate에 유저·아이템 임베딩 bias를 더하는 mol_conditioned_gate 옵션도 있다.
서빙용 CUDA epilogue 커널이 지원하는 (K×L, hidden) 조합은 {(4,32), (8,16), (8,32), (16,16)}이고, 요청당 유저는 512명까지만 지원한다.
같은 커밋에서 contrastive 손실 옵션 4개가 추가됐고, 모두 기본 off다.
고정 temperature
배치 내 같은 포스트가 negative로 쓰이는 false negative 마스킹
유저 자신의 negative 행동 포스트에 대한 logit offset
positive 수 기반 유저 가중
프리셋 6개 어디에서도 MoL이나 query head를 켜지 않는다 확인(configs 디렉터리 grep). 현재 프로덕션 retrieval이 내적 기반인지 MoL인지는 이 코드로 판단할 수 없다 추정. 실험 config가 공개되지 않았다.
광고 블렌더가 탈락시킨 광고를 query.ad_drops로 모으는 경로가 생겼다(home-mixer/ads/drops.rs 87줄). VF에는 유저 위치(geo/service/userLocation) 클라이언트 345줄과 hydration exchange.rs 528줄이 신설됐다 확인. 정책 변화인지는 심층 분석이 필요하다.
의미
For You에 영상 전용 슬롯이 생겼고, 그 슬롯은 value model 점수가 아니라 video_open 확률로 채운다 확인. 아직 기본 off라서 실험 단계로 본다 추정. 크론 미러 기본값이 false다.
랭킹 쪽에서는 체류 시간 헤드의 기본 학습 방식이 바뀌었다 확인. 4세대+31에서 ContClickDwellTimeWeight를 0 → 0.4로 올린 것과 함께 보면 체류 시간 신호를 계속 손보는 흐름이다 추정.
4세대+39 (2026-10-09, cb45b55) — 랭킹 요청 proto에 피드 요청 맥락(FeedRequestContext)·커서 방향 필드 추가 + H100 홈 랭커 학습 프리셋 재조정(배치 512·마이크로배치 2·빈 후보 슬롯 제거 패킹) + 후보 영역 전용 FA4 attention 커널
이 커밋으로 Phoenix 랭킹 요청에 앱 실행, 새로고침, 아래로 스크롤 같은 피드 요청 맥락을 담는 필드 2개가 생겼지만, 커밋 시점의 공개 코드에는 이 필드를 읽는 곳이 없다. 나머지 변경은 대부분 H100에서 홈 랭커를 학습하는 프리셋과 시퀀스 패킹을 고친 것이다 확인(daily).
변경 규모는 파일 16개, +1,228/−218이다 확인 — xai-org/x-algorithm@cb45b55. 크론 미러 sync 시각만 2026-10-07T16:00:42Z → 2026-10-08T16:00:35Z로 바뀌었고, 미러 값 변경은 없다 확인. 변경의 대부분은 Phoenix 랭커 학습 효율 코드다. 이 절의 줄 번호 기준 커밋은 cb45b55다.
feed_request_context(25번)는 이 피드 요청이 어떻게 발생했는지를 담는다. 값은 14종이다.
앱 실행(LAUNCH), 포그라운드 복귀(FOREGROUND, FOREGROUND_TRUNCATE), 당겨서 새로고침(PULL_TO_REFRESH), 수동 새로고침, 화면 이동(NAVIGATE), 뷰포트 기반 새로고침, 폴링, 백그라운드 fetch, 타임라인 공백 메우기(GAP), 가입 직후(SIGNUP), 자기 스레드, AUTO 등이다.
feed_cursor_direction(26번)은 첫 요청(INITIAL), 위로 새 글 받기(TOP), 아래로 스크롤해 더 받기(BOTTOM), 공백(GAP) 중 하나다.
두 enum은 이전 커밋부터 proto에 정의돼 있었지만 어느 메시지에서도 쓰이지 않았다. 이번에 처음 Phoenix 랭킹 요청 메시지에 연결됐다 확인(diff가 필드 2줄 추가뿐이다).
커밋 시점에는 이 필드를 읽는 코드가 공개 트리에 없다 확인. phoenix/xrex·home-mixer 전체를 grep해도 enum 이름과 필드 이름은 proto 밖에서 나오지 않는다. 그래서 랭킹 피처로 쓰이는지, 로깅용인지는 아직 판단할 수 없다 추정.
기존 패킹은 유저마다 후보 슬롯 수만큼 토큰 자리를 고정으로 잡았다. 새 레이아웃은 유저별로 실제 채워진 후보만 16 단위(CANDIDATE_TOKEN_ALIGN)로 올림해서 시퀀스 뒤쪽의 "후보 영역"에 모은다.
유저당 후보 토큰 예산은 seqpack_candidate_tokens_per_user의 단계 [32, 128] 중 하나다. 스텝마다 모든 프로세스가 필요한 최댓값을 분산 KV로 합의한다(max_over_processes, 타임아웃 2시간). 그리고 그 값을 넘는 가장 작은 단계를 고른다. 예산이 커지는 만큼 히스토리 길이 분포의 평균(mean_len 510)을 줄여 총 토큰 수를 맞춘다.
가장 큰 단계로도 예산을 넘으면 유저 안에서 뒤쪽 슬롯부터 후보를 학습 배치에서 뺀다. 빠진 수는 train/seqpack_dropped_candidate_slots 지표로 기록한다. 마지막 단계(128)가 전체 후보 슬롯 수보다 작으면 경고를 남긴다.
후보 영역용 attention 커널 ranker_candidate_attention_fa4가 신설됐다(369줄). 이 커널은 FlashAttention 계열 CuTe DSL 커널이고, SM90(H100)만 지원한다 확인 — phoenix/xrex/cutedsl/ranker_candidate_attention_fa4.py:31, 295. 각 후보 토큰은 자기 유저의 히스토리 키 구간(candidate_key_starts, candidate_key_counts)만 본다.
랭킹 모델이 "이 요청이 새로고침인지, 아래로 스크롤한 것인지"를 받을 수 있는 통로가 생겼다 확인. 같은 유저라도 앱을 막 연 순간과 피드 깊이 스크롤한 순간에 반응 확률이 다르므로, 다음 단계로 이 값을 맥락 피처로 쓸 가능성이 있다 추정. 필드를 추론 요청 메시지에 추가했다는 것 외의 근거는 없다.
B·C는 서빙 랭킹 결과를 바꾸지 않는 학습 효율 변경이다 추정. 모델 구조와 손실 정의는 그대로이고 패킹 방식만 바뀌었다. 다만 예산 초과 시 후보가 학습에서 빠지므로 학습 데이터 분포가 약간 달라질 수 있다 추정.
4세대+6에서 GB300 전용 override가 먼저 나왔지만, 이번에 H100용 전용 커널과 기본 프리셋 튜닝이 추가됐다. 홈 랭커 학습이 H100에서도 계속 운영된다고 본다 추정. 기본 프리셋 이름 home_direct_packed가 H100 override를 쓰기 때문이다.
4세대+40 (2026-10-13, e1a0375) — 홈믹서 크론 미러 가중치 3개 변경(favorite 0.5 → 0.6, video_open 0.07 → 0.02, cont_dwell_time 0.004 → 0.006) + two-tower retrieval에 체류 기반 세 번째 헤드 ld(가중 0.75)와 설치 앱 피처 off + 대화 화면 답글 랭커 reply_two_tower_v2 신설 + value model react_with_video 항(가중 0) + 영상 캐러셀 작성자·SID dedup(기본 off) + ZERO_FAV retrieval 데이터셋
이 커밋으로 홈믹서 크론 미러에서 좋아요와 체류 시간 가중치가 오르고 영상 열기 가중치는 0.07에서 0.02로 내려갔지만, VM-Ranker 쪽 미러는 이전 값 그대로다. retrieval의 combined 프리셋에도 체류 기반 헤드가 생겨, 홈 retrieval 목적이 좋아요에서 좋아요와 체류로 넓어졌다 확인(daily).
변경 규모는 파일 179개, +26,532/−5,971이다 확인 — xai-org/x-algorithm@e1a0375. 크론 미러 sync 시각은 2026-10-08T16:00:35Z → 2026-10-12T16:00:33Z다. 증가분의 절반 이상은 abuse-enforcement-service(약 10,000줄)와 visibility-filtering 리팩터(약 9,000줄 변경)다. 이 절은 피드 동작과 관련된 부분만 다룬다. 줄 번호 기준 커밋은 e1a0375다.
VM-Ranker 쪽 미러에서는 같은 이름(같은 feature switch 키)의 값이 0.5·0.07·0.004 그대로다 확인 — vm-ranker/params.rs:3, 12-17, 54-59. 두 미러가 갈라진 것은 4세대+37의 PostUnexploredWeight에 이은 두 번째 사례다.
4세대+37 절의 판단을 그대로 적용한다. VM-Ranker RPC가 응답하는 요청에서는 VM-Ranker 쪽 값이 최종값일 가능성이 있다 추정. home-mixer는 로컬 가중합을 먼저 계산하고 VM-Ranker로 정제한다(4세대+28).
방향은 "영상 열기 확률의 비중을 줄이고 좋아요와 체류 시간의 비중을 늘린다"이다 확인(값 자체). 4세대+38에서 캐러셀을 video_open 확률로 채우도록 만든 것과 반대 방향이라는 점이 눈에 띈다. 본 피드에서 영상 열기 유도를 줄이고 영상은 캐러셀 슬롯으로 따로 보내는 분업일 수 있다 추정. 두 변경이 6일 간격이라는 정황 외에는 근거가 없고, 캐러셀은 아직 기본 off다.
_xrecsys_two_tower_combined_base()의 후보 헤드가 2개(home, immersive)에서 3개(home, immersive, ld)가 됐다. ld 헤드의 positive 행동은 CLIENT_TWEET_RECAP_DWELLED 하나이고, negative는 home과 같은 목록을 쓴다.
retrieval 데이터셋 RETRIEVAL_DATASET_TYPE_ZERO_FAV = 16과 served type ForYouPhoenixRetrievalZeroFav가 신설됐고, 선택·비선택 후보 통계를 따로 기록한다 확인 — sources/phoenix_source.rs, side_effects/scored_stats_side_effect.rs. 이름으로 보아 좋아요가 0인 포스트만 모은 retrieval 채널이다 추정. 채널을 채우는 쪽 코드는 공개되지 않았다.
4세대+39에서 Phoenix 요청에 추가된 FeedRequestContext·FeedCursorDirection을 home-mixer가 처음으로 채운다. 다만 채우는 곳은 광고 인덱스 요청(AdIndexRequest)이고, Phoenix 랭킹 요청은 이번 diff에서도 채우지 않는다 확인 — sources/ads_source.rs, diff 전체 grep.
proto에는 광고 view-through 지연 전환 행동 18개(234-251번), UserActionMeta.fair_view_time_ms, UserContext.id_list_features_ipc가 추가됐다 확인 — recsys.proto.
abuse-enforcement-service에 규칙별 집행 조치를 런타임에 바꾸는 action_control.rs와 집행 전체를 끄는 off_switch.rs(1,619줄)가 신설됐다 확인.
action_control.rs(3,218줄)에는 dry-run 라벨, 확인어 ENFORCE, 변경 이력 보관이 들어 있다.
계정 집행 인프라라서 피드 랭킹과 직접 관계는 없다고 본다 추정. VF 리팩터(explain.rs 1,248줄, rules/levels.rs, rule_spec/ 분할)와 함께 심층 분석이 필요하다.
의미
가중치 미러 값이 바뀐 것은 4세대+37 이후 처음이다. 좋아요와 체류 비중을 올리고 영상 열기 비중을 1/3 이하로 낮췄다 확인. 다만 VM-Ranker 미러는 이전 값 그대로라서 실제 서빙값은 확정할 수 없다 추정.
retrieval 쪽에서도 체류를 목적에 넣었다 확인. 4세대+31(ContClickDwellTimeWeight 0 → 0.4), 4세대+38(CREAD), 이번 랭킹 cont_dwell_time 상향과 함께 보면, X가 retrieval부터 랭킹까지 체류 신호의 비중을 계속 늘리는 흐름이다 추정.
답글 랭커는 홈 Phoenix의 "hand-engineered feature를 없앴다"는 README 주장과 정반대 설계다 확인(피처 목록). 같은 회사가 지면에 따라 다른 원칙을 쓴다는 근거가 하나 생겼다.
4세대+41 (2026-10-14, 17c96bf) — immersive 지면용 페이지 단위 RL 재순위기(page_rl: autoregressive 페이지 디코더 + twin critic, 학습 config 신설·서빙 기본 off) + 홈믹서 크론 미러 favorite 0.6 → 0.66·not_interested −47.52 → −52.272 + 홈 랭커 설치 앱 피처 off + topic 요청에서 DPP 기본 생략 + 브라질 선거 필터 25개 계정으로 축소
이 커밋으로 한 페이지의 슬롯을 하나씩 차례로 고르는 immersive 지면용 RL 재순위기의 학습 코드가 공개됐지만, 서빙 스위치는 기본 off다. 홈믹서 크론 미러에서는 좋아요와 관심 없음 가중치가 똑같이 1.1배가 됐다 확인(daily).
변경 규모는 파일 53개다 확인 — xai-org/x-algorithm@17c96bf. 크론 미러 sync 시각은 2026-10-12T16:00:33Z → 2026-10-13T16:00:30Z다. 줄 번호 기준 커밋은 17c96bf다. 신규 파일 세 개는 커밋에서 처음 생겼으므로 줄 번호가 diff의 신규 파일 줄 번호와 같다.
학습 config immersive_page_rl이 신설됐다. 홈 랭커 base(_home_direct_packed_base)에 H100 설정을 더하고 page_rl=PageRlConfig(enabled=True)를 켠 것이다. Kafka 학습 토픽도 user_action_sequence_immersive_page_rl로 따로 있다 확인 — data_feeds.py.
구조는 actor-critic이다. 먼저 Phoenix 랭커의 헤드 확률로 가중합 점수(behavior score)를 만들고, 그 상위 최대 48개(max_steps)를 후보 풀로 자른다. 그다음 페이지 디코더가 슬롯을 하나씩 autoregressive하게 고른다. 서빙 시 고르는 슬롯 수는 12(serve_steps)다 확인 — page_decoder.py:22-34, page_rl.py:340-391.
이미 고른 아이템과 작성자가 같은지·SID 앞자리가 몇 단계 겹치는지에 대한 음수 가중 페널티
이 구조에서는 다양성을 사후 규칙이 아니라 디코더 안에서 학습한다.
보상은 슬롯별 체류 시간이다. 3초 이상 본 슬롯에 0.1 × min(체류초, 300) × (1 + min(참여 수, 3))을 주고, negative 행동(관심 없음·신고·차단·뮤트·덜 보기)이 하나라도 있으면 0이다. 페이지 끝까지 노출되고 마지막 슬롯에서 영상 품질 시청(VQV)이 있으면 완주 보너스 1.0을 더한다. 할인율 γ=0.8로 suffix return을 계산한다 확인 — page_rl.py:51-59, 100-158.
critic은 두 개(twin)다. 두 critic 값 중 작은 쪽(min)을 쓴다. TD(λ=0.9) 타깃으로 학습한다. 탐색 페이지(로그된 rerank 확률이 있는 요청)의 return 손실에는 가중 9.0을 준다. 다음 슬롯까지 넘겼는지를 예측하는 continuation 헤드도 있다 확인 — page_critic.py:18-25, 121-139, 174-193.
이 값이 서빙 기본값인지, 학습용 기본값일 뿐인지는 알 수 없다 추정. config 클래스 기본값이고 미러 주석이 없다.
2026-10-16 커밋(4세대+43, bc3948b)에서 VQV 0.5 → 1.0, share 40.0 → 4.0으로 바뀌었고, 요청마다 가중치를 덮어쓸 수 있게 됐다(변경 이력의 4세대+43).
서빙 러너에는 serve_page_decoder: bool = False가 추가됐다 확인 — inference/model_runner.py:3984. home-mixer 쪽에서 디코더 출력을 쓰는 코드는 이번 diff에 없다 확인(home-mixer 변경 파일 3개는 필터·param·phoenix_source.rs뿐이다).
학습 데이터에 rl_served_slot·rl_rerank_prob·rl_decode_prob·rl_impressed 필드가 이미 있다는 점에서, 어떤 형태의 확률적 재순위가 이미 일부 트래픽에서 돌며 로그를 남기고 있다고 본다 추정. 로그 확률 필드는 off-policy 학습용이며, 서빙되지 않은 정책의 로그는 생기지 않는다. 4세대+4의 rewardRerankSlotProb proto 필드가 이 로그의 출처일 가능성이 있다 추정.
지면 이름이 immersive라서 세로 영상 몰입형 지면이 우선 대상으로 보인다 추정. config 이름과 완주 보너스가 VQV에 걸린 점이 근거다.
두 값 모두 정확히 1.1배다 확인(산술). 4세대+31의 NotInterestedWeight −43.2 → −47.52도 1.1배였다. 10% 단위로 단계적으로 올리는 롤아웃 관행으로 보인다 추정. 세 번의 변경이 같은 배율이라는 것 외에 근거는 없다.
VM-Ranker 쪽 미러는 favorite 0.5(3행), not_interested −47.52(108-113행) 그대로다 확인 — vm-ranker/params.rs. 두 미러가 갈라진 가중치는 5개(PostUnexploredWeight, favorite, video_open, cont_dwell_time, not_interested)가 됐다.
C. 그 밖의 피드 관련 변경
홈 랭커 base _home_direct_packed_base에서 enable_user_installed_apps가 True → False가 됐다 확인 — configs/xrecsys.py:300. 4세대+40에서 two-tower retrieval이 같은 피처를 끈 데 이어 랭커도 껐다. xrecsys_seqpack(430행)은 여전히 True다.
PhoenixSource가 ZERO_FAV retrieval 채널에서 나온 후보 수를 요청마다 메트릭(ScoredStats.PhoenixRetrievalZeroFavTweets.Retrieved)으로 기록한다 확인 — sources/phoenix_source.rs. 4세대+40에서 신설된 이 채널에 관측 메트릭을 붙인 것이다.
Brazil2026ElectionFilter 목록이 결선 투표 관련 25개 계정으로 줄었다 확인. 자세한 내용은 필터 절에 있다.
나머지 변경은 피드 랭킹 동작과 직접 관계가 없다고 본다 추정.
abuse-enforcement action_control·confidence 리팩터, VF hydration 정리, FA4 attention 커널, 답글 랭커의 SID 디코더 경로 이동이 여기에 해당한다.
의미
X가 For You 계열 지면에서 점수순 정렬과 DPP 사후 다양화를 넘어, 슬레이트 전체를 RL로 생성하는 단계의 학습 코드를 처음 공개했다 확인(코드 존재). 서빙 스위치는 기본 off라서 실제 배포 범위는 확정할 수 없다 추정.
보상이 참여 확률이 아니라 체류 시간 × 참여 배수이고 negative가 있으면 0이다 확인. 4세대+38(CREAD)·4세대+40(ld 헤드)·이번 미러 변경과 같은 방향으로, 체류를 주 목적에 두는 흐름이 재순위 단계까지 이어졌다 추정.
4세대+42 (2026-10-15, 92f0918) — 영상 캐러셀 선정 점수에 immersive 지면 모델의 VQV 예측을 섞는 경로(기본 off) + 광고 작성자 브랜드 안전 fallback 기본 on + VF 규칙 정의 계층 재작성
이 커밋으로 For You 영상 캐러셀이 몰입형 지면 모델의 품질 시청(VQV) 예측을 섞어 영상을 고를 수 있게 됐지만, 그 가중의 기본값이 0.0이라 기본 동작은 이전과 같다 확인. 같은 커밋에서 포스트 단위 Grok 안전 라벨이 없을 때 작성자 단위 라벨로 대신하는 광고 브랜드 안전 fallback이 기본 on이 됐고, VF 규칙 정의 계층은 정책 변화 없이 다시 쓰였다 확인(daily).
바뀐 파일은 57개다 확인 — xai-org/x-algorithm@92f0918. home-mixer 변경은 12개 파일 약 330줄이고, 나머지 대부분(약 8,000줄)은 visibility-filtering이다. 크론 미러 sync 시각은 2026-10-13T16:00:30Z → 2026-10-14T16:00:44Z다. 줄 번호 기준 커밋은 92f0918이다.
캐러셀 영상의 정렬 점수가 video_open 확률 하나에서 두 예측의 가중합으로 바뀌었다. 기본값은 video_open 가중 1.0, VQV 가중 0.0이라서 기본 동작은 이전과 같다 확인.
식은 VideoCarouselVideoOpenWeight × video_open + VideoCarouselImmersiveVqvWeight × immersive_vqv다.
immersive 점수가 없는 영상은 그 항을 0으로 둔다.
VQV 가중이 0이 아니면 두 컴포넌트가 켜진다. 유저 행동 시퀀스를 다른 집계로 한 번 더 받는 hydrator와, 캐러셀 대상 영상의 예측을 다른 Phoenix 클러스터(Experiment3Fou)에 ProductSurface::GalleryPage로 요청하는 스코어러다 확인.
ImmersiveScoringSequenceQueryHydrator는 유저 행동 시퀀스를 IMMERSIVE_LONG_DWELL 집계로 한 번 더 받는다 확인. IMMERSIVE_LONG_DWELL(=29)은 4세대+19에서 proto 이름만 생겼던 집계 유형이고, home-mixer가 이를 소비하는 코드는 이번이 처음이다 확인 — query_hydrators/immersive_scoring_sequence_query_hydrator.rs(신규 75줄).
홈 랭커의 기본 집계는 DENSE_WITH_LONG_DWELL이다(params/param.rs 163-168행).
GalleryPage·Experiment3Fou·IMMERSIVE_LONG_DWELL이 모두 몰입형 세로 영상 지면 쪽 이름이라서, 캐러셀을 누른 뒤 이어지는 몰입형 뷰어의 모델로 캐러셀 첫 화면을 고르려는 것으로 본다 추정. 근거는 세 식별자의 이름과, 4세대+41의 immersive_page_rl 보상이 VQV 완주를 따로 셈한다는 점이다.
B. 광고 작성자 브랜드 안전 fallback이 기본 on이 됐다확인 — params/param.rs:807-812. 4세대+35에서 기본 off로 신설된 EnableAdsAuthorBrandSafetyFallback이 false → true가 됐다. 이 스위치는 포스트 단위 Grok 안전 라벨이 없을 때 작성자 단위 라벨(GrokSfa/GrokNsfaLimited/GrokNsfa)을 대신 넣는 경로를 켠다. 자세한 동작은 4세대+35 절에 있다. 이로써 4세대+35에서 기본 on으로 돌아온 V2 verdict 스위치와, 같은 커밋에서 신설된 작성자 fallback 스위치가 모두 기본 on이 됐다 확인. V2 verdict 스위치 자체는 4세대+2(2026-08-17)부터 있었다(b089ce6의 param.rs).
C. 그 밖의 변경
Phoenix recsys_model.py에서 seqpack(여러 유저 시퀀스를 한 배치 행에 이어 붙이는 패킹)을 켤 때 right_anchored_rope를 요구하던 assert 한 줄이 지워졌다 확인 — phoenix/xrex/models/recsys_model.py.
오른쪽 기준이 아닌 RoPE로 packed 시퀀스를 학습하는 실험을 허용하려는 것으로 본다 추정. 근거는 이 커밋이 제약만 풀었고 다른 config 변경은 없다는 점이다.
예전 형식은 Clause { when: &[Cond] }이고, 새 형식은 Rule { when: RuleCond }다. 트리는 all!/any!/!/이름 붙은 조건으로 짠다.
다섯 SafetyLevel은 FILTER_ALL·TIMELINE_HOME·TIMELINE_HOME_RECOMMENDATIONS·TIMELINE_HOME_HYDRATION·IMMERSIVE_EXPANDED_RECOMMENDATIONS다.
새 트리를 예전 평면 표로 다시 출력해 기존 레벨별 골든 파일과 비교하는 테스트 어댑터가 추가됐고, 골든 파일은 바뀌지 않았으므로 정책 변화는 없다 확인.
어댑터는 rules/legacy_table.rs이고, 테스트 전용이다.
4세대+11·+13·+14·+29에 이은 다섯 번째 "정책 변화 없음" 리팩터다.
같은 규칙 정의에서 평가, 정책 카탈로그 문서 생성, 노드별 판정 트레이스를 함께 만든다. 서빙 경로는 트레이스 없는 단축 평가를 쓴다 확인.
카탈로그 문서 생성은 테스트 전용 rules/catalog.rs가 한다.
판정 트레이스는 rules/rule_spec/observed.rs(신규 275줄)가 만든다.
디버그 도구 explain.rs가 이 트레이스와 리트윗 병합 선택(MergeSelection)을 노출한다.
"이 포스트가 왜 가려졌나"를 규칙 단위로 설명하려는 방향으로 본다 추정. 근거는 규칙 한 줄이 판정에 어떻게 기여했는지를 행마다 기록하는 Effect enum이다.
동작이 바뀔 수 있는 곳은 규칙 밖의 hydration 두 군데다 확인(코드).
뷰어 Gizmoduck(X의 유저 정보 서비스) 조회가 바뀌었다 확인 — clients/gizmoduck_client.rs:23-29, 55-62. 예전에는 컨텍스트 없이(None) 불렀고, 이제는 include_soft_users·deactivated·erased·offboarded 등을 모두 포함하는 LookupContext로 부른다. 테스트 이름이 viewer_lookups_include_every_user_state_tweetypie_reads라서 Tweetypie 레퍼런스(4세대+31)와 조회 범위를 맞추는 변경으로 본다 추정.
두 변경이 겹치면 이전에 조회되지 않던 상태의 뷰어가 SENSITIVE_VIEWER_NO_STATED_AGE_DROP_* 규칙의 대상이 될 수 있다 추정. 규모는 diff로 알 수 없다.
뷰어 상태·나이 분포 메트릭 vf_viewer_state·vf_viewer_age(국가 태그 포함)가 신설됐다 확인 — hydration/metrics.rs.
정책이 요구하는 hydrator가 계획에 없으면 그 요청의 후보를 모두 실패 처리하는 사전 검사(fail-closed)도 추가됐지만, 계획이 같은 정책에서 파생되므로 정상 경로에서는 발동하지 않는다 추정 — rules/registry.rs:418-443.
visibility-filtering/params.rs·limited_actions_policy.rs는 가시성 한정자만 pub으로 바뀌었고 값은 같다 확인.
의미
4세대+38에서 캐러셀은 "본 피드 모델이 예측한 영상 열기 확률"로 골랐다. 이번 커밋으로 다른 지면용으로 학습된 모델의 품질 시청(VQV) 예측을 가져와 고르는 경로가 생겼다 확인(코드). 한 피드 안의 모듈이 지면별 모델을 따로 호출하는 구조가 처음 공개됐다 확인.
이번 경로는 4세대+40의 video_open 가중 인하(0.07 → 0.02)에 이어, 영상 쪽 목적이 "열기"에서 "실제로 본 시간·품질 시청"으로 옮겨 가는 흐름과 방향이 같다 추정. 기본값이 0이라 서빙 여부는 알 수 없다.
이 커밋으로 인기 포스트 소스의 후보가 자체 라벨을 달게 됐고, immersive 페이지 재순위기의 behavior score에서 품질 시청 가중은 두 배, 공유 가중은 10분의 1이 됐다 확인. visibility-filtering에서는 보조 조회 13개가 실패해도 판정을 완료로 치게 됐고, 테스트는 그 대가로 드물게 과잉 드롭이 생길 수 있음을 명시한다 확인(daily).
커밋은 xai-org/x-algorithm@bc3948b이다 확인. 크론 미러 sync 시각은 2026-10-14T16:00:44Z → 2026-10-15T16:00:34Z다. 줄 번호 기준 커밋은 bc3948b다.
2026-10-14 커밋(4세대+41)에서 공개된 immersive 페이지 재순위기의 behavior score(후보 풀을 자르고 KL 고삐의 기준 정책이 되는 점수)에서 품질 시청 가중이 두 배가 되고 공유 가중이 10분의 1이 됐다. 디코더 서빙은 여전히 기본 off다.
PageDecodeParams에 RankingScoreWeights(13개 필드, 마지막은 minVideoDurationMs)가 생겨 호출 측이 요청마다 가중치를 보낼 수 있다.
값이 NaN이면 config 기본값을 쓴다.
proto의 0 기본값을 NaN으로 바꾸는 변환 코드는 이 diff에 없다 확인.
페이지 디코더의 Python for 루프가 hk.fori_loop로 바뀌었다(컴파일 최적화, 의미는 같다).
⚠ 주장 변경: 2026-10-03 커밋(4세대+35) 소절의 "PopularPostsSource 후보가 Phoenix retrieval 라벨로 섞여 구분되지 않는다"는 서술은 이 커밋(4세대+43)부터 맞지 않는다 확인.
page_rl behavior score에서 공유 가중이 10분의 1로 내려가고 품질 시청 가중이 두 배가 되면서, 점수가 공유보다 품질 시청 쪽으로 기울었다 확인(값). VQV 가중 상향은 2026-10-15 커밋(4세대+42)의 캐러셀 VQV 경로와 방향이 같다 추정.
VF 변경은 지연을 줄이려고 보조 관계 조회의 실패를 허용한 것이며, 그 대가로 드물게 과잉 드롭이 생길 수 있음을 테스트가 명시한다 확인(테스트 주석).
4세대+44 (2026-10-17, 862a123) — 영상 캐러셀 중복 제거 3종 기본 on + Who-to-Follow가 있으면 캐러셀 제거 + 포스트 참여자 목록 피처(PostEngagers, 섀도 트래픽에서만 on) + ZeroFav 채널 양면 실험 필터(기본 off) + two-tower 마이크로배치 공유 후보 풀
이 커밋으로 기본 off인 For You 영상 캐러셀 안의 중복 제거 세 가지가 기본 on이 됐고, 같은 응답에 Who-to-Follow 모듈이 있으면 캐러셀을 넣지 않게 됐다 확인. 후보 포스트에 참여한 유저 ID 목록을 랭커 요청에 넣는 피처도 생겼는데, 기본 off이고 섀도 트래픽에서는 항상 켜진다 확인(daily).
커밋은 xai-org/x-algorithm@862a123이다 확인. 크론 미러 sync 시각은 2026-10-15T16:00:34Z → 2026-10-16T16:00:32Z다. 줄 번호 기준 커밋은 862a123이다.
visibility-filtering·abuse-enforcement·bdsm 변경은 이름 변경(FilterTweets → PostEvaluator, rules/registry.rs → rules/engine.rs 등)과 메트릭, Kafka 인증(SASL → mTLS) 변경이다 추정. 피드에서 걸러지는 대상이 바뀐 곳은 찾지 못했다 추정. 모든 VF hunk를 줄 단위로 읽지는 않았다.
후보 tower는 모든 마이크로배치에 대해 한 번만 돌고, 각 마이크로배치의 유저가 전체 풀을 in-batch negative로 쓴다.
손실은 스텝 전체 유효 유저 수로 정규화한다.
같은 메모리로 negative 수를 늘리는 변경이다 추정. 이 diff에서 num_microbatch > 1을 쓰는 two-tower 프리셋은 없다.
그 밖에 기본 off인 것은 다음 세 가지다.
MoL 게이트 CUDA 커널(mol_fused_gate, 수식은 같다)
유저 단위 ID 목록 피처 로더(top_p_·top_au_·latest_p_ 등, id_list_features="")
attention 출력 게이트(attn_output_gate)
최근 검색어와 그 임베딩을 담는 proto RecentSearchQueries도 생겼지만 쓰는 코드가 없다.
의미
캐러셀이 기본 off인 채로 중복 제거와 다른 모듈과의 충돌 규칙이 먼저 기본값으로 확정됐다 확인(값). 켜기 전에 슬롯 품질 규칙을 정리하는 순서로 본다 추정. 근거는 2026-10-08 커밋(4세대+38) 이후 캐러셀 관련 변경 다섯 건(+38·+40·+42·+43·+44)이 모두 기본 off인 캐러셀의 내부 규칙을 다뤘다는 점이다.
PostEngagers는 "누가 이 포스트에 반응했나"를 ID 목록 그대로 랭커에 넣는 피처다. 집계 카운트가 아니라 참여자 신원과 행동 종류를 넘긴다는 점이 새롭다 확인(proto). 섀도 트래픽에서 항상 켜 두었으므로 모델 실험이 진행 중이라고 본다 추정.