0x00

리서치 / 서비스 정본

X (Twitter)

한 줄 요약

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 요청은 생략)과 광고·팔로우 추천 블렌딩을 거쳐 나간다.

요청 — get_for_you_feed / _urt 추천 거부 시 in_network_only 강등 · Following은 같은 파이프라인의 플래그 후보 소스 11개 — 병렬(join_all) · 실패 소스는 조용히 소멸(fail-open) Thunder 인네트워크 인메모리 · 보존 2일 Phoenix 소스 3종 retrieval · MoE · topics (two-tower) SimClusters (2026-08 부활) OON · 인기 목록 on(10-07) · tweet-mixer off cached · scored posts 캐시 재사용 ads·WTF·prompts·push 비유기 소스 · 영상 캐러셀(off) 캐시 게이트 캐시 충분 → 소스 스킵 필터 18 — 스코어링 전, 순서 고정 (2026-08 신규 5, Brazil2026 포함) VF(안전) 필터는 fail-closed — 판정 없으면 제거 Phoenix — Grok-1 포팅 트랜스포머 랭커 예측 헤드 24 (Engagement 7 / Clicks 6 / Attention 5 / Author 1 / Negative 5) ranking_scorer — Σ w·P(head) 가중합 감쇠(decay 0.5·floor 0.25) · OON ×0.75 · 새-작성자 부스트 +0.02 (인네트워크만) · 상호팔로우 원본 reply +15 가중치 정본 공개(2026-08-13): reply 5.0 · favorite 0.5 · retweet 1.0 · share copy 20.0 · report −234 VMRanker — 외부 DPP 슬레이트 서비스 (gRPC · 기본 on · θ=0.65 · K=150) 점수를 통째로 덮어씀 · 실패하면 조용히 스킵(fail-open · VMRankerEnableFallback=false) top-K 선택 → 선택 후 필터 3 → 블렌더 → 피드 광고 삽입: 유기 포스트 ≥5 · 간격 {요청 3, 최소 2} · 안전 갭의 절반까지 블렌더는 프레임워크 정렬을 무력화하고 위치로 삽입 Grox — nearline 주석 Kafka 기반 · 요청 경로 밖 slop score · PTOS 안전 · 토픽 멀티모달 임베딩 결과는 필터·광고 인접성으로: 안전 미채점 포스트는 MediumRisk (fail-closed) 랭킹 트랜스포머 입력은 여전히 ID 임베딩만
X For You 파이프라인 — 2026 Rust 세대 정본 요약

↑ 목차

공개 소스 4세대 — 이걸 먼저 알아야 나머지가 안 헷갈린다

X의 추천 코드 공개는 네 세대로 나뉜다. 1세대는 Scala/Java, 2세대는 Scala로 된 트위터 레포 코드이고, 3·4세대는 xAI 레포의 Rust/Python 코드다. 4세대부터는 가중치 파라미터 미러와 안전 파이프라인, 학습 코드도 함께 공개됐다 확인.

세대 레포 공개 언어 상태
1세대 twitter/the-algorithm @ ef4c5eb…72eda9a 2023-03-31 ~ 2023-07-07 Scala/Java 세상에 돌아다니는 "트위터 알고리즘" 분석의 99%가 이 세대를 본다
2세대 같은 레포 @ c54bec0 2025-09-03 Scala 988 파일, +65,319/−3,195. tweet-mixer 신설, Grok·Phoenix 진입
3세대 xai-org/x-algorithm @ aaa167b·e414c17·0bfc279 2026-01-20·2026-05-15 Rust/Python 완전 재작성. 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. 1세대와 2세대 사이에 26개월 공백이 있다. 2023-07-07 이후 2025-09-03까지 커밋이 하나도 없다 확인.

  2. 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. 3세대는 별개 레포다. xai-org/x-algorithm의 초기 3커밋(aaa167b→e414c17→0bfc279)은 코드를 다시 쓰고 grox/를 추가해 216파일에 이르렀다 확인.

  4. 4세대는 3세대의 성격을 바꿨다. 3세대까지는 "로직의 형태만 공개, 값·배선·안전규칙 미공개"였지만, 4세대는 다음 세 가지를 공개했다 확인 — README.md #latest-update--august-13th-2026.

    • 가중치와 상수의 프로덕션 크론 미러를 home-mixer/params/param.rs로 공개했다 확인 — 같은 README.
    • visibility-filtering/과 labeling 서브레포 15개로 안전 파이프라인 전체를 공개했다 확인 — 같은 README.
    • phoenix/에 학습 코드와 합성 데이터를 넣어 end-to-end로 실행할 수 있게 했다 확인 — 같은 README.

    상세 분석은 daily/2026-08-15.md에 있다.

↑ 목차

용어

이름 풀이 코드 위치 근거 절
4세대+N 4세대 공개(2026-08-13) 뒤에 나온 xai-org/x-algorithm 커밋에 이 노트들이 차례로 붙인 번호 — 공개 소스 4세대, 변경 이력
canon 이 문서(X 정본)를 가리키는 말. 'canon 이전 판'은 이 문서의 예전 판을 뜻한다 — 세대 대비
크론 미러 프로덕션 feature-switch 기본값을 매일 동기화해 레포에 공개한 파라미터 파일. 마지막 sync 시각이 파일 헤더에 적혀 있다 home-mixer/params/param.rs 가중치
home-mixer For You와 Following 피드 요청을 받는 서비스. 후보 소스, 스코어러, 필터, 블렌더 코드가 이 아래에 있다 home-mixer/ 피드 지면
Thunder 인네트워크 최신 포스트를 메모리에 들고 있는 인덱스(보존 2일). 2026에 새로 만들었다 thunder/ 후보 생성 현행
Phoenix Grok-1을 포팅한 트랜스포머. 유저 이력과 후보를 한 시퀀스에 넣고 후보마다 참여 확률을 예측한다. OON 후보를 찾는 Phoenix retrieval도 있다 phoenix/ Phoenix
OON out-of-network. 뷰어가 팔로우하지 않는 계정의 포스트다. 점수 계산에서 OON 배수가 적용된다 OonWeightFactor 후보 배분
SID semantic ID. coarse → fine 계층의 codeword 시퀀스로 포스트를 나타내는 ID SidSource, sid_k1 / sid_k2 / sid_k3 후보 생성 현행, Phoenix
VQV video quality view. 영상을 일정 시간 이상 실제로 시청했는지를 뜻하는 X의 행동 라벨이다. Phoenix 예측 헤드 가운데 Attention 카테고리에 속한다 VqvWeight Phoenix
VMRanker (VM-Ranker) 슬레이트 재정렬 서비스. 레거시 이름을 유지하지만 DPP 슬레이트 재정렬을 담당한다 home-mixer/scorers/vm_ranker.rs, vm-ranker/ 다양성
DPP Determinantal Point Process. 다양성 장치의 하나로, 후보를 집합 단위로 최적화해 고른다 vm-ranker/params.rs의 DppTheta, DppMaxSelectedRank. 4세대 home-mixer param에서는 VMRankerDppTheta, VMRankerDppMaxSelectedRank 다양성, 커밋 요약표
VF (visibility-filtering) Labeling Path의 마지막 단계. 포스트마다 이 뷰어에게 보여도 되는지 판정(verdict)을 내리고, home-mixer는 이 판정에 따라 후보를 떨어뜨리거나 남긴다 visibility-filtering/, home-mixer/filters/vf_filter.rs 안전성
grox Grok 기반 nearline 주석 파이프라인. slop score, 안전 라벨, 토픽, 답글 점수, 멀티모달 임베딩을 만든다 grox/ Grox
PTOS (PtOS) grox 안전 파이프라인에서 정책 기반으로 위반 카테고리를 판정하는 계층 grox/flows/ptos/ Grox → PtOS
Strato X 내부 시스템. 코드는 Strato를 거쳐 안전 어노테이션, AI 트렌드, 답글 랭킹 점수를 조회하거나 저장한다 StratoVfClient, grox/flows/reply_spam/strato_loader.py Grox → PtOS
TES Tweet Entity Service. X 내부의 트윗 메타데이터 조회 서비스 TesHydrator, TESClient 변경 이력의 TesHydrator 캐시 제거
섀도 트래픽 실제 요청 일부를 골라 실험용 Phoenix 클러스터에도 채점을 보내고, 그 점수는 로그로만 남기는 경로. 서빙 결과에는 영향을 주지 않는다 home-mixer/util/shadow.rs, PhoenixExperimentsSideEffect 열린 질문
UTH (Under the Hood) 유저가 자기 계정과 포스트에 붙은 visibility-impacting 라벨을 조회하는 투명성 툴. 일일 배치가 수집하고 서빙 층이 집계한다 under-the-hood/ Under the Hood

↑ 목차

피드 지면

이 문서가 주로 다루는 피드 지면은 알고리즘이 추천하는 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 피드 6번째 자리에 세로 영상 3~5개를 묶어 넣는 세로 영상 캐러셀 모듈이 2026-10-08 커밋(4세대+38)에서 기본 off로 신설됐다 확인 — home-mixer/util/video_carousel.rs
    • 기본 off는 home-mixer/params/param.rs의 EnableVideoCarousel = false다.
    • For You 요청에서만 켜지고, 직전 캐러셀 노출 후 60분(VideoCarouselFatigueMinutes)이 지나야 다시 나온다.
    • 넣는 자리는 피드 6번째(VideoCarouselPosition)다. 세로 영상은 가로/세로 비가 1.0 미만인 영상이다.
    • 2026-10-15 커밋(4세대+42)부터 정렬 점수는 video_open 가중과 immersive 지면 모델(GalleryPage)의 VQV 예측 가중을 더한 값이다. VQV 가중 기본값이 0.0이라 기본 동작은 video_open 순 그대로다 확인 — home-mixer/util/video_carousel.rs:104-123.
    • 2026-10-17 커밋(4세대+44)부터 캐러셀 안의 중복 제거 세 가지(같은 작성자, SID 앞 3단계 일치, 멀티모달 임베딩 코사인 0.72 이상)가 기본 on이다 확인 — params/param.rs:1095-1112.
    • 2026-10-20 커밋(4세대+45)부터 캐러셀은 메인 피드 상위 35개와 같은 미디어 ID나 MD5 해시를 가진 영상, 캐러셀 안에서 앞서 고른 영상과 같은 미디어를 건너뛴다(파라미터 없음) 확인 — util/video_carousel.rs:52-108.
    • 신설 시점(4세대+38, 2026-10-08)에는 세로 영상을 video_open 예측 확률 순으로 묶었다.
    • 자세한 선정 경로는 변경 이력의 4세대+38에 있다.
  • Following은 인네트워크 포스트만 다루고, 2026-10-17 커밋(862a123) 기준으로 For You와 별도인 gRPC 서비스와 후보 파이프라인을 갖는다 확인 — home-mixer/lib.rs
    • 서비스는 FollowingFeedService와 RankedFollowingFeedService이고, 파이프라인은 following_candidate_pipeline.rs, ranked_following_candidate_pipeline.rs, reverse_chron_posts_pipeline.rs다. 4세대 공개(47c1bcd)에도 같은 구성이 있었다 확인 — home-mixer/server.rs:522-680, home-mixer/candidate_pipeline/
    • 두 Following 서비스는 쿼리를 만든 뒤 in_network_only를 true로 둔다 확인 — home-mixer/server.rs:555, 658
    • 중요: For You 요청이라도 사용자가 allow_for_you_recommendations = false이면 서버가 요청을 in_network_only로 강등한다 확인 — home-mixer/server.rs:96-97
    • Phoenix에 들어가는 product_surface 값도 이 플래그에 따라 HomeTimelineRankedFollowing과 HomeTimelineRanking으로 갈린다 확인 — home-mixer/scorers/phoenix_scorer.rs:86-90.
    • 3세대(0bfc279)에는 Following 전용 서비스가 없었고, 같은 파이프라인의 in_network_only 플래그로만 갈렸다 확인 — home-mixer/server.rs:75-76
    • 2026-08-18 커밋(4세대+3)에서 reverse-chron(Following) 전용 VF hydrator가 분기됐다 확인 — home-mixer/candidate_hydrators/vf_following_candidate_hydrator.rs
      • 새 hydrator는 신규 파일이고 95줄이다.
      • 다른 근거 위치는 home-mixer/candidate_pipeline/reverse_chron_posts_pipeline.rs:141-144다.
      • 이전에는 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)

후보 생성(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 슬라이스 상한은 그 뒤 커밋에서 추가된 항목이다.

소스 무엇 게이트 · 기본값
ThunderSource 인네트워크 최신 포스트 (in-memory, 보존 2일) EnableThunderSource=true (기본 on), ThunderMaxResults=1200
PhoenixSource Phoenix two-tower retrieval(유저 타워와 포스트 타워로 나뉜 임베딩 모델)로 찾은 OON 후보. 2026-09-18 커밋부터 cold-start 슬라이스를 따로 구분한다(아래 세부) EnablePhoenixSource=true, PhoenixMaxResults=1000. cold 슬라이스 상한 PhoenixColdStartMaxResults=200(2026-09-30, 4세대+32부터)
SimclustersSource SimClusters (2026-08 부활). 클러스터 유사도로 찾은 OON 후보 EnableSimclustersSource=true
TweetMixerSource 레거시 OON 집계기 (2세대 tweet-mixer를 thrift로 호출) EnableTweetMixerSource=false 기본 off, TweetMixerMaxResults=800
PhoenixTopicsSource 토픽 요청용 Phoenix retrieval 토픽 요청
PhoenixMOESource Phoenix MoE retrieval EnablePhoenixMOESource
SidSource SID generative retrieval microservice (4세대+34~35, 2026-10-02 ~ 10-03 공개, 아래 세부) 홈믹서 측 EnableSidSource=false 기본 off, SidSourceMaxResults=800, SidSourceMaxSeeds=50, SidSourceMaxPerSeed=100, SidSourceMinPrefixDepth=3 (2026-10-02 커밋 기준)
PopularPostsSource 오프라인 잡이 미리 만든 인기 포스트 목록 (아래 세부) EnablePopularPostsSource=true, 2026-10-07 커밋(4세대+37)부터 기본 on(param.rs:944-948). PopularPostsMaxResults=500
CachedPostsSource Redis 워밍 캐시 has_cached_posts (다른 전부의 역조건)
ScoredPostsSource 위 파이프라인 전체를 재귀 실행 항상
AdsSource 광고 EnableAdsSource && !is_preview
WhoToFollowSource 팔로우 추천 (최대 3명) EnableWhoToFollowModule
PromptsSource 인라인/커버 프롬프트 EnablePrompts
PushToHomeSource 푸시에서 온 포스트를 0번에 고정 push_to_home_post_id.is_some()

표에서 줄인 세 소스의 세부는 다음과 같다.

  • 2026-09-18 커밋(4세대+25)에서 PhoenixSource에 cold-start 슬라이스가 dataset_type 태그로 first-class로 연결됐다 확인(콜드스타트 절).
    • 응답 후보의 dataset_type == HOME_COLD_DATASET_TYPE(=13)이면 served_type이 ForYouPhoenixRetrievalCold이고, 그 외는 기존 ForYouPhoenixRetrieval이다.
    • 이 슬라이스의 상한 PhoenixColdStartMaxResults는 4세대+25에서 0(기본 off)으로 신설됐고, 2026-09-30 커밋(4세대+32)부터 200이다 확인 — params/param.rs:16. 2026-10-17 커밋(862a123)에서도 200이다.
  • SidSource는 2026-10-02~03 커밋(4세대+34~35)에서 공개된 SID generative retrieval microservice를 부른다 확인(4세대+34, 4세대+35).
    • 유저의 positive engagement post ID들을 seed로 넘기면, 서비스는 세 단계로 후보를 만든다.
      1. seed를 6-level 코드로 바꾼다. seed post가 로컬 SID 트라이에 있으면 트라이에서, 없으면 외부 SidLookupService에서 찾는다.
      2. seed마다 max_prefix_depth에서 min_prefix_depth까지 깊이가 깊은 순으로 공유 접두사 범위에서 후보를 뽑는다. 이미 더 깊은 수준에서 소비된 범위는 뺀다.
      3. 모든 seed의 후보 리스트를 round-robin 인터리브하고, 중복을 제거해 돌려준다.
    • seed는 simclusters_source::post_signal_ids와 같은 함수를 재사용해 유저 engagement 신호에서 뽑는다.
    • 각 Candidate에는 shared_prefix_depth가 점수로 담긴다.
    • 응답 후보는 served_type=ForYouPhoenixRetrievalMoe로 라벨된다. PhoenixMOE와 같은 라벨이다.
    • 호출에는 300ms gRPC 데드라인이 걸리고, 연결 경로는 xDS P2C → DNS 폴백의 2-경로다.
    • 서버는 parquet 스냅샷(post_sid_snapshot.parquet, 컬럼 post_id/author_id/post_sid)을 30초마다 폴링해 전부 다시 적재한다.
    • 트라이는 by_key(prefix 범위 이진탐색용)와 by_post(seed lookup용) 두 벡터로 이뤄진다.
    • 서버 측 상한은 max_results=800 / max_per_seed=100 / min_prefix_depth=3 / max_prefix_depth=6 / max_seeds=200이고, 하드캡은 MAX_RESULTS_CAP=5000 · MAX_PER_SEED_CAP=2000 · MAX_CALLER_LEN=64다.
  • PopularPostsSource는 2026-10-03 커밋(4세대+35)에서 생겼고, 2026-10-06 커밋(4세대+36)에서 구조가 전면 교체됐다 확인(4세대+35, 4세대+36).
    • 교체된 구조에서는 요청 시점에 Thunder를 부르지 않는다. 오프라인 잡 두 개가 미리 만든 인기 포스트 목록(최대 500개)을 Manhattan KV에서 60초마다 읽어 오고, 유저가 이미 본 포스트(seen_ids)만 빼고 그대로 후보로 낸다.
    • 첫째 잡 popular_authors_job(1시간 주기)은 BigQuery 스냅샷에서 최근 7일 안에 글을 쓴 작성자 중 팔로워 수 상위 0.005%(TOP_POSTING_AUTHORS_FRACTION=0.00005)를 뽑는다.
    • 둘째 잡 popular_posts_job(5분 주기)은 그 작성자들의 원본 포스트(답글·리트윗 제외)를 Thunder에서 모은다. 24시간 이내 포스트의 조회수를 engagement counts에서 받아 점수를 매긴 뒤, 작성자당 5개, 전체 500개로 자른다.
      • 점수 식은 quality = views × (1−e^{−λ·24h}) / (1−e^{−λ·max(age,0.5h)})(반감기 8시간)이다.
    • 라벨은 2026-10-16 커밋(4세대+43)부터 자체 served type ForYouPopularPosts이고, 그 전까지는 ForYouPhoenixRetrieval이었다(popular_posts_source.rs:13).
    • 캐시 재사용 요청과 in_network_only 요청에서는 꺼진다(popular_posts_source.rs:27-31).
    • 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
  • LightPost에는 텍스트도 미디어도 참여수도 안전라벨도 없다 확인 — thunder/kafka/tweet_events_listener.rs:230-248
    • 들어 있는 필드는 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,
};

effective_oon은 세 갈래로 계산되고, 그 값은 4세대 공개에서 전부 공개됐다 확인 — home-mixer/params/param.rs:247,269,275

param 기본값 조건
OonWeightFactor 0.75 기본 OON 배수
TopicOonWeightFactor 0.5 토픽 요청 시
NewUserAgeThresholdSecs 0 신규 유저 라우팅 임계. 0초라는 것은 사실상 신규 유저 경로가 비활성 상태로 미러링돼 있다는 뜻이다 추정. NEW_USER_OON_WEIGHT_FACTOR param은 param.rs에 없다고 확인됐다
EnableOonRescoreForInNetworkRepliesRetweets true 인네트워크 답글·리트윗도 OON 배수 적용

일반화 노트는 candidate-allocation.md에 있다.

레거시 (2025 Scala, tweet-mixer)

tweet-mixer는 2025-09-03에 파일 384개로 한꺼번에 등장했다. README도 .md 파일도 하나 없어서 모든 사실을 코드에서만 확인해야 한다 확인.

이 세대에서 새로 들어온 retrieval 계열은 다음과 같다.

  • DeepRetrieval 관련 파일이 19개 새로 생겼다(2023 스냅샷에는 0개) 확인
  • SimClusters를 소비하는 새 파일이 20개 생겼고, SimClusters 코어 자체는 2023 그대로다 확인 — git show --diff-filter=M c54bec0 | grep -ci simclusters = 0
    • 새 파일에는 SimclusterColdPostsCandidateSource.scala가 들어 있다.
    • 코어는 src/scala/com/twitter/simclusters_v2와 simclusters-ann/이다.
    • 즉 엔진은 고치지 않고 소비 지점만 늘렸다.
  • Earlybird를 소비하는 파일이 15개 새로 생겼고, 인덱스 본체(src/java/com/twitter/search)는 바뀌지 않았다 확인
  • Hydra는 2023 cr-mixer에 thrift 클라이언트로 이미 있었고, 2025에 scorer와 embedding generation service로 승격됐다. "Hydra는 2025 신규"는 틀린 말이다 확인

SimClusters, TwHIN, DeepRetrieval 각각의 임베딩 생성·인덱스·조회 방식과 그 안에서 발견한 코드 결함은 embedding-retrieval.md에 정리했다.

↑ 목차

랭킹

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).

Phoenix (2026, 현행) — 4세대 공개 반영

Phoenix는 Grok-1을 포팅한 트랜스포머다. 유저 이력과 후보를 한 시퀀스에 넣고 한 번에 채점한다. 구조, 마스크, 파라미터 분포는 sequence-transformer-ranking.md에 정리돼 있다.

예측 헤드는 문서 기준 24개이고, 카테고리 5개로 나뉜다 확인 — README.md #scoring-and-ranking (2026-08-13). 표는 이 README 기준이고, 그 뒤 빠지거나 새로 생긴 가중치 항은 가중치 표에 있다.

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

체류 시간을 다루는 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.
    • 헤드 이름은 click_dwell_time이다.
    • 서빙 헤드는 확률 [0,1]로 남는다. _rescale_sigmoid_heads_for_inference가 binary 예외로 원단위 복원을 건너뛴다.
  • 2026-09-17 커밋(42266f3, 4세대+23)에서 click dwell의 랭킹 항이 재정의됐다 확인(변경 이력의 4세대+23).
    • 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로 재정의한 것이다.
    • ContClickDwellTimeWeight는 미러상 바뀌지 않았다.
  • 2026-10-08 커밋(35650fb, 4세대+38)에서 dwell_time 헤드의 학습 손실이 Tweedie·MAE에서 CREAD로 바뀌었다 확인 — phoenix/xrex/configs/xrecsys.py:272-275, 715-742.
    • 새로 켜진 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세대에서 정리됐는지는 심층 분석이 필요하다. 확인(문서) / 추정(코드 정리 여부)

헤드 설계를 일반화한 내용은 multi-task-ranking.md에 있다.

4세대 공개(47c1bcd) 기준으로 최종 점수는 다음 순서로 계산했다 확인 — home-mixer/scorers/ranking_scorer.rs.

  1. 26개 weight × P(head)를 가중합한다. negative_sum은 부호를 뒤집어 뺀다.
    • 상호팔로우 원본 포스트에 한해 reply 가중치에 BidirectionalFollowReplyWeightBoost=15.0을 더한다. 조건은 !in_reply_to && !retweeted && is_mutual_follow_author다.
  2. offset_score를 적용한다. 점수가 음수면 (score + negative_sum) / total_sum * NEGATIVE_SCORES_OFFSET, 양수면 score + NEGATIVE_SCORES_OFFSET이다.
  3. 작성자 다양성 감쇠를 곱한다. 같은 작성자의 k번째 등장에 (1-0.25)*0.5^(k-1) + 0.25를 곱하므로 첫 등장은 1.0배다.
    • 지수는 그 작성자가 앞서 나온 횟수다(ranking_scorer.rs:614-615, 635).
    • 설정은 AuthorDiversityDecay=0.5, AuthorDiversityFloor=0.25, EnableAuthorDiversity=true다.
  4. OON 배수 OonWeightFactor=0.75를 곱한다. 토픽 요청이면 TopicOonWeightFactor=0.5를 쓴다.
  5. 새-작성자 부스트를 적용한다.

3세대 코드(0bfc279)에 있던 normalize_score 정규화 단계는 4세대 공개의 ranking_scorer.rs에 없다 확인 — ranking_scorer.rs:4, 259.

2026-09-24 커밋(4세대+28, 44d37eb)에서 ranking_scorer.rs가 삭제됐고, 가중합과 offset, 작성자 다양성 감쇠, OON 배수 계산은 공유 crate xai-value-model로 옮겨졌다 확인 — xai-value-model/scoring.rs.

영상 길이와 쿼리의 유저 팔로워 수에 따라 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).
  • 4세대 공개에서는 ranking_scorer.rs:230이 이 함수를 호출했다.

VMRanker와 DPP의 설정값이 이제 공개됐다 확인 — home-mixer/params/param.rs:579-618. 표의 값은 4세대 공개 시점의 것이고, VMRankerClusterId만 2026-10-06 값이다.

param 기본값 의미
EnableVMRanker true canon의 열린 질문 "VMRanker/DPP가 켜져 있는가"에 대한 직접 답
VMRankerValueModelId "dpp" Determinantal Point Process 선택
VMRankerDppTheta 0.65 DPP mixing 파라미터
VMRankerDppMaxSelectedRank 150 슬레이트 크기 상한
VMRankerEnableFallback false 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.

VMRanker 서비스 본체는 4세대 공개부터 vm-ranker/에 있다 확인 — vm-ranker/ranker_service.rs.

  • 같은 디렉토리에 main.rs, DPP 구현인 dpp.rs, scoring/dpp_model.rs가 있다 확인 — vm-ranker/.
  • 2026-09-23 커밋(4세대+27)에서 scoring/value_model.rs, params.rs, config_sync.rs가 더해졌다 확인 — vm-ranker/.
  • VMRanker 응답이 후보의 score를 덮어쓰는 구조는 3세대부터 그대로다 확인 — vm_ranker.rs:116-131.
  • gRPC 호출이 실패하면 2026-09-24 커밋(4세대+28)부터 DPP 없이 로컬 가중합 점수를 내보내고, 실패를 VMRanker.local_score_fallback 메트릭으로 센다 확인 — vm_ranker.rs:107-113, 170-178.

2026-08-18 커밋(4세대+3)에서 VMRanker 요청에 SID(semantic ID) 필드 7개가 새로 연결됐다 확인 — home-mixer/scorers/vm_ranker.rs:194-206:

VMRanker request 필드 source 필드 의미
sid_known s.sid_known 이 후보의 SID가 known/valid인가 (bool)
sid_k1 / sid_k2 / sid_k3 s.sid_k_l1 / sid_k_l2 / sid_k_l3 3-level 계층 semantic ID 각 레벨의 codeword
sid_gap1 / sid_gap2 / sid_gap3 s.sid_gap_l1 / sid_gap_l2 / sid_gap_l3 각 레벨 코드북에서의 이웃 gap(신뢰도/희소성 대리 지표 추정)
  • 이 필드들이 기존 필드와 나란히 후보 소스별로 전달되면서, VMRanker가 아이템 다양성 판정에서 collaborative pool rank뿐 아니라 semantic ID 계층 위치를 볼 수 있게 됐다.
    • 기존 필드는 k, pool_rank, pool_rank_gap이다.
    • VMRanker는 레거시 이름을 유지하지만 이제 DPP 슬레이트 재정렬을 담당한다.
    • Kuaishou HD-Rec/PushDualGen에서 관찰된 SID 계열에 대응하는 X 쪽 연결이다 추정.
  • 의미: VMRanker의 다양성 판단은 이제 후보 소스별 rank, 3-level semantic ID codeword, 이웃 gap의 3축 정보를 갖는다.
    • 3레벨 코드북 계층(coarse → fine)이 있다는 사실 자체가 새로 확인된 아키텍처 정보다.
    • 이 연결은 Phoenix retrieval의 SID window와 같은 SID 인프라를 rank/rerank 쪽에서 소비하는 자리다. SID window(video_14day·video_48h 등)는 Phoenix 학습·인덱스 절의 4세대+1 항목에 있다.
  • 미공개: 다음은 공개되지 않았다.
    • SID 코드북 정의(각 level의 vocabulary 크기·훈련 방식)
    • sid_gap의 정확한 계산식
    • VMRanker 응답 로직에서 이 필드들이 어떻게 활용되는지

새-작성자 부스트 (New-Author Boost) — 4세대에서 문서화된 세 번째 조정 인자

새-작성자 부스트는 노출이 기준치보다 적은 작성자의 포스트를 목표 위치 쪽으로 끌어올리는 조정 인자다. README는 이렇게 설명한다.

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

새 README는 스코어링 조정 인자를 세 개로 명시한다 확인 — README.md #scoring-and-ranking.

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

이 부스트를 정하는 param은 아래 표와 같다 확인 — home-mixer/params/param.rs:351-374, #coldstart-threshold.

param 기본값 의미
PostUnexploredWeight 0.02 → home-mixer 미러 0.015(4세대+37, 2026-10-07). vm-ranker/params.rs는 0.02 그대로 새 작성자 부스트 가산값
PostUnexploredWeightInNetworkOnly true 인네트워크에만 적용 (OON에는 안 붙음)
EnableMultiplicativePostUnexplored false 기본은 가산형, 곱셈형 비활성
MultiplicativePostUnexploredAlpha 0.0 곱셈형 활성 시 사용될 지수
ColdStartImpressionThreshold 200 (2026-09-30, 4세대+32부터) README는 노출이 임계 미만인 작성자를 대상으로 설명하지만, 코드는 후보 포스트의 홈 노출 수가 이 값보다 작을 때만 콜드스타트 슬롯 후보로 본다 확인

ColdStartImpressionThreshold는 2026-09-30 커밋(4세대+32)에서 1000에서 200으로 내려갔다 확인 — params/param.rs:459(변경 이력의 4세대+32). 2026-10-17 커밋(862a123)에서도 200이다.

  • 이 임계를 쓰는 콜드스타트 슬롯 선택기는 후보 포스트의 view_count_on_home이 임계보다 작은 후보만 고른다 확인 — author_cold_start.rs:293-294.
  • 4세대 공개 코드도 후보 포스트의 view_count로 같은 비교를 했다 확인 — author_cold_start.rs:179.

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 시각은 파일 헤더에 명시된다는 것이다.

파일 헤더가 이 미러의 성격을 규정한다 확인 — home-mixer/params/param.rs:1:

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

README가 미러링의 의미를 명시한다 확인 — README.md #experiments-and-configuration:

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

엔게이지먼트 헤드 가중치는 아래 표와 같다(파일 등장 순, 2026-08-12 sync) 확인.

헤드 기본값 비고 (2023-04-05 스냅샷 대비)
favorite 0.5 (home-mixer 미러는 4세대+40에서 0.6, 4세대+41부터 0.66. vm-ranker 미러는 0.5) 0.5 → 일치
reply 5.0 13.5 → 크게 감소. 단 상호팔로우 원본에는 +15 boost로 실효 20
retweet 1.0 1.0 → 일치
photo_expand 0.05 스냅샷에 대응 없음
video_open 0.05 (4세대+39 시점 두 미러 모두 0.07. home-mixer 미러는 4세대+40부터 0.02, vm-ranker 미러는 0.07) 스냅샷에 대응 없음
click (포스트) 0.4 (4세대+31(2026-09-29)부터 두 미러 모두 0.3) good_click 11 / good_click_v2 10 대비 스케일 크게 다름 — 다른 헤드 가능성 추정
open_link 0.2 스냅샷에 대응 없음
profile_click 0.0 good_profile_click 12 → 완전 제거
vqv (video quality view) 0.05 (4세대+8(2026-08-25)부터 0.0. 2026-10-17 커밋 기준 두 미러 모두 0.0) video_playback50 0.005 대비 10배 증가
share 2.0 스냅샷에 대응 없음
share via DM 5.0
share via copy link 20.0 최고 양수 개별 헤드
dwell 0.0 (4세대+8(2026-08-25)부터 0.05. 2026-10-17 커밋 기준 두 미러 모두 0.05) 2026-08-12 sync 기준으로 실 dwell 신호는 연속형 cont_dwell_time_weight = 0.004였다
quote 5.0
quoted_click 0.05
quoted_vqv 0.0
follow_author 4.0
post_unexplored 0.02 (home-mixer 미러는 4세대+37부터 0.015) 새-작성자 부스트, 위 조정 인자 절
cont_dwell_time 0.004 (home-mixer 미러는 4세대+40부터 0.006, vm-ranker 미러는 0.004)
cont_click_dwell_time 0.0 (4세대+31(2026-09-29)부터 두 미러 모두 0.4)
cont_active_secs_5m_residual_norm — 4세대+36(2026-10-06, e62790c)에서 제거. proto 슬롯 3을 HOME_VIDEO_CONTINUATION_SECS가 대신 차지
video_continuation 0.0 4세대+36 신설. P(video_open) × E[이어 본 초 | open] 곱으로 들어간다
user_video_continuation 0.0 4세대+36 신설. 후보 대신 요청 내 후보 전체의 open-가중 평균 초를 쓴다
profile_visit_secs 0.0 4세대+36 신설. P(profile_click) × E[프로필 체류 초 | click]
react_with_video 0.0 4세대+40 신설. 행동 SERVER_TWEET_REACT_WITH_VIDEO(ActionName 44, 구 CLIENT_CARD_CLICK 자리)

상호팔로우 부스트는 2026-07에 도입돼 2026-07-24에 조정됐다 확인 — docs/BIDIRECTIONAL_BOOST_CHANGE.md.

param 기본값 조건
BidirectionalFollowReplyWeightBoost 15.0 상호팔로우 원본 포스트
BidirectionalFollowDwellWeightBoost 0.0 코드는 있으나 미배포

reply 부스트 값은 세 단계를 거쳐 정해졌다 확인 — 같은 문서.

  • 2026-07-10: 5/10/15/20으로 A/B를 시작했다 확인 — 같은 문서.
  • 07-13: 20으로 롤아웃했다 확인 — 같은 문서.
  • 07-24: 20에서 15로 내렸다. 월드컵 시즌 유저 피드백을 반영한 조정이다 확인 — 같은 문서.

부스트 대상인지는 아래 함수가 판정한다 확인 — home-mixer/scorers/ranking_scorer.rs:

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

즉 원본 포스트에 한해서만 reply 가중치가 5.0에서 20.0이 된다. 답글과 리트윗은 부스트 대상이 아니다.

부정 헤드 가중치는 아래 표와 같다 확인.

헤드 기본값 2023-04-05 스냅샷 대비
not_interested −43.2 (home-mixer 미러는 4세대+31에서 −47.52, 4세대+41부터 −52.272. vm-ranker 미러는 −47.52) 대응 없음
block_author −31.2 대응 없음
mute_author −58.8 대응 없음
report −234.0 report −369 → 완화 방향
not_dwelled −0.02 대응 없음

파일 자체 주석이 가중치 부호와 크기의 의미를 규정한다 확인:

"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 네트워크 전체에서 보통 얼마나 일어나는지를 뜻한다. 리포트가 워낙 드물기 때문에 큰 절대값이 붙어 있다.

2026-08-14 커밋(4세대+1)은 이 해석 규칙을 코드 주석과 README에 명시했다 확인 — home-mixer/params/param.rs:281-306, home-mixer/scorers/ranking_scorer.rs:415-446, README.md #scoring-and-ranking:

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

같은 주석은 "mass reporting으로 도달이 균일하게 억눌린다"는 오해도 두 가지 이유로 부정한다 확인 — 같은 주석 블록.

  1. 스코어는 당신의 예측 확률에 곱하므로, 봇 계정의 리포트는 그 봇들과 유사한 유저의 추천에 주로 영향을 준다(개인화) 확인 — 같은 주석.
  2. Home Timeline에 서빙된 포스트에서 이뤄진 액션만 랭킹 신호로 반영된다. 그룹채팅으로 링크를 공유해서 나타난 반응은 랭킹에 영향을 주지 않는다 확인 — 같은 주석.

가중치 미러의 caveat 다섯 가지

이 미러를 읽을 때 주의할 점이 다섯 가지 있다.

  1. 크론 sync는 하루 단위로 따라가는 미러라 시차가 있다. 헤더에 적힌 sync 시각 이후의 프로덕션 변경은 아직 반영되지 않았을 수 있다. 확인
  2. 부호와 크기는 propensity를 감안해 해석해야 한다. 파일 자체 주석이 "값 = 가치 × 1/propensity"라고 명시한다. 절대값 비율(report −234 vs favorite 0.5의 468배)이 그대로 상대 중요도가 아니다.
  3. 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.
    • 공유 crate xai-value-model이 실물 파일로 공개되면서 두 프로세스가 같은 코드로 value model을 계산한다 확인. 다만 각 프로세스는 자기 쪽 파라미터 파일의 가중치를 읽는다 확인 — home-mixer/scorers/value_model.rs:8-51, vm-ranker/scoring/value_model.rs:68.
    • phoenix 학습 hyperparameter와 botmaker 규칙 상수는 여전히 레포 밖에 있다. 3세대 canon이 레포 밖이라고 본 crate::util은 4세대 공개부터 home-mixer/util/로 공개돼 있다 확인 — home-mixer/lib.rs.
  4. Phoenix 학습 코드는 "학습 가능한" 것이지 "프로덕션과 같은" 것이 아니다. README는 규모가 다르다는 점을 이렇게 명시한다: "a small model can be trained and served end-to-end".
  5. 일부 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-ml projects/home/recap/README.md

  • 그 표의 값은 다음과 같다.
    • 양수 항목: fav 0.5 / retweet 1.0 / reply 13.5 / good_profile_click 12.0 / video_playback50 0.005 / reply_engaged_by_author 75.0 / good_click 11.0 / good_click_v2 10.0
    • 음수 항목: negative_feedback_v2 −74.0 / report −369.0

세대를 건너뛰어 복원되는 상수도 있다. 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).
  • 응답이 logprob(로그 확률)이라 math.exp(logProb)로 확률을 복원한다(:151-158).
  • 후보에 실려 가는 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 포스트를 맨 앞에 고정한다 확인(최종 배치 절).

필터 21개 (2026-08, 4세대+1) — 스코어링 전 18 + 후 3

2026-10-17 커밋(862a123) 기준으로 스코어링 전에 걸리는 필터는 20개이고, 아래 순서대로 실행된다 확인 — phoenix_candidate_pipeline.rs:419-451. 2026-08-14 커밋(4세대+1) 기준으로는 18개였다. 그 근거는 README.md #filtering과 phoenix_candidate_pipeline.rs다.

  • DropDuplicatesFilter
  • ZeroFavCodivertFilter(4세대+44 신규, 2026-10-17)
  • CoreDataHydrationFilter
  • AgeFilter
  • SelfTweetFilter
  • OONRetweetReplyFilter(4세대 신규)
  • OONNsfwSimclustersFilter(4세대 신규)
  • RetweetDeduplicationFilter
  • IneligibleSubscriptionFilter
  • PreviouslySeenPostsFilter
  • PreviouslySeenPostsBackupFilter
  • PreviouslyServedPostsFilter
  • ViewerMutedKeywordFilter(2026-08-19 커밋(4세대+4)에서 MutedKeywordFilter를 대체)
  • AuthorSocialgraphFilter
  • Brazil2026ElectionFilter(4세대+1 신규, 2026-08-14)
  • VideoFilter
  • TopicIdsFilter
  • NewUserMinEngagementFilter(4세대 신규, NewUserTopicIdsFilter 대체)
  • InventoryHoldoutFilter(4세대 신규)
  • FavHoldoutFilter(4세대+23 신규, 2026-09-17)

2026-10-17 커밋(862a123) 기준으로 선택 후에 걸리는 필터는 VFFilter, AncillaryVFFilter, AncestorVideoFilter, DedupConversationFilter, VideoCarouselFilter 5개다 확인 — phoenix_candidate_pipeline.rs:518-524.

  • 2026-08-14 커밋(4세대+1)까지는 VFFilter, AncillaryVFFilter, DedupConversationFilter 3개였고, 3세대와 같았다 확인.
  • VideoCarouselFilter는 2026-10-08 커밋(4세대+38)에서, AncestorVideoFilter는 2026-10-13 커밋(4세대+40)에서 더해졌다 확인 — 35650fb, e1a0375의 phoenix_candidate_pipeline.rs.

아래 불릿 가운데 처음 4개가 4세대 공개에서 새로 생긴 필터다.

  • OONRetweetReplyFilter는 팔로우하지 않는 계정의 리트윗과 답글, 그리고 부모 없는 답글을 거른다. RetweetDeduplicationFilter와는 별개다.
  • OONNsfwSimclustersFilter는 SimClusters 부활과 짝을 이뤄 새로 생겼다. SimClusters 소스에서 나온 성인 콘텐츠 계정의 포스트를, 그 계정을 팔로우하지 않는 유저의 피드에서 걸러 낸다.
  • NewUserMinEngagementFilter는 신규 유저에게 보여 줄 OON 포스트 중 참여 임계에 못 미치는 것을 제거한다. 관련 param은 6개다.
    • NewUserMinEngagementThreshold, NewUserMinEngagementFilterMetric, NewUserMinEngagementFilterUseRatio, NewUserMinEngagementFilterMaxAccountAgeSecs, NewUserMinEngagementFilterMaxResurrectionAgeSecs, EnableNewUserMinEngagementFilter
  • InventoryHoldoutFilter는 결정론적 유저×포스트 해싱으로 3-tier 비율만큼 인벤토리를 홀드아웃하고, 2026-09-17 커밋(4세대+23, 42266f3)에서 params가 확인됐다 확인 — home-mixer/filters/inventory_holdout_filter.rs
    • 해싱은 splitmix64 계열 이중 mixing을 거쳐 100-modular 버킷으로 나눈다.
    • Param은 InventoryHoldoutOriginalsPercent, InventoryHoldoutRepliesPercent, InventoryHoldoutRetweetsPercent 3개이고, 각각 .min(100)으로 클램프한다.
    • 포스트 종류는 retweeted_tweet_id.is_some() → Retweet, in_reply_to_tweet_id.is_some() → Reply로 판별하고, 그 외는 Original로 본다. quoted post는 Original로 취급한다.
    • 게이트는 EnableInventoryHoldout=true이면서 동시에 세 값 중 하나라도 0보다 클 때 열린다.
    • 리트윗은 get_original_tweet_id()(원본)에 대해 판정하므로, 원본과 홀드아웃 결정을 공유한다.
  • FavHoldoutFilter는 2026-09-17 커밋(4세대+23, 42266f3)에서 새로 생겼고, fav count 계단별로 홀드아웃한다. Param EnableFavHoldout=false로 기본 off다 확인 — home-mixer/filters/fav_holdout_filter.rs
    • 계단 테이블은 다음 23-tier다.
      • 10K+ fav = 15%, 5K+ = 15%, 3K+ = 15%, 2K+ = 14%, 1.5K+ = 13%, 1K+ = 13%, 750+ = 12%, 500+ = 11%, 300+ = 10%, 200+ = 9%, 150+ = 8%, 100+ = 7%, 75+ = 7%, 50+ = 6%, 30+ = 6%, 20+ = 5%, 15+ = 5%, 10+ = 5%, 7+ = 4%, 5+ = 4%, 3+ = 3%, 2+ = 3%, 1+ = 2%, ≤1 = 0%
    • 홀드아웃 판정은 InventoryHoldoutFilter::is_held_out(post_id, viewer_id, percent)를 재사용하므로(같은 splitmix64 해시) 뷰어별로 결정론적이다.

4세대+1(2026-08-14, c65aa17)에서 새 필터 1개가 생겼다.

  • Brazil2026ElectionFilter는 브라질 선거법원(TSE)이 브라질 2026 총선과 관련해 조치한 계정과 엮인 포스트를, 뷰어가 그 계정을 팔로우하지 않으면 걸러 낸다 확인 — home-mixer/filters/brazil_2026_election_filter.rs
    • 계정 목록은 665개 계정을 하드코딩한 FxHashSet<u64>이다.
    • 후보에서 대조하는 필드는 author_id, retweeted_user_id, quoted_user_id, ancestor_users이고, 이 중 하나라도 그 집합에 있으면 대상이 된다.
    • 파이프라인에서는 AuthorSocialgraphFilter 직후, VideoFilter 직전에 놓인다. 다른 근거 위치는 phoenix_candidate_pipeline.rs:356-368다.
    • 근거 자료는 브라질 선거법원 열린 데이터셋과 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는 이 경로의 누락 결과에 해당한다.
    • 2026-10-17 커밋(862a123)에서도 두 처리와 should_drop_action은 같다 확인 — vf_client.rs, vf_filter.rs.
  • 인네트워크와 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

4세대+4(2026-08-19, aad7179)에서 Following 파이프라인 전용 뮤트 키워드 필터와, 이를 뒷받침하는 quoted-post text hydrator가 새로 붙었다 확인 — home-mixer/filters/following_viewer_muted_keyword_filter.rs(65줄 신규), home-mixer/candidate_hydrators/quoted_post_text_hydrator.rs(106줄 신규). 이 커밋은 Following 파이프라인이 2026-08-18 커밋(4세대+3)에서 VFFollowingCandidateHydrator로 분기된 흐름의 연장선이다.

  • 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 채널이 새로 생겼다.

기존 topic feedback context(사용자 관심 topic 추론용 컨텍스트 라벨)와 나란히 두 번째 컨텍스트 채널이 열렸다 확인 — home-mixer/candidate_hydrators/ai_trend_feedback_context_hydrator.rs(157줄 신규). 파이프라인에서는 TopicFeedbackContextHydrator 뒤에 나란히 연결됐다 확인 — home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs:424-428. 다른 근거 위치는 home-mixer/util/urt/post_marshaller.rs:72-110다.

  • 게이트는 3중이다. 스위치는 EnableAiTrendFeedbackContext=false로 기본 off이고, 나머지 두 조건은 !query.is_topic_request()와 !query.in_network_only다. 즉 topic 지면·Following 지면과 겹치지 않고, For You OON 후보만 대상으로 한다.
  • 후보는 standalone original post 4중 조건으로 거른다. 즉 AI trend 라벨은 답글, 리트윗, 대화 조상이 있는 포스트, 팔로잉이 답글을 단 대상이 아닌 원본 포스트에만 붙는다.
    • 조건식은 in_reply_to_tweet_id.is_none() && retweeted_tweet_id.is_none() && ancestors.is_empty() && following_replied_user_ids.is_empty()다.
  • 한 페이지에 AI trend 라벨은 최대 2개가 붙고, trend마다 한 후보에만 붙는다. 이 제한은 같은 trend의 여러 후보를 같은 라벨로 도배하지 않게 하는 억제 장치다.
    • 선정 방식은 Top-2 trend와 trend당 후보 1개 랜덤이다.
    • 후보들의 trend_id를 Strato batch_get_tweet_ai_trend로 조회하고, 빈도 히스토그램에서 상위 2개 trend를 고른다(TOP_TREND_COUNT = 2). 동률이면 trend_id가 작은 쪽이 우선이다.
    • 각 trend에 대해 그 trend로 라벨링된 후보 중 1개를 무작위로 고르고, Strato batch_get_ai_trend_name로 표시 이름을 조회한다.
  • 렌더할 때는 topic feedback이 우선한다. post_marshaller에서 topic feedback이 있으면 topic이 이기고, 없을 때만 AI trend가 폴백으로 들어간다.
    • 둘 다 URT에서 ContextType::TOPIC 타입으로 나간다.
    • AI trend는 landing_url = "https://x.com/i/trending/{trend_id}"를 채우고, TopicFeedbackContext.topic_id가 None이다.
    • 즉 chip을 탭하면 topic feedback 페이지가 아니라 trending 지면으로 이동한다.
  • 의미: 이 채널은 랭킹 상류의 컨텍스트·시그널 소스 자체를 넓히는 방향이다. 사용자가 이 chip을 탭하거나 닫거나 무시하는 반응이 "이 유저는 이 AI trend에 관심 있는가"의 라벨 소스가 된다.
  • 미공개: 다음은 공개되지 않았다.
    • AI trend의 정의(Grok으로 트윗 클러스터링? 사람 큐레이션?)
    • batch_get_tweet_ai_trend upstream 파이프라인의 위치
    • EnableAiTrendFeedbackContext 실 배포 여부

다양성

X For You의 다양성 장치는 세 겹이다.

  1. 작성자 다양성 감쇠는 4세대 공개 때 랭킹 스코어러 안에서 적용됐고, 2026-09-24 커밋(4세대+28)부터는 vm-ranker 서비스가 공유 crate xai-value-model로 계산한다 확인(Phoenix 절의 최종 점수 계산 순서).
  2. VMRanker DPP는 후보를 집합 단위로 최적화하고, 서비스 본체는 4세대 공개부터 vm-ranker/에 있다 확인(Phoenix 절의 VMRanker 문단).
    • 4세대+20(6bb4594, 2026-09-12)에서 optional seed_tweet_id 인자가 추가됐다.
    • 이 id의 임베딩이 있으면 seed가 greedy DPP의 첫 슬롯에 pinning되고 최종 결과에서는 걸러져 나가므로, top_k 예산은 소진하지 않으면서 seed와 유사한 후보만 페널티를 받는다.
    • 즉 다양성 축을 집합 내부에서 참조 tweet 대비로도 확장할 수 있게 됐다.
    • 프로덕션 지면 중 이 필드를 실어 주는 곳이 어디인지는 아직 공개되지 않았다.
  3. 대화 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
  • 2026-10-08 커밋(4세대+38)부터 기본 off인 영상 캐러셀 모듈을 VideoCarouselPosition(기본 6)번째 자리에 삽입한다. 실행 순서는 광고 블렌딩 → 프롬프트 → WTF → 캐러셀 → push-to-home 고정 → 프레임 → 설문이다 확인 — home-mixer/selectors/blender_selector.rs:72-85, 137-153

↑ 목차

콜드스타트

콜드스타트는 이력이나 노출이 아직 쌓이지 않은 유저, 작성자, 포스트를 추천에서 어떻게 다루느냐의 문제다. 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다.

  • 2026-09-18 커밋(4세대+25)에서 Phoenix retrieval의 cold-start 슬라이스가 first-class로 연결됐다 확인 — home-mixer/sources/phoenix_source.rs.
    • 이 슬라이스는 Phoenix retrieval 응답에 담긴 dataset_type 축으로 구분한다.
    • HOME_COLD_DATASET_TYPE=13 태그가 붙은 후보는 served_type이 ForYouPhoenixRetrievalCold로 매핑된다.
    • record_phoenix_retrieval_cold_stats가 PhoenixRetrievalColdTweets.* stat block을 발행한다.
    • 이 슬라이스의 별도 상한 param PhoenixColdStartMaxResults는 0(기본 off)으로 신설됐다.
    • 앞의 라우팅이 유저 이력 임계로 모델 플릿을 가르는 것과 달리, 이 슬라이스는 같은 Phoenix retrieval RPC가 반환하는 후보 세트 안을 dataset_type 태그로 나눈다.
    • 신설 시점에는 기본 off라 프로덕션 지분이 없었다.
    • 이 상한은 그 뒤 홈믹서 cold-start 파라미터를 프로덕션 값으로 튜닝한 커밋에서 200으로 켜졌다 확인 — params/param.rs:16.
  • 신규 유저 라우팅 임계는 4세대 공개 기준으로 NewUserAgeThresholdSecs = 0이다 확인. 0초이므로 신규 유저 경로가 사실상 비활성 상태로 미러링돼 있다고 본다 추정.
    • 이 param은 2026-09-24 커밋(4세대+28)에서 홈믹서 param.rs에서 빠졌고, 2026-10-17 커밋(862a123)의 vm-ranker/params.rs에서도 0이다 확인.
  • 4세대에 새로 생긴 OON 참여 임계 필터 NewUserMinEngagementFilter는 참여 임계에 못 미치는 OON 포스트를 제거한다.
  • 3세대에 있던 NEW_USER_OON_WEIGHT_FACTOR, NewUserTopicIdsFilter, EnableNewUserTopicRetrieval은 4세대 param.rs에 없다. 심층 분석이 필요하다.

신규 작성자 (계정 단위)

새 작성자는 임프레션 수로 가려내고, 그 작성자의 인네트워크 포스트에 새-작성자 부스트를 준다 확인(랭킹의 새-작성자 부스트 절). 부스트와 임계 param은 4세대에서 문서화됐고, 부스트 param 전체는 그 절의 표에 있다.

  • 새-작성자 부스트는 2026-10-07 커밋(4세대+37)부터 home-mixer 미러에서 0.015, VM-Ranker 미러에서 0.02다. 인네트워크 포스트에만 붙는다.
    • 4세대에서 문서화된 값은 PostUnexploredWeight = 0.02다.
  • 4세대 README는 노출이 임계 미만인 작성자를 "새 작성자"로 설명하고, 그때 임계 param은 ColdStartImpressionThreshold = 1000이었다. 코드는 이 임계를 작성자가 아니라 후보 포스트의 홈 노출 수에 적용한다 확인(새-작성자 부스트 절).

콜드스타트 슬롯 선택기

콜드스타트 슬롯 선택기는 콜드스타트 대상 후보 집합 안에서 "슬롯을 어느 후보로 채울지"를 고른다. 2026-08-14 커밋(4세대+1)에서 이 선택 방식이 greedy max-score와 Beta reward Thompson sampling 두 갈래로 나뉘었다 확인 — home-mixer/scorers/author_cold_start.rs:151-190. Thompson sampling은 처음에는 기본 off였고, 홈믹서 cold-start 파라미터를 프로덕션 값으로 튜닝한 커밋부터 기본으로 켜져 있다 확인.

param 정의와 4세대+1 시점의 기본값은 param.rs에 있다 확인 — home-mixer/params/param.rs:687-724.

param 기본값 기준 의미
EnableColdStartThompsonSampling true 홈믹서 cold-start 파라미터를 프로덕션 값으로 튜닝한 커밋 greedy 대신 Thompson sampling으로 슬롯을 고를지 정하는 스위치
ColdStartBetaAlpha0 0.75 2026-08-14 커밋(4세대+1) 사전분포 Beta(α₀, β₀)의 α. likes 사전관측치
ColdStartBetaBeta0 49.25 2026-08-14 커밋(4세대+1) β₀. non-like 사전관측치. 두 값의 합 50이 "사전 유효표본 크기"다
ColdStartTsTopK 5 2026-08-14 커밋(4세대+1) Beta 샘플링으로 상위 K를 뽑은 뒤 실제 스코어로 tie-break
ColdStartImpressionScale 1.0 2026-08-14 커밋(4세대+1) view_count 스케일. 샘플링할 때 n = scale × view_count
ColdStartTrackedIds "" 2026-08-14 커밋(4세대+1) 콤마로 구분한 계정/포스트 ID 리스트. 서빙 통계 계측 대상

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가 콜드스타트 파이프라인의 어느 소스에서 몇 번 나왔는지 센다.
    • 카운터는 home_mixer.cold_start_tracked_ids_total{id, source}다.
  • 2026-08-18 커밋(4세대+3)에서 이 계측의 집계 키가 단순화됐다 확인 — home-mixer/scorers/author_cold_start.rs:92-108.
    • 바뀐 함수는 count_tracked_ids다.
    • 이전에는 tweet_id와 author_id를 별도 항목으로 집계했다.
    • 이후에는 tweet_id나 author_id가 tracked 목록에 든 후보를 그 후보의 tweet_id 하나로 센다. author_id 별도 항목은 없어졌다.
    • tracked 목록 필터는 남아 있어서, 목록이 비어 있으면 아무것도 세지 않는다. 2026-10-17 커밋(862a123)의 함수도 같다 확인 — author_cold_start.rs:151-185.
  • 2026-09-04 커밋(4세대+16)에서 record_tracked_ids를 호출하는 쪽과 param 연결이 확정됐다 확인 — home-mixer/scorers/author_cold_start.rs. 이 tracked ID 관측 훅은 A/B와 무관한 상시 관측 채널이다.
    • ColdStartParams::read(query)가 모든 param을 한 번에 읽어 params.tracked_ids에 담는다.
    • record_tracked_ids(candidates, &params.tracked_ids)는 EnableViewerColdStart 게이팅과 무관하게 항상 호출된다.
  • 같은 2026-09-04 커밋(4세대+16)에서 codivert 실험의 impression 대칭성을 회귀 테스트로 고정했다 확인 — 같은 파일.
    • 신규 experiment moe_codivert_viewer의 두 arm이 각각 enable_viewer_cold_start_boost=true/false로 대칭으로 갈리도록 YAML rule 한 쌍이 들어왔다.
    • experiment param은 PhoenixMoeCodivertViewerIsControl과 PhoenixMoeCodivertViewerIsTreatment다.
    • 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.
  • 같은 2026-09-04 커밋(4세대+16)에서 engagement counts hydration이 cold-start 게이팅과 분리됐다 확인 — home-mixer/candidate_hydrators/engagement_counts_hydrator.rs.
    • enable()은 EnableViewerColdStart 종속을 없애고, EnableEngagementCountsHydration || is_shadow_traffic만으로 켜질지를 정한다.
    • enable()의 !query.has_cached_posts 게이팅도 없앴다. 후보마다 조회 여부를 정하는 쪽의 has_cached_posts 분기는 2026-10-01 커밋(4세대+33)까지 남았고 2026-10-02 커밋(4세대+34)에서 없어졌다 확인 — engagement_counts_hydrator.rs:123, 76843a5의 같은 파일.
    • Cold-start Thompson sampling이 view_count_on_home, fav_count에 의존하던 4세대+1(2026-08-14) 초기 연결의 강결합이 풀린다.

신규 아이템 (포스트 단위)

4세대 공개 기준으로는 작성자와 상관없이 새 포스트 자체를 밀어 주는 별도 장치가 없었다 확인. 2026-10-17 커밋(862a123) 기준으로 포스트 단위 조건으로 작동하는 장치는 두 가지다.

  • 2026-09-24 커밋(4세대+28)에서 Phoenix retrieval 학습 데이터에 HOME_COLD(13)와 HOME_HOT(14) 데이터셋이 생겼다. HOME_COLD는 조회 500 미만, 좋아요 8 미만인 최근 포스트만 남긴다 확인 — retrieval_dataset.py:280-281, cold_pool_filter.py:26-46.
    • 홈믹서는 2026-09-18 커밋(4세대+25)부터 이 태그(13)가 붙은 retrieval 후보를 cold 슬라이스로 따로 구분하고, 2026-09-30 커밋(4세대+32)부터 200개까지 받는다 확인(후보 생성 현행).
  • 콜드스타트 슬롯 선택기는 작성자 팔로워가 50,000 이하이고, 올라온 지 2시간 이내이며, 홈 노출이 200 미만인 원본 포스트 가운데 하나를 골라 순위를 올린다 확인 — author_cold_start.rs:296-309, params/param.rs:507-531.
    • 이 선택기는 4세대 공개부터 후보 포스트의 노출 수를 조건으로 썼고, 그때 임계는 1000이었다(새-작성자 부스트 절).

새 포스트의 해시 임베딩은 지속학습 중인 테이블이 현재 갖고 있는 값이고, 처음 보는 ID면 해시 충돌로 섞인 다른 포스트들의 평균이 된다 추정.

2025 Scala 코드에는 SimclusterColdPostsCandidateSource.scala가 있었다. 4세대에서 SimClusters가 부활했으므로, 이 소스가 4세대 simclusters/에 살아 있는지는 심층 분석이 필요하다(열린 질문).

↑ 목차

안전성 / visibility-filtering / labeling 파이프라인 (4세대에서 통째 공개)

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세대).

4단계 Labeling Path 확인 — README.md #labeling-path

1~3단계가 라벨을 만들어 저장하고, 4단계가 요청 경로에서 그 라벨로 판정한다 확인.

Content Understanding → Labeling Rules → Storage → Visibility Filtering

1단계(Content Understanding)는 요청 경로 밖에서 도는 nearline 단계다. README 그림의 1단계에는 grox, media-model-proxy, clip, agatha, bdsm, user-cred-v2 여섯 개가 나온다 확인. 관련 서브레포는 아래 표와 같다.

서브레포 대상 역할
grox/ 포스트·미디어 Grok 기반 nearline 분류기. 3세대에도 부분 공개
media-model-proxy/ 이미지·비디오 NSFW·폭력/고어·혐오 심볼·주제·알려진 미디어 매칭용 서빙 프록시
clip/ 이미지·텍스트 CLIP 임베딩 모델 학습(미디어 분류기의 입력)
adult-content/ 성인 미디어 분류기 학습·캘리브레이션
pnsfwmedia/ 성인 미디어 CLIP 임베딩과 계정 스코어를 결합한 판별기
agatha/ 계정 오프라인 배치. 다른 계정이 이 계정의 포스트에 어떻게 반응했는지에 기반한 라벨(블록·리포트 대비 favorite 비율)과 spam-suspension·adult 라벨
bdsm/ 계정 시계열 행동 시퀀스에서 inauthentic/abusive 신호 추출(Rust·proto·training·tests 서브디렉토리)
user-cred-v2/ 계정 팔로우·엔게이지먼트 엣지 위의 PageRank로 매긴 계정 신뢰도 스코어

2단계(Labeling Rules)는 이벤트와 스코어를 라벨로 바꾼다 확인.

  • scarecrow/는 이벤트에 반응해 도는 규칙 러너이고, botmaker/를 규칙 엔진으로 임베드한다 확인.
  • botmaker/는 규칙 DSL·컴파일러·런타임이다 확인.
  • botmaker-rules/는 scarecrow가 로드하는 실제 규칙이다 확인. README에 따르면 일부는 게이밍 방지 목적으로 공개하지 않았다 확인.
  • abuse-enforcement-service/는 모델 스코어를 근거로 계정 조치(라벨링·챌린지·서스펜션)를 내린다 확인.
  • safety-label-user-agg/는 계정이 소유한 포스트의 라벨을 계정 레벨로 집계한다 확인.

3단계(Storage)에서는 라벨이 저장소에 쓰이고, 요청 경로에서 읽힌다.

4단계(Visibility Filtering)는 요청 경로에서 돈다 확인 — visibility-filtering/rules/registry.rs. 클라이언트 라이브러리는 visibility-filtering-client/에 있다 확인.

2026-10-15 커밋(92f0918) 기준으로 SafetyLevel은 다섯 개이고, 레벨별 규칙 상수 항목 수는 다음과 같다 확인 — visibility-filtering/rules/levels.rs.

SafetyLevel 규칙 상수 항목 수 (2026-10-15 커밋 기준)
FILTER_ALL 1
TIMELINE_HOME 39
TIMELINE_HOME_RECOMMENDATIONS 65
TIMELINE_HOME_HYDRATION 87
IMMERSIVE_EXPANDED_RECOMMENDATIONS 47

판정은 2026-09-22 커밋(4세대+26)부터 Allow/Interstitial/Drop 3분류가 아니라, 보류와 표시로 나뉘는 트리다 확인 — visibility-filtering/models/verdict.rs(커밋 요약표의 4세대+26 행).

  • 보류 쪽은 Drop·Tombstone이다.
  • 표시 쪽에는 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
    • 2026-08-24 커밋(4세대+7, d011592)에서 BrandSafetyVerdict::HighRisk = 4 tier가 신설됐다 확인 — home-mixer/models/brand_safety.rs
      • 이전에 medium에 있던 5종과 신규 SOFT_NSFW가 HIGH_RISK_LABELS로 승격됐다. v1과 v2가 대칭이다.
        • 5종은 NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW다.
      • ads_brand_safety_vf_hydrator.rs의 NSFW ancestor 상속 escalation이 MediumRisk에서 HighRisk로 바뀌었다.
      • scored_posts_server.rs가 V2 라벨 5종의 proto HM enum 매핑을 새로 만들었다(다운스트림 노출 시작).
        • 5종은 GROK_SFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_V2·GROK_NSFA_EXPANDED_V2·PTOS_REVIEWED다.
  • 인용 포스트 조회가 실패하면 비관적으로 MediumRisk로 강등한다 확인 — ads/../ads_brand_safety_hydrator.rs:144-145
  • 4세대+2 (2026-08-17, b089ce6)에서 V2 판정 함수가 나란히 연결됐고, 4세대+3 (2026-08-18, 11a71f8)에서 그 시맨틱이 하루 만에 재설계됐다.
  • 4세대+2 원안(b089ce6, 2026-08-17)은 V2용 라벨 목록 두 개와 마이그레이션 헬퍼, 검증 테스트를 넣었다 확인 — home-mixer/models/brand_safety.rs
    • MEDIUM_RISK_LABELS_V2는 16개다. V1과 비교해 GROK_NSFA가 GROK_NSFA_V2로 이름이 바뀌었고 GROK_NSFA_EXPANDED_V2가 신설됐으며, NSFA_HIGH_PRECISION을 포함한다.
    • LOW_RISK_LABELS_V2는 3개다. V1과 비교해 GROK_NSFA_LIMITED가 GROK_NSFA_LIMITED_V2로 이름이 바뀌었고, NSFA_LIMITED_INVENTORY를 포함한다.
    • 마이그레이션 헬퍼 strip_v1_grok_written은 V2 판정 전에 두 조건을 모두 만족하는 라벨을 입력 라벨 집합에서 제거한다.
      • 첫째 조건은 라벨이 NSFA_HIGH_PRECISION 또는 NSFA_LIMITED_INVENTORY인 것이다.
      • 둘째 조건은 그 라벨의 botmaker rule id가 PROMPT_OWNED_RULES = [1400, 1410, 1420, 1500, 1510, 1610, 1700] 7개 중 하나인 것이다.
      • 즉 V1 프롬프트가 dual-write한 라벨을 V2 로직이 무시하고, 순수 V2 라벨만으로 판단한다.
    • 검증 테스트 v2_mirrors_v1_across_tier_matrix는 6가지 라벨 조합 × 두 tweet_id 시점(PTOS_CUTOFF 전/후)에서 V1과 V2 판정이 정확히 일치해야 한다고 assert한다.
  • 4세대+3 재설계(11a71f8, 2026-08-18)는 이 헬퍼를 지우고, V2가 쓴 라벨이 하나라도 있을 때만 V2로 판정하고 없으면 V1으로 폴백하는 게이트를 넣었다 확인 — home-mixer/models/brand_safety.rs
    • strip_v1_grok_written이 완전히 삭제됐다(Cow import까지 제거). 즉 어제 canon이 반영한 PROMPT_OWNED_RULES 7개 rule id 로직은 하루 만에 폐기됐다.
    • 대신 새 상수 V2_WRITTEN_LABELS가 생겼다. GROK_SFA_V2·GROK_NSFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_EXPANDED_V2의 4개다.
    • 새 게이트는 compute_verdict_v2 초입에서 V2_WRITTEN_LABELS 중 하나라도 라벨 집합에 있는지 검사한다. 없으면 compute_verdict(v1)으로 폴백하고, 있으면 순수 V2 라벨 세트로 판정한다.
    • MEDIUM_RISK_LABELS_V2에서 NSFA_HIGH_PRECISION이, LOW_RISK_LABELS_V2에서 NSFA_LIMITED_INVENTORY가 제거됐다. 즉 V2가 판정을 내리면 V1 heuristic 라벨은 무시된다.
    • 새 테스트 v2_defers_to_v1_when_v2_has_not_ruled는 결정적 케이스를 명시적으로 assert한다.
      • 특히 NSFA_HIGH_PRECISION + GROK_NSFA + GROK_SFA_V2 라벨 세트에서 V1 판정은 MediumRisk이고 V2 판정은 Safe다.
      • 즉 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 프롬프트 변경? 파이프라인 재훈련?)
  • 4세대+7 커밋(d011592, 2026-08-24)이 브랜드 안전 판정에 HighRisk tier를 신설했다 확인 — home-mixer/models/brand_safety.rs
    • 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와 똑같이 광고를 붙이지 않는다.
      • 추가 조치는 아직 없고, 강도는 flag나 기록 목적일 가능성이 있다.
    • 인용 인접성도 승격됐다. ads_brand_safety_vf_hydrator.rs는 인용된 조상 포스트가 NSFW author면 verdict를 MediumRisk가 아니라 HighRisk로 escalate한다.
    • 다운스트림 노출이 시작됐다. scored_posts_server.rs의 safety_label_to_proto mapping에 V2 라벨 5종이 추가됐다.
      • 5종은 GROK_SFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_V2·GROK_NSFA_EXPANDED_V2·PTOS_REVIEWED다.
      • 이제 이 라벨들이 feed 응답 proto에 실제로 실려 나간다. 이전에는 V2 라벨이 내부 판정에만 쓰이고 다운스트림으로는 나가지 않았다.
    • DDG 실험 트래킹이 생겼다. ads_injection_logging_side_effect.rs가 EnableAdsBrandSafetyVerdictV2 experiment 버킷을 ddg_experiment_bucket = "experiment_id:bucket" 형식으로 로깅에 노출한다.
      • DDG는 광고 리포팅 파이프라인(Data Dashboard 또는 유사 시스템으로 추정 추정)으로 넘어가서 V2 실험군의 매출/안전 metric을 관측할 수 있게 한다.
      • 의미: V2가 아직 실험 단계(experiment 버킷팅)라는 방증이다. 4세대+2에서 V2 판정 함수만 도입됐고, 4세대+3에서 시맨틱이 재설계됐으며, 4세대+7에서 experiment 실측 인프라가 갖춰졌다.
    • 미공개: HighRisk tier가 광고 avoid 외에 어떤 후속 조치(랭커 강한 감쇠, 유기 노출 완전 차단 등)를 트리거하는지, V2 experiment의 홀드아웃/처리군 비율

광고 conversion 액션 대폭 확장 — 4세대+6 (2026-08-21, 28e414f)

Phoenix 랭커의 광고 매출 예측 헤드가 2026-08-21 커밋(4세대+6)에서 20개 늘고 학습 손실이 재편됐다 확인. 그 뒤 4세대+9 커밋(2026-08-26, 45b48ba)이 픽셀 발화 관련 헤드 3종을 더했고, 2026-09-17 커밋(4세대+23)이 delayed conversion 액션에 tier 2와 tier 3를 10종씩 더했다 확인.

4세대+6 이전에는 광고 conversion 계열이 사실상 몇 개(설치·click-attributed 컨버전)뿐이었다. 4세대+6 커밋으로 다음처럼 늘었다.

Proto에 ActionName 20개가 신설됐다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto

  • MACT 6종 × click/view = 12개(171–182)가 생겼다.
    • MACT는 Mobile App Conversion Tracking이다. 광고 클릭·노출 이후의 앱 내 이벤트(구매·장바구니·레벨 달성·튜토리얼 완료·회원 가입·커스텀)를 광고에 귀속한다.
    • 이벤트마다 click-attributed(MACT_<이벤트>)와 view-attributed(MACT_VIEW_<이벤트>) 두 버전이 있다.
  • View-through conversion 8개(183–190)가 생겼다.
    • 대상은 purchase·add_to_cart·checkout_initiated·sign_up·site_visit·session·landing_page_view·upper_funnel이다.
    • 각각 기존 click-attributed(ADS_PURCHASE_CONVERSION = 200 등)에 대응하는 view-through 헤드다.

engagement metric group이 바뀌었다 확인 — phoenix/xrex/data/recsys/constants.py

  • 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와 방향이 같다.

Delayed feedback 학습 분기가 생겼다 확인 — phoenix/xrex/models/recsys_model.py

새 config 옵션은 4개다. 표의 기본값은 4세대+6 커밋 기준이다.

옵션 기본값 의미
split_head_training_by_source False delayed feedback 샘플과 fresh 샘플을 loss mask에서 나눈다(engagement head는 fresh만, conversion head는 delayed만 학습). condition_conversion_on_click=True가 assert 전제조건이다. 4세대+24(2026-09-18)에서 이 옵션은 ads_head_masking으로 통합돼 삭제됐다.
train_view_through_heads False view-through 헤드 8개 학습을 켠다. False면 view-through loss mask=0이다. 4세대+24에서 ads_head_masking으로 통합돼 삭제됐다.
mact_in_app_loss_weight 1.0 MACT 12 액션에만 곱하는 loss weight. 광고 conversion 헤드의 학습 강도를 튜닝한다
metric_mask_keys None 학습 관측 메트릭 슬라이스를 필터링한다

새 batch 필드는 두 개다.

  • sample_source (bool)는 kafka record의 is_delayed_feedback 컬럼에서 들어온다. True면 이 샘플은 conversion delayed 재관측분이다.
  • 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 확장 축과 별개로, 광고 매출 예측 스택이 새 축으로 붙었다.

4세대+9 (2026-08-26, 45b48ba)에서 픽셀 발화·long-dwell 결합 헤드 3종이 생기고 conv_asset_ids가 연결됐다 확인 — phoenix/python/common/xai-proto/proto/recsys.proto (+12)

  • 다른 근거 파일은 phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto와 home-mixer/models/candidate.rs (+8)다.
  • 새 ActionName 3종(191–193)이 생겼다.
    • 191 ADS_PIXEL_FIRE는 광고주가 자기 사이트/앱에 심어 둔 전환 픽셀(웹 트래킹 픽셀)이 발화하는 것이다. 웹 컨버전 이벤트를 직접 예측한다.
    • 192 ADS_LONG_DWELL_AND_PIXEL_FIRE는 픽셀 발화와 long dwell(랜딩 페이지에 오래 머무름)을 AND로 결합한다. 즉 픽셀만 터진 것이 아니라 유저가 실제로 오래 머무른 케이스다.
    • 193 P_LONG_DWELL_AND_PIXEL_FIRE의 접두 P_는 4세대+6 관행상 primary 또는 predicted target 계열을 시사한다 추정.
  • ConvAssetIds message가 신설돼 PredictNextActionsRequest.conv_asset_ids = 18 필드로 연결됐다.
    • 구성은 impressed_time_ms(int64), author_id(int64), asset_id(int64)의 3배열이다.
    • Phoenix 서빙에 유저가 최근 노출된 광고 크리에이티브(asset) 이력을 요청 시에 함께 넘긴다.
    • 광고 asset이 여러 광고주에 재사용되거나 유저가 같은 asset을 여러 번 봤을 때 컨버전 예측에 이 이력을 참조한다 추정.
  • home-mixer의 SlateContext struct에 Option<u32> 필드 두 개(SlateContext.recon_count_above, recon_gap_above)가 추가됐다.
    • served_slate_context의 recon 재구성 metrics를 세분화하는 필드다.
    • proto 쪽에는 아직 반영되지 않았다. proto에는 13번 reconCosMilli 필드만 있다.
    • home-mixer 로컬 연결이 proto 이관을 기다리는 상태다.
  • 의미: 4세대+6이 광고 conversion 예측 헤드를 20개 대폭 확장한 것의 후속이다. 4세대+9는 픽셀 발화와 long dwell을 합성한 3종을 추가하고, asset 이력을 서빙 요청에 실을 채널을 만든다. constants.py나 models/recsys_model.py에는 아직 반영이 없다. proto 신설만 있으므로 학습·서빙 연결은 다음 커밋에서 볼 관측 포인트다.

4세대+13 (2026-09-01, 7ba7768)에서 DPA 제품 SID가 서빙 wire format에 실렸다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.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 어휘 밑에서 표현될 가능성이 열린다. 다만 이 커밋만으로는 다음 세 가지를 알 수 없다.

  • DPA SID의 codebook이 유기 post SID와 같은지 다른지
  • sid_codes를 소비하는 서버 측 연결이 이미 있는지
  • A/B 결과가 있는지

4세대+23 (2026-09-17, 42266f3)에서 delayed conversion attribution이 3-tier로 확장됐다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto

기존 reserved 214 to 252가 reserved 234 to 252로 좁혀지고, 214-233에 tier 2와 tier 3가 붙었다.

  • Tier 2 10종(214-223)이 생겼다.
    • ADS_ATTRIBUTED_KEY_CLICK_CONVERSION_DELAYED_TIER_2·ADS_ATTRIBUTED_CLICK_CONVERSION_DELAYED_TIER_2·ADS_PURCHASE_CONVERSION_DELAYED_TIER_2·ADS_MID_FUNNEL_CONVERSION_DELAYED_TIER_2·ADS_ADD_TO_CART_CONVERSION_DELAYED_TIER_2·ADS_UPPER_FUNNEL_CONVERSION_DELAYED_TIER_2·ADS_WEB_CONVERSION_DELAYED_TIER_2·ADS_SEARCH_CONVERSION_DELAYED_TIER_2·ADS_SIGN_UP_CONVERSION_DELAYED_TIER_2·ADS_CHECKOUT_INITIATED_CONVERSION_DELAYED_TIER_2
  • Tier 3 10종(224-233)은 동일 10종의 _TIER_3 대칭이다.

의미: 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세대에서 신설)

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 옆에 나란히 추가됐다 확인.

4세대 공개부터 Phoenix를 end-to-end로 실행할 수 있다 확인 — README.md #deployment-related-code.

  • Cargo workspace, pyproject.toml, QUICKSTART, 합성 데이터 생성기가 함께 들어 있다 확인 — README.
  • README가 밝힌 범위는 작은 모델이다: "a small model can be trained and served end-to-end". 이 README 기준으로 프로덕션 checkpoint·데이터·infra는 여전히 공개되지 않았다 확인 — README.
  • loss와 negative sampling 세부는 심층 분석이 필요하다. optimizer는 2026-08-19 커밋(4세대+4)에서 공개됐다.

2026-08-19 커밋(4세대+4)에서 Phoenix의 dense-parameter optimizer가 Adam에서 Muon으로 바뀌었다 확인 — phoenix/xrex/optimizers/recsys/muon.py(240줄 신규).

Muon은 Newton-Schulz orthogonalization으로 gradient momentum을 근사 직교화하는 optimizer다. 이 코드의 Muon은 optax.contrib._muon을 감싸 쓰고, Phoenix ranker의 dense-parameter 기본 optimizer가 됐다. 적용 방식은 하이브리드다. matrix-shaped 파라미터만 Muon을 쓰고, 이름이 아래 패턴에 걸리는 파라미터는 여전히 Adam을 쓴다.

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

다시 말해 임베딩 테이블, 출력 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 단위로 분할해 각각 직교화) —
adam_embedding_weight_decay 0.014 —
b1, b2 0.95, 0.98 —
embedding optim RecsysRowwiseAdagradConfig(learning_rate=0.28, half_life_steps=2500, lazy_decay=True, weight_decay=2.8e-4) rowwise_adagrad (LR 0.2)

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). ...

— phoenix/TRAINING.md

  • flagship ranking config와 home_direct_packed_nano는 프로덕션 Muon 레시피로 end-to-end 학습한다 확인 — TRAINING.md. export의 nano 모델을 그대로 돌리면 프로덕션과 같은 Muon 학습이 된다.
  • AdamW arm은 two-tower retrieval, gen-recs, legacy ranking preset에만 남는다 확인 — TRAINING.md. 이쪽은 여전히 프로덕션 tuned RMS-normalized-Adam derivative의 export-safe 등가물이다.
  • flagship과 nano의 임베딩 rowwise AdaGrad에도 옵션 세 개가 함께 붙는다고 명시했다 확인 — TRAINING.md.
    • 세 옵션은 accumulator half-life decay, lazy per-row decay, decoupled weight decay다.
    • 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 유지).

같은 커밋(4세대+6)에서 nano config가 GB300 하드웨어 전용 오버라이드의 LR과 optimizer 설정을 물려받게 됐다 확인 — phoenix/xrex/configs/xrecsys.py:330-345.

  • _GB300_OVERRIDES 자체는 4세대 공개부터 있었다(배치·EP·attention·remat·LR 항목) 확인 — xrecsys.py:305-313, 368. optimizer 항목은 2026-08-19 커밋(4세대+4)에서 더해졌다.
  • 아래 블록은 4세대+6 시점의 값이다.
_GB300_OVERRIDES = {
    "bs_per_device": 512,
    "ep": 64,
    "attn_impl": "cutedsl_ranker_varlen_attn",
    "remat_policy": RematType.SAVE_GB300_RECSYS,
    "unroll_layer_stack": True,
    "learning_rate": 5e-4,
    "checkpoint_every_n": 300,
    "optim_config": RecsysDenseOptimConfig(
        optim="muon",
        muon_consistent_rms=0.2,
        muon_matrix_weight_decay=0.01,
        muon_split_fused="qkv:128",
        adam_embedding_weight_decay=0.01,
        b1=0.95,
        b2=0.98,
        ...
    ),
    ...
}
  • bs_per_device=512는 이전 _home_direct_packed_nano의 bs_per_device=64의 8배다. GB300의 HBM 여유를 반영했다.
  • ep=64는 expert parallelism을 64-way로 둔다.
  • attn_impl="cutedsl_ranker_varlen_attn"은 NVIDIA CUTLASS/CUTe DSL 기반의 variable-length attention 커널이다. GB300에 최적화된 신규 impl이다.
    • 오픈소스 카탈로그에 이 이름이 없으므로, xAI 자체 커널일 가능성이 있다 추정.
  • remat_policy=RematType.SAVE_GB300_RECSYS는 GB300 메모리 계층에 맞춘 rematerialization 정책이다.
  • unroll_layer_stack=True는 트랜스포머 layer stack을 unroll해 컴파일한다. XLA 최적화 여지가 있다.
  • checkpoint_every_n=300은 300 step마다 checkpoint를 저장한다.

_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을 상정한 재현 시나리오라는 뜻이다.

H100용 remat 정책은 그 뒤 커밋에서 추가됐다 확인(변경 이력의 4세대+15 D).

2026-08-19 커밋(4세대+4)에서 Phoenix가 xrecsys_search config를 신설하며 검색 랭킹으로 확장됐다 확인 — phoenix/xrex/configs/xrecsys.py:435-473.

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).

2026-08-21 커밋(4세대+6)에서 two-tower retrieval의 nano config에 옵션 3개가 추가됐다 확인 — phoenix/xrex/configs/xrecsys_two_tower.py:429-431.

옵션이 들어간 곳은 MODEL_CFGS의 xrecsys_two_tower_nano 항목이다 확인 — xrecsys_two_tower.py:411-431. 이 항목은 combined base를 물려받지 않아서, 그전까지는 세 키 모두 모델 생성 코드의 기본값을 썼다 확인 — 같은 파일 530·627·675·681행. combined base에는 4세대 공개부터 세 키가 False·80.0·True로 있었고, 이 커밋은 base를 바꾸지 않았다 확인 — xrecsys_two_tower.py:274-275, 318.

  • qk_norm=True
  • attn_logit_cap=-1: logit capping을 끈다.
  • right_anchored_rope=True: RoPE positional encoding을 right-anchored로 둔다.

LLM 학습의 안정성 트릭이 retrieval two-tower로 넘어왔다. 이와 별개로 EVERGREEN retrieval dataset 파일이 evergreen_video_1825day.parquet에서 video_4to14day.parquet로 극단적으로 축소됐다. 이름과 실체가 어긋난 상태이고, 임시적일 가능성이 있다 추정.

같은 커밋(4세대+6)에서 retrieval post-embedding 계산이 65k row 단위로 나뉘었다 확인 — phoenix/xrex/train/trainer_recsys.py _forward_chunked.

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였다 확인.

2026-08-19 커밋(4세대+4)에서 Phoenix proto에 reward 헤드와 link-open 5분위 헤드가 생겼다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto.

Phoenix 예측 출력 스키마는 두 방향으로 확장됐다.

  1. RewardOutputs 메시지가 생겨, Phoenix가 확률 예측 헤드 24개 옆에 reward value 헤드와 policy logits 헤드를 나란히 낼 수 있는 스캐폴딩이 갖춰졌다 확인 — recsys.proto.
    • NextActionDistribution에 RewardOutputs rewardOutputs = 8 필드가 추가됐다. RewardOutputs의 두 필드는 스칼라 array인 values와 정책 로짓인 policyLogits다.
    • ScoreInfo에도 optional double rewardRerankSlotProb = 5가 추가됐다.
    • RL 스타일(actor-critic value+policy) 학습이나 reward-model-guided 재순위 슬레이트 결정에 쓰는 것으로 추정되며, 이 필드를 소비하는 스코어러는 아직 연결되지 않았다 추정. 아직 hidden state 스캐폴딩 단계다 추정.
    • 그 뒤 공개된 immersive 지면용 페이지 단위 RL 재순위기의 학습 코드는 rewardRerankSlotProbSeq 컬럼을 rl_rerank_prob로 읽어 페이지 RL 손실의 로깅 확률로 쓴다 확인 — recsys_batch.py:284, page_rl.py:206, 216, 233(변경 이력의 페이지 단위 RL 재순위기 커밋).
      • 이 컬럼이 서빙 응답의 rewardRerankSlotProb 필드를 모은 것이라는 연결은 이름이 같다는 점에 기댄 것이다 추정.
  2. 링크 열림 확률을 단일 스칼라가 아니라 분위 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 변경에는 이런 것도 있다.

  1. 검색 관련성 action이 CLIENT_TWEET_RELEVANT_TO_SEARCH = 105와 CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106으로 다시 번호를 받았다 확인 — recsys.proto. 기존 161/162는 *_DEPRECATED로 남았다. 이 action은 xrecsys_search config와 짝을 이룬다.
  2. 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을 따로 학습하는지가 관건이다.
  • link-open 5분위는 quantile regression인가, categorical mixture인가.
  • xrecsys_search config는 프로덕션에 배포됐는가, 아직 오프라인 학습에만 쓰이는가.

2026-08-20 커밋(4세대+5)에서 home-mixer가 Phoenix 서빙이 계산한 slate context를 받아 쓰기 시작했다 확인 — home-mixer/models/candidate.rs:26-27, 76-77, 99-118.

4세대+4에서 Phoenix proto에 생긴 SlateContext.sid* 필드가 이 커밋에서 실제 파이프라인의 소비 경로에 연결됐다. 세 파일(candidate.rs, phoenix_scorer.rs, ranking_scorer.rs)에서 네 군데가 동시에 바뀌었다.

  1. PostCandidate에 served_slate_context: Option<SlateContext> 필드가 새로 생겼다. 기존 slate_context(home-mixer가 로컬에서 계산한 뒤 캐시하는 값)와 나란한 두 번째 저장 슬롯이고, Phoenix 응답에서 온 slate context만 담는다.
  2. 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을 뜻한다.
  3. 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)다.
  4. 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)가 모두 같은 폴백을 쓴다.
    1. 게이트가 켜져 있고 Phoenix가 모든 후보의 slate context를 보내 왔으면 그대로 쓴다.
    2. 게이트가 꺼져 있고 Redis 캐시가 히트하면 저장된 컨텍스트를 쓴다.
    3. 그 밖의 경우에는 로컬에서 계산한다.

의미: 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가 프로덕션 트래픽에 반영되는 시점이다.

2026-08-21 커밋(4세대+6)에서 NextActionDistribution에도 SlateContext가 필드 #9로 추가됐다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto.

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 모드였다 확인.

2026-08-14 커밋(4세대+1)에서 Phoenix 입력 피처 3개가 새로 생겼다 확인 — phoenix/crates/common/xai-recsys/src/util.rs:456-540.

bool feature 설명
IS_STALE_POST14D 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에서 세 개가 처음 채워졌다 확인.
    • 근거는 patch의 bool_features: vec![false; ...] → bool_features: candidate_bool_features 교체다.
  • stale 신호는 count zeroing과 bool set이 짝을 이뤄 들어간다. 랭커에게 "이 카운트가 낮은 이유는 인기 없어서가 아니라 오래돼서 노출이 끊긴 것"이라고 알려 주는 명시 라벨이다. 목적은 3세대의 snowball 문제를 완화하는 것이다 추정.
  • is_mutual_follow 부스트(reply +15)는 스코어러에 하드코딩된 조건이었다. 이 커밋부터는 모델이 팔로우 방향을 개별 입력 bool로 받아, 학습으로 비슷한 패턴을 잡을 수 있다.
  • 2026-08-19 커밋(4세대+4)에서 네 피처가 모두 train-default ON으로 승격됐다 확인 — phoenix/xrex/configs/xrecsys.py:270-290.
    • _home_direct_packed_base() 기본 config에서 enable_stale_post=True, enable_is_author_followed_by_viewer=True, enable_is_author_following_viewer=True, enable_engagement_counts=True 4개가 FeaturePrepConfig의 기본값으로 올라갔다.
    • 4세대+1에서 채워진 bool 3종이 이 커밋부터 학습 파이프라인의 기본 입력이다.
    • 별도로 grpc_recsys dataset type만 enable_stale_post=False로 override해, 실시간 서빙 경로에서는 stale 판정 stamp를 끈다. 끈 이유는 실시간에는 count zeroing이 위험할 수 있어서다 추정.

4세대+13 커밋(2026-09-01, 7ba7768)에서 Phoenix 랭킹 모델에 SID recon 모드가 들어왔다. 기본은 off다. 확인 — phoenix/xrex/models/recsys_sid.py(72줄 신규).

이 커밋은 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 크기와 레벨 수는 아티팩트가 정하며, 코드에는 하드코딩되지 않았다.
  • dec_w0·dec_b0·dec_w1·dec_b1은 2-layer MLP decoder(relu → linear)다. SID 시퀀스를 dense vector로 복원한다.

recon 함수 reconstruct_entity_sid는 SID 코드를 아래 순서로 랭커 입력 벡터로 바꾼다 확인 — phoenix/xrex/models/recsys_sid.py.

  • (a) codes = sids - 1로 1-based를 0-based로 바꾸고, sids[..., 0] == 0인 행을 "missing"으로 표시한다.
  • (b) 레벨마다 stages[l, code_l, :]를 뽑아 residual sum을 구한다.
  • (c) 2-layer MLP와 relu로 복원한다.
  • (d) L2 정규화한다(eps=1e-12).
  • (e) missing 행을 0으로 마스킹한다.
  • (f) stop_gradient로 decoder를 완전히 frozen 상태로 둔다.
  • (g) recon_dim → target_dim 선형 project 하나({prefix}_sid_recon_proj, 학습 가능)만 매개변수로 둔다. 결과는 랭커 post tower에 맞게 dtype을 캐스팅해 반환한다.

모드는 config 게이트로 고른다 확인. RecsysAggregatedModelConfig에 두 필드가 추가됐다.

sid_embedding_mode: Literal["learned", "recon"] = "learned"
sid_decoder_path: str = ""
  • 기본값 "learned"는 기존 SID 임베딩 테이블(post tower 안에서 학습되는 lookup)을 그대로 쓴다.
  • "recon"으로 켜면 학습 SID 테이블을 우회한다. 대신 history_seq와 candidate_seq의 SID 코드에서 recon 벡터를 만들어 post tower 임베딩에 더한다(add).
    • 요구 조건은 legacy build_inputs 경로(feature_prep_enabled=False), sid_hash_level 미사용, sid_cross_attn 미사용, sid_decoder_path 지정이다.

의미

  • X SID 라인의 첫 코드 공개다. 이번 커밋은 SID 코드 자체를 어떻게 dense feature로 되돌리는지를 처음 코드로 보여 준다. 이전까지 X의 SID 관련 공개 자료는 셋이었다.
    • (a) 4세대+3(11a71f8, 2026-08-18)에서 VMRanker에 sid_known·sid_k_l1/2/3·sid_gap_l1/2/3 필드가 연결됐다.
    • (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)부터는 좋아요가 없는 후보도 인덱싱 이벤트로 발행된다 확인.

4세대 공개에서 Phoenix retrieval 인덱스 서비스도 공개됐다 확인 — README.md #retrieval-index.

  • phoenix-rankall/은 Phoenix retrieval이 조회하는 포스트 인덱스다. 이벤트가 도착하면 업데이트된다 확인 — README.
  • phoenix-rankall-strato/는 포스트가 어느 인덱스에 속할지 정하는 이벤트 층이다. 이 층은 visibility-filtering을 먼저 참조한다 확인 — README.
  • 2026-08-14 커밋(4세대+1)에서 SID window video_14day가 하나 추가됐다 확인 — phoenix-rankall/src/config/mod.rs:177-180.
    • 기존 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이 완전히 몰랐던 아키텍처 사실이다.

2026-08-17 커밋(4세대+2)에서 인덱싱 게이트 두 개가 제거됐다 확인 — phoenix-rankall-strato/columns/phoenix_rank_all/phoenixRankAllCandidateProcessor.strato.

  • 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)와 짝지어 켜지는지다.

2026-08-18 커밋(4세대+3)에서 Phoenix postCreationEventForwarder가 unified posts topic V3로 마이그레이션됐다 확인 — phoenix-rankall-strato/stream_forwarders/postCreationEventForwarder.strato.

필드 값 (4세대+3부터) 이전 값
Kafka topic content_understanding_realtime_unified_posts_v3 content_understanding_realtime_unified_posts
Kafka dest /s/kafka/phoenix-kafka-external-bootstrap (Phoenix 전용 별도 클러스터) /s/kafka/main-2:kafka-tls (X 메인 Kafka 클러스터)
Consumer group post_creation_event_v3 (별도로 develConsumerGroupId = Some("post_creation_event_v3_devel")) post_creation_event
Kafka mapping KeyValue({key = Long, keyEncoding = NativeEncoding, ...}) (postId를 명시 key로) ValueOnly (Thrift value만)
Import hydra/sharedConfig content_understanding/sharedConfig
Processor 시그니처 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로 들어왔다 확인.

2026-08-20 커밋(4세대+5)에서 emb_table.rs가 Python wrapper와 pyo3-free core로 나뉘었다 확인 — phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs(+189/−169, 근본은 동치 리팩터).

기존 load_tensor는 pyo3 타입과 에러에 얽매여 non-Python 컨텍스트에서 재사용할 수 없었다. 이 커밋은 그 core를 아래 함수로 뽑았고, 기존 load_tensor는 pyo3 wrapper로 다시 정의됐다.

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

안전성도 보강됐다.

  • tokio runtime의 unwrap()이 map_err로 바뀌었다.
  • prefix.len() < 3 방어(checkpoint path too short: {path})가 추가됐다.
  • modulo 체크가 is_multiple_of API로 통일됐다.

큰 임베딩 테이블(4세대+4 xrecsys_search의 vocab 100M/100M/30M)을 여러 서빙·학습 컨텍스트에서 로드해야 할 필요가 커지는 흐름에 대비한 리팩터 준비다 추정.

2026-08-21 커밋(4세대+6)에서 inference hotswap에 malloc_trim(0) 옵션이 생겼다 확인 — phoenix/xrex/inference/model_runner.py.

새 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은 프로덕션에서 오래 도는 인프라라, 이 패치가 필요할 만큼 누적 부담이 있었다는 관찰이다.

같은 커밋(4세대+6)에서 xai-o2 스토리지 crate의 인터페이스가 공개됐다 확인 — phoenix/crates/storage/xai-o2/.

Cargo.toml의 description은 이렇다.

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

crate는 파일 3개로 이뤄진다.

  • base_client.rs(12줄)는 BaseO2Client async trait을 정의하며, 메서드는 put(key, data) -> Result<()> 하나뿐이다.
  • o2_client_builder.rs(115줄)에서는 O2ClientBuilder가 builder method 15개를 제공한다.
    • endpoint·bucket·prefix·access_key_id·secret_access_key·allow_anonymous·timeout·io_timeout·retry_max_times(6)·retry_min_delay(1s)·retry_max_delay(32s)·write_chunk_size_mib(8)·write_chunk_concurrency(16)·max_concurrent_write_requests(64)·trace_always
  • lib.rs는 7줄이다.

다만 오픈소스 빌드에서는 build()가 항상 아래 에러를 반환한다.

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

xai-recsys-engine crate는 xai-o2 = { workspace = true }로 의존성을 선언했지만, 실제로 쓸 때는 표준 object_store crate로 폴백해야 한다.

의미: xAI 내부에 xai-o2라는 이름의 S3 호환 오브젝트 스토리지 시스템이 있다는 것이 처음 확정됐다. builder param 15개는 프로덕션에서 실제로 튜닝되는 항목이라 유의미하다. 프로덕션 checkpoint와 데이터 저장이 여기에 붙는다는 신호다 추정.

SID recon 모드와 같은 커밋(4세대+13)에서 StableHLO 번들 export 경로가 새로 생겼다. 기본은 off다. 확인 — phoenix/xrex/train/recsys_bundle_export.py(634줄 신규).

진입점 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하고, 서빙할 때 요청 배치 크기에 맞는 버킷을 고른다.
  • target은 CUDA뿐이다. MANIFEST에 device_kind와 compute_capability가 실린다.
  • 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 스키마는 코드에 있지만, 소비자 측 코드는 공개되지 않았다.
  • 프로덕션에서 이 번들이 켜져 있는지 여부

4세대+13 커밋은 학습 체크포인트를 envelope encryption으로 암호화해 저장하는 Orbax writer를 추가했다. 기본값은 off다. 확인 — phoenix/python/training/xai-checkpointing/xai_checkpointing/orbax_encrypted.py

링크한 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")를 낸다.
  • CheckpointConfig에 암호화 설정 필드가 새로 생겼다 확인 — 같은 커밋.

    encrypt: bool = False
    encryption_key_id: str | None = None
    encryption_context: dict[str, str] = field(default_factory=dict)
    encryption_chunk_size_bytes: int = 8 * 1024 * 1024
    
    • 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 어설션으로 코드에 굳힌다.

다음은 공개되지 않았다.

  • kms_client의 실제 구현(파일 안에서는 밖에서 넘겨받는다)
  • DEK 회전 주기
  • encryption context의 실제 사용 값
  • 어느 KMS(AWS·Azure·자체)를 쓰는지

↑ 목차

Grox (LLM 주석) — 3세대 그대로 (+ 2026-08-14 임계 상향)

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에 있다.

2026-08-14 커밋은 답글 스팸 판정과 답글 랭킹 채점의 팔로워 임계를 두 배로 올렸다 확인 — grox/flows/reply_spam/task_filter.py:17,185.

  • grox/flows/reply_spam/task_filter.py의 두 임계 상수가 15000 → 30000으로 바뀌었다.
    • TaskSpamFilter.FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION: 15,000 → 30,000
    • TaskReplyRankingFilter.FOLLOWER_COUNT_THRESHOLD_FOR_REPLY_RANKING: 15,000 → 30,000

의미: 스레드 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 콘텐츠 이해 스트림으로 동시에 옮겨 갔다는 관찰이 붙는다.

4세대+6 커밋(2026-08-21, 28e414f)은 두 상수를 모두 60,000 → 80,000으로 올렸다 확인 — grox/flows/reply_spam/task_filter.py.

  • 3일 만의 네 번째 상향이고, 폭은 1.33배다.
  • 임계는 4세대 공개 뒤 8일 사이 네 번 올랐다(15k → 30k → 40k → 60k → 80k).
  • 첫 상향(2배) 뒤로 폭이 1.33~1.5배로 줄어든 것은 grok 예산 병목 근접, 그 이상 계정에서 스팸 답글 유입이 적어 생긴 marginal utility 감소, 다음 단계에서 임계 없이 전면 온으로 넘어갈 준비 가운데 하나다 추정.

이 절의 변경 이력은 다음과 같다. 값은 TaskSpamFilter와 TaskReplyRankingFilter가 함께 쓰는 임계이고, 각 커밋의 task_filter.py와 constants.py에서 확인했다 확인.

  • 2026-08-13(4세대 공개): 15k
  • 2026-08-14(4세대+1): 30k
  • 2026-08-17(4세대+2): 40k
  • 2026-08-18(4세대+3): 60k
  • 2026-08-21(4세대+6): 80k
  • 2026-08-24(4세대+7): 100k
  • 2026-08-25(4세대+8): 120k
  • 2026-09-08(4세대+17): 150k
  • 2026-09-16(4세대+22): 180k. 상수가 GROK_GEMMA_FOLLOWER_SPLIT으로 바뀌었다.
  • 2026-09-17(4세대+23): 200k
  • 2026-09-23(4세대+27): 225k
  • 2026-09-24(4세대+28): 250k. 2026-10-17 커밋(862a123)에서도 250k다.

↑ 목차

Grox → PtOS Adult Content Cross-Validation (4세대+2 신규, 2026-08-17)

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 안전 파이프라인은 두 판정을 병렬로 돌리고, 결과가 다를 때는 로그만 남겼다.

심판 태스크는 네 단계로 동작한다.

  1. 두 판정을 비교한다. enum CompareOutcome이 BOTH_POSITIVE·BOTH_NEGATIVE·SAFEMODEL_ONLY_POSITIVE·PTOS_ONLY_POSITIVE를 구분한다. is_disagreement는 SAFEMODEL_ONLY 또는 PTOS_ONLY일 때 true다.
  2. 게이트 두 개를 확인한다.
    • deluxe 플로우에서만 동작하고, 일반 플로우는 로그만 남긴다. 조건은 is_deluxe = ctx.payload.task_type == SAFETY_PTOS_DELUXE다.
    • 이미 NSFW 플래그된 포스트면 건너뛴다. 플래그는 StratoSafetyPostAnnotationsResultDirectMh로 조회한다.
  3. 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 판정을 나눈다.
  4. 심판 결과를 적용한다. 심판이 AdultContentSexualHard이면 원래 판정을 유지한다. 그 밖의 결과이면 세 가지를 한다.
    • 모든 AdultContent 위반의 safetyPolicy.policyType을 AdultContentSexualSoft로 강등한다.
    • 강등된 위반에 reason = "Grok 4.5 Cross Validation disagreed"를 적어 감사 로그에 남긴다.
    • safemodel_sex_nudity.positive를 false로 바꾼다.

이 설계에서 눈여겨볼 점은 네 가지다.

  • 강등 방향(hard → soft)만 있고 격상 방향은 없다.
    • 이 심판 계층은 과다 게이팅을 완화하는 방향으로만 작동한다.
    • false negative가 아니라 false positive를 줄이려는 정책 방향을 코드에서 관찰할 수 있다.
  • 답글이면 부모 포스트도 컨텍스트로 넣어서, "이건 그 포스트에 대한 반응이다"라는 맥락이 판정에 영향을 준다(include_reply_to=True).
  • 심판 컨텍스트에서는 원 프롬프트에 담긴 "사고 과정 노출 금지" 유의 제약을 _strip_thinking_restrictions()로 벗겨, 사고 과정을 볼 수 있게 한다(감사 목적).
  • 판정 계층은 safemodel, PtOS, Grok 4.5 심판의 세 층이 됐다. 4세대의 "labeling 파이프라인 통째 공개" 흐름과 방향이 같다.

심층 분석이 필요한 질문은 세 가지다.

  • 이 심판 계층이 다른 카테고리(폭력·혐오·스팸)로 확장되는가
  • Grok 4.5 추론 비용을 어느 정도까지 소화할 수 있는가
  • EAPI_GROK_4_5_X_ALGO 모델이 기존 EAPI_GROK_4_5_INTERNAL과 어떻게 다른가. 아마도 x-algorithm 전용 튜닝과 내부 일반 심판의 차이다.

2026-08-18 커밋(4세대+3)은 이 태스크의 로깅을 개선했다.

  • Metrics.counter(metric)의 outcome attribute가 "hard"|"soft" 이분 라벨에서 판정된 policyType.value 원본으로 바뀌었다 확인 — grox/flows/ptos/task_safety_ptos_adult_content_cross_validation.py:128-131
    • 심판이 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세대+6(28e414f, 08-21): 4.6 internal로 100% 전환했고, 4.5 internal은 코드에서 완전히 제거됐다.
  • 4세대+7(d011592, 08-24): 4세대+2부터 성인 콘텐츠 심판이 쓰던 EAPI_GROK_4_5_X_ALGO를 새 cross-validator 클래스도 쓰게 됐고, 범위가 3개 카테고리(child safety, violent media, illegal)로 넓어졌다.
    • 성인 콘텐츠 심판은 별개 클래스(SafetyPtosAdultContentCrossValidationJudge)로 여전히 유지된다.

심판이 primary와 같은 모델이면 같은 실수를 확대 재생산하므로, 별개 모델의 관점이 강등만 허용하는 것이 이 설계의 합리다.

2026-08-19 커밋(4세대+4)은 Grok 4.6 internal을 트래픽 10%에 붙이고, NSFW 재분류를 건너뛰기 위한 캐시를 추가했다. 확인

판정 스택이 두 방향으로 정교해졌다.

  • 첫째, 아동 안전 심판과 고-fav deluxe 재분류에 Grok 4.6을 도입했다 확인 — grox/flows/ptos/classifier.py (+66/−7)
    • 새 모델명 ModelName.EAPI_GROK_4_6_INTERNAL을 등록하고, EapiSampler 인스턴스와 전용 서킷브레이커(_eapi_4_6_internal_breaker)를 붙였다.

    • 서킷브레이커 파라미터는 실패율 50%, 600초 창, 회복 600초, half-open 5회로 4.5 internal과 같다.

    • 두 호출 위치에서 확률적으로 모델을 고른다.

      _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세대+2가 도입한 성인 콘텐츠 심판은 이 dial의 대상이 아니다.

  • 둘째, 포스트가 이미 NSFW로 플래그됐는지 조회하는 post_is_already_flagged_nsfw가 새 모듈로 나왔다 확인 — grox/flows/ptos/prior_nsfw.py (34줄 신규)
    • 새 모듈 prior_nsfw.py는 StratoSafetyPostAnnotationsResultDirectMh(X 내부 Strato http/Manhattan에서 안전 어노테이션 결과 조회)로 확인한다.
    • 결과를 state.prior_nsfw에 캐시해, 같은 태스크 컨텍스트 안에서 다시 조회하지 않는다.
    • 조회에 실패하면 카운터 safety_ptos.prior_nsfw_lookup_error.count를 올리고 not-flagged로 fallback한다.
    • 4세대+2 cross-validation 태스크가 조회하던 것을 별도 모듈로 리팩터링한 것으로 보인다 추정.
    • cross-validation 태스크의 sync_task_kwargs가 post_is_already_flagged_nsfw import로 바뀌었다.

의미: PTOS 판정 계층은 4.5 → 4.6 모델 전환 트래픽 분할과 prior classification lookup 캐시 분리로 정리되고 있다. 4세대+2 → +3 → +4의 3커밋에 걸쳐 이 판정 스택이 매일 정교화되는 흐름이 확인된다.

2026-08-20 커밋(4세대+5)은 Grok 4.6 internal dial을 0.1에서 0.3으로 올렸다(하루 만에 3배). 확인 — grox/flows/ptos/classifier.py:114

-_GROK_4_6_INTERNAL_DIAL = 0.1
+_GROK_4_6_INTERNAL_DIAL = 0.3
  • _EAPI_4_6_INTERNAL_BREAKER_CONFIG 회로차단기 파라미터와 적용 위치(아동 안전 cross-model validation, deluxe 4.3 카테고리 재분류)는 그대로다.
  • 의미: 이 승격은 4.6 트래픽이 회로차단에 걸리지 않았고 판정 disagreement rate가 허용 범위였음을 뒷받침한다 추정. 그렇지 않으면 3배 dial 승격이 불가능하다.
  • 4세대+2 성인 콘텐츠 심판 도입 → +3 로깅 세분화 → +4 4.6 dial 10% → +5 dial 30%로 PTOS 판정 스택이 매일 바뀌었다. 4.6 dial은 4세대+2 심판이 아니라 아동 안전 심판과 deluxe 재분류에 걸린다.

2026-08-21 커밋(4세대+6)은 Grok 4.5 internal을 완전히 제거하고, 4.6 internal로 100% 전환했다. 확인 — grox/flows/ptos/classifier.py (+6/−63)

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_sampler에 stop_predicate callback과 aclosing이 들어갔다 확인 — grox/libs/grok_sampler/llm.py (+27/−13)
    • raw stream을 async with aclosing(...) 컨텍스트로 감싸, 조기 종료 때도 정리를 보장한다.
    • 각 토큰을 yield한 뒤 stop_predicate(resp)를 확인하고, True면 llm.sample.early_stop.count 카운터를 올린 뒤 break한다.
    • 안전 판정 응답이 <json>...</json> 태그로 오는 경우, 태그가 닫히면 뒤 토큰은 무의미하다. 조기 종료로 토큰 비용과 지연을 줄인다.
    • 4.6 트래픽 증가에 맞춘 sampling 인프라 최적화다 추정.
  • grox User 스키마에 profile_image 필드가 생겼다 확인 — grox/core/data_loaders/data_types.py
    • 매핑은 grox/core/data_loaders/post_mapper.py에 있다. AuthorMetadata.profileImageUrl이 있으면 Image(url=...)로 채운다.
    • 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의 아동 안전 심판을 이 클래스로 흡수한 것이다.
    • ViolentMedia(_validate_violent_media)는 4.5로 재확인한다. 일치하면 유지하고, 심판이 NoViolation으로 판정하면 강등하고, type_mismatch면 ViolentMediaGraphicMedia(약한 형)로 downgrade한다. 강등 옵션이 NoViolation, GraphicMedia, 유지의 3가지로 세분화됐다.
    • IllegalAndRegulatedBehaviors(_validate_illegal_and_regulated_behaviors)는 4.5로 재확인한다. 일치하면 policy type을 유지하되 reason은 4.5가 준 것으로 덮어쓰고, 불일치하면 NoViolation으로 강등한다.
  • 세 카테고리 모두 _build_policy_convo로 표준 프롬프트를 만들고 _strip_thinking_restrictions를 적용한다(reasoning 활성) 확인 — 같은 커밋.
    • 프롬프트는 child_safety_policy_prompt(post_creation_time), violent_media_policy_prompt(), illegal_and_regulated_behaviors_policy_prompt()다.
  • 강등만 허용하는 원칙을 유지한다 확인 — 같은 커밋. 4세대+2의 성인 콘텐츠 judge와 같은 설계 철학이다.
    • cross-validator가 primary보다 엄격한 판정을 낼 수는 없다.
    • 심판은 primary가 놓친 위해 콘텐츠를 새로 잡아내지 않고, primary가 오탐한 것을 풀어 주는 방향으로만 작동한다.
  • 메트릭은 세 카테고리별 safety_ptos.<category>_cross_model_validate_with_grok_4_5 카운터다 확인 — 같은 커밋.
    • 카운터에는 outcome 태그(agreed/disagreed/unparseable/no_violation/type_mismatch/error)가 붙는다.

같은 커밋에서 분류기는 ViolentSpeech와 SuicideOrSelfHarm 두 카테고리에서만 추론(reasoning)을 켜고, 부모 스레드가 있으면 스레드 전체를 함께 넣게 했다 확인.

  • SafetyPtosPolicyClassifier.build_convo()는 이 두 카테고리에서만 _strip_thinking_restrictions를 프롬프트에 적용한다(reasoning 활성) 확인 — 같은 커밋.
    • 이때 assistant 메시지를 빈 상태로 두어 모델이 자유롭게 사고하게 한다.
    • 집합은 USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm}이다.
    • 다른 카테고리는 여전히 THINKING_CONTROL_END로 사고를 억제한다(빠르고 저렴한 응답).
  • 같은 두 카테고리에서만, post에 ancestors가 있으면 ThreadRenderer로 부모 스레드 전체를 사용자 메시지로 렌더한다 확인 — 같은 커밋.
    • 집합은 USE_THREAD_RENDERER_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm}이다.
    • 이유는 근본적이다. "죽고 싶어" 같은 문장은 스레드 문맥 없이는 판정할 수 없다. 이전 발화가 게임에서 진 얘기면 무해하고, 자해 지원 스레드면 위중하다.
    • 마찬가지로 폭력 발언도 인용과 응답 맥락이 판정을 결정한다.
    • 다른 카테고리(성인·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의 쓰기 태스크).

2026-08-24 커밋(4세대+7)에서는 grox의 다른 곳도 세 군데 바뀌었다.

  • 답글 스팸 필터와 답글 랭킹 필터의 follower 임계가 80k → 100k로 올랐다(grox/flows/reply_spam/task_filter.py:17).
    • 두 필터는 grox/flows/reply_spam/task_filter.py의 TaskSpamFilter와 TaskReplyRankingFilter다.
    • 11일 동안 다섯 번(15k → 30k → 40k → 60k → 80k → 100k) 올라 스팸 판정 대상이 계속 넓어졌다.
    • 4세대+3 뒤의 상향 폭은 1.25~1.5배다. 임계를 올릴수록 팔로워가 더 많은 계정의 스레드까지 grok으로 검사하므로, 큰 계정 스레드의 스팸 답글을 더 검사하려는 조정으로 본다 추정.
  • 답글 랭킹 점수 저장 모듈이 점수를 지역 캐시 두 곳에도 쓰게 됐다 확인 — grox/flows/reply_spam/strato_loader.py (+17/−0).
    • 이 파일과 ReplyRankingScoreStratoLoader 클래스는 4세대 공개 때부터 있었다(32줄) 확인 — 47c1bcd의 같은 파일.
    • 그때부터 점수를 StratoReplyRankingScore.put()로 저장하고, StratoReplyRankingScoreV2Kafka.insert()로 이벤트를 냈다.
    • 4세대+7은 put() 뒤에 단계 하나를 더했다. score ≤ 1.0이면 지역 캐시 두 곳(StratoReplyRankingScoreCacheAtla, StratoReplyRankingScoreCachePdxa)에 병렬로 쓴다.
    • 스코어가 1.0을 넘으면 캐시 fanout을 건너뛴다 확인 — strato_loader.py:20, 34-41. 낮은 신뢰도 판정은 정본(strato)에만 남고, 서빙 캐시는 오염되지 않는다 추정.
  • X Spaces(오디오) 컨텍스트가 LLM 판정에 들어왔다. 오디오 스페이스 카드가 붙은 포스트를 Grok 안전 심판이 처음으로 인식한다.
    • grox/core/data_loaders/data_types.py에 SpaceMetadata 클래스가 생겼다(이 커밋 기준 title 하나). Post에는 space_metadata: SpaceMetadata | None 필드가 붙었다.
    • post_mapper.py가 StratoSpaceMetadata에서 매핑한다.
    • grox/core/lm/post.py renderer가 \n\nThis post has the following audio space card attached:\nAudio Space title: {title}로 LLM 프롬프트에 넣는다.
    • 오디오 콘텐츠 자체를 듣지는 않지만, 스페이스 제목 텍스트가 판정 재료가 된다. 예를 들어 스페이스 제목에 명시된 주제가 성인/폭력이면 브랜드 안전 판정에 반영된다.

4세대+9(2026-08-26, 45b48ba)는 reply_spam LM 프롬프트에 팔로워 수와 bio를 넣는 옵션을 만들고, Manhattan 쓰기에 monotone-only 게이트를 걸었다. 확인

  • LM 판정에 들어가는 유저 메타가 넓어졌다 확인 — grox/core/lm/post.py (+18/−6), grox/core/lm/thread.py (+25/−4)
    • 이전 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을 확장한다.
  • Manhattan으로 나가는 답글 랭킹 스코어는 시간에 따라 낮아지기만 하고 오르지 않는다 확인 — grox/flows/reply_spam/task_write.py (+22)
    • _publish_to_reply_ranking_manhattan 안에서 ReplyRankingScoreStratoLoader.fetch_reply_ranking_score(post.id)로 기존 스코어를 조회한다.
    • 새 스코어가 기존보다 크면 쓰기를 건너뛰고 Metrics.counter("task.write_reply_ranking_manhattan.skipped.count", reason="higher_than_existing")를 올린다. 낮으면 그대로 쓴다.
    • 의미:
      • 답글의 첫 판정이 낮게(스팸에 가깝게) 나오면 그 답글은 계속 낮은 스코어를 유지한다. 새 판정이 관대해져도 서빙 스코어는 갱신되지 않는다.
      • 반대로 첫 판정이 높게(관대하게) 나온 답글은 이후 새 판정이 엄격해지면 낮아진다.
      • 이는 답글 스팸 판정의 monotone 안전 방향(한번 스팸으로 잡히면 계속 스팸 취급, 정상→스팸 재분류만 허용)과 정합적이다.
      • 답글 랭킹 스코어의 시간축 진동(label flapping)을 원천 제거해, UI나 A/B에서 같은 답글의 랭킹이 오르락내리락하지 않게 만든다 추정.
      • reply ranking 채널에서 label flap이 실제로 관측됐을 것이다 추정.

4세대+10(2026-08-28, 24c6094)은 Kafka-first 저장 순서와 동점 건너뛰기를 더하고, coordinated spam 판정에 팔로워 수를 상시 주입하게 했다. 확인

_publish_to_reply_ranking_manhattan은 4세대+9의 monotone-only 위에서 세 갈래로 더 조여졌다 확인 — grox/flows/reply_spam/task_write.py (+15/−21)

  1. 동점도 쓰지 않는다. 비교 연산자가 >에서 >=로 바뀌어, score >= existing.score이면 건너뛴다.
    • 이전에는 새 스코어가 기존과 같으면 그대로 썼다(idempotent write).
    • 정확히 같은 스코어가 다시 계산됐을 때 Manhattan put을 아끼고, Kafka 이벤트도 나가지 않는다.
    • 카운터 이름도 higher_than_existing → not_lower_than_existing으로 바꿔 시맨틱을 분명히 했다.
  2. 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 순서다.
  3. fetch_reply_ranking_score의 try/except를 걷어냈다. 이전에는 fetch에 실패하면 warning 로그를 남기고 existing = None으로 진행해 항상 썼다. 이제는 예외를 그대로 위로 던져, flow task가 실패로 표시되고 재시도된다.
    • fetch가 조용히 실패해 monotone 비교가 무효화되고 write가 튀는 사고를 원천 차단한다.
    • defensive 예외 처리를 걷어내고 로직 정확성을 우선했다.

같은 커밋에서 4세대+9의 팔로워 카운트 옵션이 coordinated spam 판정에서 상시 활성으로 채택됐다 확인 — grox/flows/reply_spam/classifier_coordinated_spam.py (+5/−1)

  • 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 계약이 훨씬 명시적이 됐다.

↑ 목차

BDSM (계정 행동 시퀀스 부정 계정 탐지) — 4세대+1에서 enforcement note 생성 로직 부분 공개

BDSM은 계정의 시계열 행동 시퀀스에서 부정·어뷰징 신호를 뽑는 계정 스코어러다. c65aa17 커밋(4세대+1) 기준으로 enforcement note 생성 로직은 일부 공개됐다 확인(커밋 요약표). 노트를 만들 조건과 노트에 실을 봇 헤드를 고르는 로직은 공개됐고, 템플릿 문자열과 튜닝된 판정 임계값은 공개되지 않았다 확인(해소 기록). 노트에는 어떤 봇 계열에 얼마나 걸렸는지가 점수로 실리지만, 어떤 액션 패턴 때문인지를 설명하는 텍스트는 공개되지 않았다 확인(BDSM 절 본문).

3세대부터 4세대 초까지 bdsm/runtime/score_results_sink_focal.py:78의 build_enforcement_note는 return None뿐인 스텁이었다. 4세대+1 커밋에서 gate 로직과 dominant-head 선택 로직이 공개됐고, 템플릿 문자열만 "<redacted>" 센티널로 남았다 확인 — bdsm/runtime/score_results_sink_focal.py:78-128, bdsm/README.md, bdsm/proto/abuse_inference.proto:98-102.

  • 노트는 액션이 30개 이상인 시퀀스에서, LegitimateUser가 아닌 헤드 가운데 점수가 0.5를 넘는 것이 있어야 만든다 확인 — 앞 문단의 세 파일.
    • 액션 수 조건은 MIN_ACTIONS = 30, total_actions >= 30이고, 점수 조건은 score > 0.5다.
  • 조건을 통과하면 점수가 가장 높은 헤드를 dominant로 뽑고, 노트 문자열에 상위 3개 헤드까지 이름과 점수를 싣는다 확인 — 앞 문단의 세 파일.
    • 노트 문자열은 "<redacted> [model: FollowBot=0.91, also: ReplySpamBot=0.60, LikeBot=0.53]" 형태다.
    • 어떤 봇 계열에 얼마나 걸렸는지는 노트에 그대로 나가지만, 어떤 액션 패턴 때문에 걸렸는지를 설명하는 텍스트는 공개되지 않았다.
  • 봇 헤드 7개가 정식으로 공개됐다 확인 — 앞 문단의 세 파일.
    • 이름은 FollowBot, LikeBot, EngagementAmplifier, ReplySpamBot, TweetSpamBot, RTBot, MultiActionBot이다.
  • sink_policy.yaml의 operating point(판정 임계값)를 채운 9.99는 센티널이며, README가 이를 명시적으로 규정한다 확인 — bdsm/runtime/sink_policy.yaml:5-13.
    • operating point는 확률 [0, 1] 범위인데, 9.99로 채워져 있으면 절대 트리거되지 않는다.
    • 게이팅 구조와 헤드 이름은 진짜이고, 튜닝된 실수는 게이밍 방지 목적으로 공개하지 않았다는 뜻이다.
  • 주의할 점은 노트에 실리는 헤드 점수가 봇 확률이지 "리포트 심사자 결정"이 아니라는 것이다 확인 — 앞 문단의 세 파일.

↑ 목차

소스 읽는 법 — 주의 목록

이 목록은 X 공개 소스를 읽을 때 "코드에 있으니 사실이다"라고 판단하지 않도록 주의할 점을 모은 것이다. 2026-08-15 갱신 기준으로 4세대 xai-org/x-algorithm에는 가중치와 상수 값이 채워졌고, Phoenix도 완전히 빌드할 수 있게 됐지만 프로덕션 규모는 아니다 확인(1번 항목). 3세대 canon이 레포 밖이라고 본 점수 후처리 유틸과 외부 서비스 클라이언트 모듈도 4세대 공개에는 들어 있다 확인(1번 항목). 3세대 코드에서는 어떤 모듈에도 연결되지 않은 파일과, 리터럴 치환으로 문법이 깨진 파일도 관찰됐다 확인(2·3번 항목).

  1. 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.
    • 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 실행 가능, 단 서비스 접착층은 여전히 미공개"로 옮겨 갔다. 빠진 부분은 우연한 누락이 아니라 여전히 일관된 성격을 띤다.

  2. 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개 있다 확인.
      • 11개 파일은 다음과 같다.
        • candidate_hydrators/engagement_counts_hydrator.rs
        • candidate_pipeline/{candidate,candidate_features,query,query_features}.rs
        • query_hydrators/{user_action_seq,user_features}_query_hydrator.rs
        • scorers/{author_diversity,oon,weighted}_scorer.rs
        • side_effects/cache_request_info_side_effect.rs
    • 판별법(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. 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)에는 없다 확인.
  4. git log --diff-filter=D <commit>은 그 커밋의 삭제가 아니라 조상 전체의 삭제를 보여 준다. 이 명령의 결과로 "2025 리프레시가 DiversityScorer.scala를 지웠다"고 결론 내리면 틀린다. 그 파일은 2023-07-07에 지워졌다 확인.

  5. the-algorithm-ml 클론이 shallow clone이면 모든 파일이 "new file"로 보인다. 그런 클론으로는 가중치 히스토리를 읽으면 안 된다 확인.

  6. 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)에서 이 게이트를 뺐다(콜드스타트 슬롯 선택기).
    • 스크롤 중의 "더 보기"는 상당 부분 모델 추론 없이 이전 요청의 잔여 후보로 응답된다 확인.
    • 그 비중이 얼마인지는 추정조차 할 수 없다. 레포에 단서가 없다.
  7. 3세대(0bfc279) 코드 기준으로 CoreDataHydrationFilter는 이름과 달리 코어 데이터 조회에 실패한 후보를 실제로는 거르지 못한다. 하이드레이터가 author_id를 쓰지 않기 때문이다 확인(코드) — core_data_candidate_hydrator.rs:108-113, filters/core_data_hydration_filter.rs:13

    • README는 이 필터를 "코어 메타데이터 hydration 실패한 포스트 제거"라고 설명한다.
    • 버그로 본다 추정. 코드 흐름상 다른 설명이 어렵다.
  8. 3세대(0bfc279) 코드 기준으로 실패가 전부 조용하다. 프레임워크 레벨의 타임아웃도, 서킷브레이커도, 폴백도 없다 확인 — candidate-pipeline/candidate_pipeline.rs:202-218, 266, 419-428

    • 에러가 난 소스와 쿼리 하이드레이터는 사라진다 확인.
    • 후보 하이드레이터에서 에러가 나면 해당 필드가 설정되지 않은 채로 남는다 확인.
    • 사이드이펙트는 detached로 실행된다 확인.
  9. 3세대(0bfc279) 코드 기준으로 로깅이 샘플링돼 있다. 가장 풍부한 두 로그 스트림이 대표성 없는 슬라이스다 확인 — side_effects/reranking_kafka_side_effect.rs:14,29

    • 리랭킹 Kafka 로그는 프로덕션에서 5%를 샘플링하고, 상위 50개만 남긴다 확인.
    • 나머지 하나는 shadow traffic에서만 남는다 확인.
  10. 2025 Scala 코드에는 주석이 코드와 어긋난 곳이 있다. 저신호 유저 게이트의 주석 두 곳은 "< 25 users"라고 쓰지만, 실제 상수는 SmallFollowGraphSize = 5다. 주석을 근거로 임계값을 인용하면 5배 틀린다 확인 — SignalUtils.scala:17-23 vs AllowLowSignalUserGate.scala:12, DenyLowSignalUserGate.scala:12

  11. "랭커 점수"라는 단일 값은 없다. 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).

↑ 목차

이 서비스에서 나온 일반화 노트

X에서 관찰한 기법 가운데 여러 서비스에 걸치는 것은 topics 폴더의 주제 노트에 따로 정리했다. 각 노트는 X의 사례를 Meta 같은 다른 서비스의 사례와 나란히 놓고 비교한다.

노트 다루는 내용
멀티태스크 랭킹 예측 헤드 설계, 3세대까지 가중치가 공개되지 않았던 이유
후보 배분 후보 소스가 여럿일 때 소스마다 몇 개씩 넣는가, 인네트워크와 OON 포스트를 "50:50"으로 섞는다는 오해
피드 다양성 감쇠, MMR, DPP, dedup
임베딩 기반 후보 생성 SimClusters, TwHIN, two-tower, ANN 인덱스
시퀀스 트랜스포머 랭킹 Phoenix의 구조
추천 파이프라인 안의 LLM Grok/Grox 주석 파이프라인

↑ 목차

열린 질문

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/에서 어떤 스코어가 어떤 조치(라벨/챌린지/서스펜션)로 매핑되는가.
  • scarecrow/botmaker/botmaker-rules의 DSL과 규칙 카테고리는 무엇인가. 공개 규칙과 미공개 규칙의 비율도 질문이다.
  • under-the-hood/의 라벨 taxonomy는 무엇인가. 유저에게 노출되는 라벨이 규칙 코드와 어떻게 연결되는지가 질문이다.
  • InventoryHoldoutFilter의 홀드아웃 비율 param: 2026-09-17 커밋 42266f3(4세대+23)에서 3개 param을 확인해 해소했다(필터 21개 절).
    • 세 param은 InventoryHoldoutOriginalsPercent·InventoryHoldoutRepliesPercent·InventoryHoldoutRetweetsPercent다.
  • RECSYS_EMBED_V4 멀티모달 임베딩은 어디서 쓰는가. 홈 랭킹은 아니다.
  • 2025 Scala 세대 heavy ranker(Navi)의 아키텍처는 무엇인가. MaskNet/DCN 계열의 세부가 질문이다. 모델 정의가 the-algorithm-ml 쪽에 있어 따로 확인해야 한다.
  • Phoenix retrieval의 two-tower 구조와 ANN 인덱스는 어떻게 생겼는가. phoenix/recsys_retrieval_model.py는 봤다. 서빙 쪽 인덱스는 4세대에서 부분 공개됐다(phoenix-rankall/). 전체를 다시 검증해야 한다.
  • 2026 세대의 models/와 candidate_pipeline/ 이중 레이아웃이 4세대에서 정리됐는가. 아직 확인해야 한다.
  • 2세대의 가중치 노이즈 주입이 4세대에서도 유지됐는가. 2세대의 노이즈는 HeavyRankerWeightsQueryFeatureHydrator.scala의 Beta 분포다. 유지 여부는 심층 분석이 필요하다.
  • 2026-10-08 커밋(4세대+38)은 네 가지 심층 분석 과제를 남겼다(변경 이력: 35650fb).
    1. 영상 캐러셀의 최종 응답 크기, 그리고 본 피드와 겹치는지
    2. VF hydration/sources/exchange.rs(528줄)와 clients/user_location_client.rs(345줄)가 위치 기반 판정을 새로 추가하는지
    3. phoenix-rankall/src/hydrate/*(약 1,300줄)로 새로 생긴 hydration 스케줄러
    4. MoL two-tower가 실제 실험 config에서 켜졌는지
  • 2026-10-09 커밋(4세대+39)이 랭킹 요청에 넣은 FeedRequestContext·FeedCursorDirection은 어디서 채워지고 어디서 읽히는가.
    • Phoenix 랭커가 이 값을 피처로 읽는 코드가 언제 공개되는지, home-mixer가 어느 요청 정보로 이 값을 채우는지 확인이 필요하다.
    • 4세대+40(e1a0375)에서 home-mixer가 RequestContext::parse(query.request_context)로 값을 만들어 광고 인덱스 요청에만 채우기 시작했다 확인.
    • Phoenix 랭킹 요청 쪽은 여전히 미해소다.
  • 2026-10-13 커밋(4세대+40)은 네 가지 심층 분석 과제를 남겼다(변경 이력: e1a0375).
    1. two-tower ld 헤드의 CLIENT_TWEET_RECAP_DWELLED 판정 임계와 combined 프리셋이 프로덕션 retrieval인지
    2. 답글 랭커 reply_two_tower_v2를 부르는 서비스, 그리고 grox 답글 점수와의 연결
    3. abuse-enforcement action_control·off_switch(약 6,000줄)와 VF explain.rs·rules/levels.rs 리팩터가 정책 변화를 포함하는지
    4. home-mixer와 vm-ranker 미러에서 갈라진 가중치 4개(PostUnexploredWeight, favorite, video_open, cont_dwell_time) 가운데 어느 쪽이 서빙값인지
  • 2026-10-14 커밋(4세대+41)은 네 가지 심층 분석 과제를 남겼다(변경 이력: 17c96bf).
    1. serve_page_decoder(기본 False)를 켠 서빙 인스턴스가 있는지, 그리고 디코딩 결과를 home-mixer의 어느 단계가 소비하는지
    2. 학습 로그에 이미 rl_rerank_prob·rl_decode_prob가 있는데, 이 값을 만든 behavior 정책이 무엇인지
    3. immersive_page_rl이 immersive 영상 지면 전용인지, 홈 For You에도 쓰이는지
    4. TopicDppEnabled를 켜는 실험이 있는지
  • 2026-10-16·10-17 커밋(4세대+43·+44)은 네 가지 질문을 남겼다(변경 이력: bc3948b, 862a123).
    1. Phoenix 모델이 TweetInfo.postEngagers를 어떤 인코더로 읽는지
    2. RankingScoreWeights를 채우는 home-mixer 호출 측
    3. IsBrandEngagement 라벨을 쓰는 헤드가 광고 랭커인지
    4. ZeroFav 양면 실험의 작성자 버킷 규모
  • 2026-10-20 커밋(4세대+45)은 두 가지 질문을 남겼다(daily 2026-10-21).
    1. VMRanker가 요청에 실린 뷰어 이력 토픽을 어떤 재순위 목표에 쓰는지
    2. 섀도 클러스터 Experiment16Fou가 쓰는 이력 집계 DENSE_WITH_LONG_DWELL_HOME_EXCURSION이 기존 집계와 무엇이 다른지
  • X는 EU DSA 투명성 보고서에서 무엇을 바꾸는가. X가 이번 8-13 공개 뒤 무엇을 새로 제출하거나 삭제하는지가 질문이다. 규제 문서와 코드가 서로 맞는지(sync 여부)가 새로운 검증 각도다.

4세대 공개로 해소된 것 (2026-08-15)

4세대 공개로 가중치 실값, VMRanker와 DPP의 활성 여부, Phoenix 헤드 개수에 관한 질문이 풀렸다. 학습 파이프라인과 콜드스타트 질문은 풀렸지만 남은 부분이 있다.

  • ✅ 가중치 실값: home-mixer/params/param.rs가 프로덕션 크론 미러로 공개됐다.
  • ✅ VMRanker/DPP가 켜져 있는가: 켜져 있다(EnableVMRanker=true, DPP θ=0.65, K=150).
  • ✅ Phoenix 헤드 개수: 문서 기준 24개로 확정됐다.
    • 구성은 Engagement 7 + Clicks 6 + Attention 5 + Author 1 + Negative 5다.
  • ✅ 2026 Rust 학습 파이프라인: phoenix/에 학습 코드, synthetic 데이터, QUICKSTART가 있다. 단 loss, negative sampling, optimizer는 심층 분석이 필요하다.
  • ✅ 신규 아이템 콜드스타트 부재: 4세대에서 새-작성자 부스트가 공개됐다(PostUnexploredWeight=0.02, ColdStartImpressionThreshold=1000). 단 개별 새 포스트를 위한 별도 장치는 여전히 없다.

4세대+1 공개로 해소된 것 (2026-08-16)

이 공개로 가중치 비율을 읽는 규칙, 콜드스타트 슬롯 선택기, 관할권 필터에 관한 질문이 풀렸다. Phoenix bool feature와 BDSM enforcement note는 일부만 풀렸다.

  • ✅ 가중치 비율의 해석 규칙: param.rs·ranking_scorer.rs 주석과 README가 "ratio를 raw count 등가로 읽지 말라"고 명시했다.
    • canon은 3세대까지 "reply 13.5 = favorite 27개 값"으로 해석한 문서를 인용해 왔다. 이 해석이 원리적으로 틀린 프레이밍이었음이 확정됐다.
  • ✅ Phoenix bool feature 채움 상태: bool_features 전체가 zero stamp된다는 관찰이 4세대+1에서 부분 해소됐다.
    • 3개가 채워졌다(IS_STALE_POST14D·IS_AUTHOR_FOLLOWED/FOLLOWING).
    • 나머지 bool 슬롯을 쓰는지는 여전히 공개되지 않았다.
  • ✅ 콜드스타트 슬롯 선택기 로직: greedy와 Beta Thompson sampling의 두 갈래가 param으로 연결됐다. 단 Thompson은 기본 off이고, 실제 프로덕션은 여전히 greedy다.
  • ✅ 관할권 필터 존재 여부: Brazil2026ElectionFilter로 코드 수준에서 확인됐다. 앞으로 다른 나라의 선거·규제 대응이 같은 관행으로 붙을지가 새로 지켜볼 점이다.
  • ✅ BDSM enforcement note 생성 로직: gate와 dominant head 로직이 공개됐다. 템플릿 문자열과 operating point 실수는 여전히 공개되지 않았다.

4세대+2 공개로 해소된 것 (2026-08-19)

이 공개로 Grok 안전 라벨의 마이그레이션 방식, 성인 컨텐츠 판정이 엇갈릴 때의 처리, Phoenix retrieval의 인덱싱 게이트가 드러났다. 첫 항목의 판단은 다음 날 뒤집혔다.

  • ✅ Grok 안전 라벨의 스키마 마이그레이션 관행: 라벨 스키마가 버전으로 관리되고, 그 마이그레이션이 코드 안의 dual-write 감지 로직으로 들어가 있음이 확정됐다. 주의: 이 판단은 4세대+3(다음 날)에서 재설계로 뒤집혔다(4세대+3 기록).
    • compute_verdict_v2가 _V2 라벨 6종을 참조한다.
    • strip_v1_grok_written이 PROMPT_OWNED_RULES = [1400, 1410, 1420, 1500, 1510, 1610, 1700]에 속한 V1 dual-write 라벨을 걸러 낸다.
    • v2_mirrors_v1_across_tier_matrix 테스트가 6가지 라벨 조합과 2 tweet_id 시점에서 V1/V2 동치를 assert한다.
  • ✅ 성인 컨텐츠 판정이 엇갈릴 때의 처리: 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이 파이프라인 단계에서 명시적으로 갈리기 시작했다.
    • VFFollowingCandidateHydrator가 새로 연결됐다.
    • Following은 이제 VF 결과만 남기고, 다른 후보 필드는 default로 둔다.
  • ✅ Phoenix 이벤트 스트림이 v3로 옮겨 가고 Kafka 클러스터가 분리됐다.
    • content_understanding_realtime_unified_posts → ..._v3
    • main-2 → phoenix-kafka-external-bootstrap
    • ValueOnly → KeyValue(key=Long)
    • grox reply_spam도 같은 커밋에서 v3로 옮겨 갔고, 흐름도 통합됐다.
  • ✅ grox reply_spam 흐름이 통합되고 임계가 다시 조정됐다.
    • PostStreamTaskGenerator가 삭제되고, ReplyRankingTaskGenerator가 PlanSpamComment·PlanCoordinatedSpam을 흡수했다.
    • 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의 하이브리드다.
    • muon_adam_patterns="embed,_emb,logits,vocab,norm,table"
    • base LR 1e-3 → 7.1e-4, muon_split_fused="qkv:128", muon_consistent_rms=0.2
    • embedding은 rowwise_adagrad를 쓴다(LR 0.28, half-life 2500 step, lazy_decay, weight_decay 2.8e-4).
    • GB300 override는 LR 5e-4·half-life 5000이다.
    • 이 저장소가 반영해 온 대규모 프로덕션 recsys 학습 파이프라인 가운데 Muon 채택을 코드로 확인한 첫 사례다. LLM optimizer가 랭커/retrieval 학습으로 넘어온 것을 보는 관찰 축이 새로 생겼다.
  • ✅ Phoenix가 검색 지면으로 확장하는 첫 코드 신호가 나왔다. 검색용 config가 새로 생겼고, proto의 CLIENT_TWEET_(NOT_)RELEVANT_TO_SEARCH 두 액션이 짝을 이룬다.
    • config는 xrecsys_search다(vocab 100M/100M/30M, EP 512, use_product_surface=True, condition_search_relevance_on_prompt=True).
    • 이전 canon은 지면마다 코드베이스가 따로라고 이해했다. 이제는 같은 Phoenix 트랜스포머가 config만 갈아 끼우면 검색과 피드를 모두 서빙하는 방향으로 통합되고 있다.
  • ✅ Phoenix proto에 reward head의 뼈대가 생겼다. RL(actor-critic value+policy) 학습이나 reward-model-guided 재순위 슬레이트 결정으로 가는 연결이 시작됐다. 어느 스코어러가 소비하는지는 심층 분석이 필요하다.
    • 새로 생긴 것은 RewardOutputs(values와 policyLogits)와 ScoreInfo.rewardRerankSlotProb다.
    • 2026-10-14 커밋 17c96bf(4세대+41)에서 immersive 지면용 페이지 디코더(actor)와 twin critic 학습 코드가 공개됐고, 이 학습은 rewardRerankSlotProbSeq 컬럼을 로깅 확률 rl_rerank_prob로 읽는다 확인(변경 이력의 4세대+41).
      • 같은 커밋에서 proto 밖의 코드는 RewardOutputs(rewardOutputs·policyLogits)를 참조하지 않는다 확인 — 17c96bf 전체 grep.
  • ✅ X 랭커의 예측 스키마가 처음으로 분포 형태를 담기 시작했다. 링크 열림 확률을 단일 스칼라가 아니라 P10/P25/P50/P75/P90의 5분위 헤드로 예측한다.
  • ✅ Following 파이프라인의 병렬 구조가 넓어졌다. 4세대+3의 하이드레이터 분기에 이어, 4세대+4에서 필터와 hydrator가 하나씩 새로 붙었다.
    • 4세대+3의 분기는 VFFollowingCandidateHydrator다.
    • FollowingViewerMutedKeywordFilter(65줄)는 quoted text와 ancestor까지 매치한다.
    • QuotedPostTextHydrator(106줄)는 TES에서 quoted text를 가져온다.
    • For You와 Following이 필터·hydrator 스택에서 서로 다른 컴포넌트를 쓰므로 병렬 구조가 재확인된다.
  • ✅ PTOS 판정 계층에서 Grok 4.6 shadow가 시작됐다. 아동 안전 cross-validation과 deluxe 4.3 카테고리에서 트래픽 10%만 4.6으로 보낸다. 4.5 → 4.6 gradual rollout의 dual-write 단계가 시작됐다.
    • 비율은 _GROK_4_6_INTERNAL_DIAL = 0.1로 정한다.
    • 새 모델(EAPI_GROK_4_6_INTERNAL)과 전용 서킷브레이커가 연결됐다.
    • prior_nsfw.py(34줄 신규)로 Manhattan lookup 캐시를 분리했다.
  • ✅ VF shadow 배포 인프라가 연결됐다. fail-closed인 VF의 규칙 변경을 shadow로 검증하는 기반이 확립됐다(변경 이력의 4세대+4).
    • visibility-filtering/dark_traffic_setup.rs(109줄 신규)가 env-gated로, 특정 ordinal pod만 요청을 xai-vf-shadow.prod.visibility로 미러링한다.
    • mTLS를 쓰고, /config/dark-traffic/dark_traffic.yaml 규칙 파일을 reload한다.
  • ✅ 4세대+1에서 채워진 Phoenix bool feature 3종이 학습 기본값 ON으로 올라갔다.
    • enable_stale_post·enable_is_author_followed_by_viewer·enable_is_author_following_viewer·enable_engagement_counts 4개가 _home_direct_packed_base()의 기본값이다.
    • grpc_recsys dataset type만 enable_stale_post=False로 override한다(실시간 서빙에서는 count zeroing 위험을 회피 추정).
  • 심층 분석 필요 (신규):
    • RewardOutputs가 어느 학습 루프에서 채워지고 어느 스코어러가 소비하는지(recsys_model.py의 +265/−25 대폭 개편을 추적해야 한다)
    • link-open 5분위가 quantile regression인지 categorical mixture인지
    • xrecsys_search config가 프로덕션에 배포됐는지
    • Muon의 실제 성능 관찰치(공개 지표 없음)
    • dark_traffic YAML 실물(미공개)
    • condition_search_relevance_on_prompt의 프롬프트 형태

4세대+5 공개로 해소된 것 / 신규 (2026-08-22)

이 공개로 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) 하나뿐이었다.
    • NEGATIVE_FEEDBACK_HEAD_INDICES에 CLIENT_TWEET_SEARCH_QUERY_REFORMULATED가 추가됐다.
  • ✅ 학습 손실이 delayed feedback 여부로 갈리게 됐다. X 랭커 학습이 광고 delayed conversion prediction 문헌(Chapelle 2014)의 표준 처리 방식과 명시적으로 정렬됐다.
    • split_head_training_by_source config가 kafka의 is_delayed_feedback 컬럼을 sample_source로 들여온다.
    • engagement head는 fresh 샘플만, conversion head는 delayed 샘플만 학습한다(loss mask가 갈린다).
    • 메트릭 슬라이스는 fresh/delayed_clicked/delayed_non_clicked 3개다.
    • 튜닝 노브는 mact_in_app_loss_weight·train_view_through_heads·conversion_keep_mask다.
  • ✅ ads_slim metric group이 새로 생겼다. 광고 랭킹 라인의 slim signal 조합이다.
    • 긍정 신호 3개(open link, external link long dwell, video quality view)가 들어간다.
    • 부정 신호 4개(not interested, block, report, mute)가 들어간다.
    • non-primary conversion click들도 들어간다.
  • ✅ Muon 학습 레시피가 export에 정식으로 들어갔다. export되는 학습 파이썬 코드를 그대로 돌리면 프로덕션 Muon 학습이 되는 상태로 옮겨 갔다.
    • TRAINING.md·README·reference/README.md가 재작성됐다. flagship ranking config와 home_direct_packed_nano가 프로덕션 Muon 레시피로 end-to-end 학습된다고 명시한다.
    • 임베딩 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의 인터페이스가 공개됐다.
    • BaseO2Client trait과 15 builder method(endpoint·bucket·access_key·chunk_size_mib·retry·timeout 등)를 노출한다.
    • 다만 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배다.
  • ✅ visibility-filtering dark_traffic의 타깃이 shadow에서 staging으로 바뀌었다. ⚠ 4세대+4 canon 재해석이 필요하다(변경 이력의 4세대+4).
    • 3일 만에 shadow 워크로드가 삭제되고 staging 미러링으로 재정의됐다.
    • 타깃은 xai-vf-shadow.prod.visibility → xai-vf-service.staging.visibility:grpc로 바뀌었다.
  • 심층 분석 필요 (신규):
    • is_delayed_feedback upstream 파이프라인의 소스(kafka에 이 컬럼을 흘리는 conversion 재관측 이벤트 로거)
    • split_head_training_by_source가 _home_direct_packed_base()나 flagship config에서 True로 승격되는 시점(광고 conversion 학습이 프로덕션으로 전환된다는 신호)
    • train_view_through_heads opt-in이 승격되는 시점
    • GB300 override를 flagship도 명시적으로 상속하는지
    • xai-o2 실제 구현이 공개될지(가능성 낮음 추정)
    • 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 판정을 확인할 수 있다.
    • scored_posts_server.rs의 safety_label_to_proto mapping에 GROK_SFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_V2·GROK_NSFA_EXPANDED_V2·PTOS_REVIEWED가 추가됐다.
  • ✅ V2 experiment의 DDG 트래킹이 연결됐다. V2가 experiment 단계이며, 매출/안전 metric을 관측하는 인프라가 완성됐다. 4세대+2(도입) → +3(재설계) → +7(experiment 실측) 8일 여정이 완결됐다.
    • ads_injection_logging_side_effect.rs가 EnableAdsBrandSafetyVerdictV2 experiment 버킷을 ddg_experiment_bucket 필드로 로깅한다.
  • ✅ 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(동시 디코딩 스로틀) 인자가 새로 생겼다.
    • XAI_RESTORE_NODE_SERIALIZE와 fcntl.flock()으로 노드당 restore를 직렬화한다.
  • ✅ grox reply_spam이 답글 랭킹 점수를 지역 캐시에도 쓰기 시작했다. 점수를 Strato에 되쓰는 경로는 4세대 공개 때부터 있었고, 이번에는 지역 캐시 팬아웃이 더해졌다 확인 — d011592의 저장 모듈.
    • ReplyRankingScoreStratoLoader는 4세대 공개(47c1bcd) 때부터 점수를 Strato에 put하고 Kafka v2로 emit했다.
    • 4세대+7부터는 score ≤ 1.0이면 지역 캐시(Atla, Pdxa)로도 팬아웃한다.
  • ✅ grox reply_spam의 follower 임계가 80k → 100k로 올랐다. 11일 동안 다섯 번(15k→30k→40k→60k→80k→100k) 올라 스팸 판정 대상이 계속 넓어졌다.
  • ✅ X Spaces(오디오) 컨텍스트가 grox LLM 판정에 들어간다. 오디오 자체는 듣지 않지만, 스페이스 제목이 브랜드 안전·성인 컨텐츠 판정의 재료가 된다.
    • SpaceMetadata 클래스와 Post.space_metadata 필드가 새로 생겼다.
    • 렌더러는 오디오 스페이스 카드를 \n\nThis post has the following audio space card attached:\nAudio Space title: {title} 꼴로 프롬프트에 넣는다.
  • 심층 분석 필요 (신규):
    • VF reference diff가 로그로만 나가는지, 별도 대시보드로 가는지(승격 임계값 정의: "diff율 X% 이하면 promote")
    • StratoVfClient가 감싸는 레퍼런스 VF의 실체(1·2세대 Scala VF의 잔재? 별도 스택?)
    • HighRisk tier가 광고 avoid 외의 후속 조치를 트리거하는지(랭커 강한 감쇠, 유기 노출 완전 차단)
    • V2 experiment의 홀드아웃/처리군 비율
    • EAPI_GROK_4_5_X_ALGO 튜닝 방식(4.5 base에 x-algorithm 파인튜닝을 한 것인지, 완전 별개 모델인지)
    • PTOS 심판 확장의 다음 카테고리(스팸·혐오 발화가 순차 확장 대상 추정)
    • SafetyPtosAdultContentCrossValidationJudge와 새 SafetyPtosPolicyCrossValidator의 통합 여부(같은 함수 시그니처, 다른 라우팅)
    • grox reply_spam score threshold 1.0의 의미(캐시 fanout 게이팅 임계가 확률인지 점수인지)
    • 오디오 컨텐츠 자체를 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 조회를 한다.
    • AuthorSocialgraphFilter가 여섯 갈래로 거른다.
      • (i) 뷰어→저자 mute
      • (ii) 뷰어→저자 block
      • (iii) 저자→뷰어 block
      • (iv) quoted 저자→뷰어 block
      • (v) 뷰어→quoted 저자 block
      • (vi) 뷰어→retweeted 저자 block
    • reverse_chron_posts_pipeline.rs에 연결됐다(VF 필터 앞).
  • ✅ Brazil2026ElectionFilter의 계정 리스트가 갱신됐다. 정기 갱신 관행이 정착했다는 신호다. 필터 시맨틱은 바뀌지 않았다(팔로우 예외 유지).
    • TSE dadosabertos 2026-08-25 판을 반영했다(+3,379줄, 파일 전체 4,947줄).
    • README에도 명시했다: "(Account list updated August 25, 2026.)".
  • ✅ phoenix-rankall에 prefloor 스냅샷이 연결됐다. 신규 후보는 min_age 문턱을 넘기 전에는 별도 pool에 머물다가, 통과하면 곧바로 프로덕션에 반영된다.
    • 변경 파일은 store/base.rs(+202/−61)와 config/mod.rs(+51/−12)다.
    • min_age가 있는 window에 대해 prefloor_<window>.parquet 파일을 함께 로드한다(PREFLOOR_SLACK_SECS = 6.0 * 3600.0, 6시간 여유).
    • 4세대+2의 favoriteCount>=1 게이트 제거와 방향이 같은 freshness 개선이다.
  • ✅ Phoenix copy_port의 rate-limit이 정확해졌다. 이전에는 순차 폴링으로 실제 in-flight concurrency가 상한을 넘을 수 있었다. 4세대+7의 restore path 보강과 짝을 이루는 서빙 쪽 보강이다.
    • copy_port_client.rs(+155)에서 join_all(futures)가 join_all(futures.map(tokio::task::spawn))로 바뀌었다.
    • 이제 tokio 스케줄러가 진짜 병렬로 실행하고, rate limit은 in-flight 카운트를 기준으로 다음 배치를 낸다.
    • 회귀 테스트는 2개다(rate_limit_caps_in_flight_despite_spawn, rate_limit_paces_between_spawned_batches).
  • ✅ README의 Latest Updates가 Notable Updates로 바뀌었다. 매일 후속 커밋이 붙는데 README를 매일 갱신하지 않겠다는 선언이다 추정.
  • ✅ 홈믹서 크론 미러(sync 2026-08-25)에서 가중치 두 개와 광고 브랜드 안전 V2 스위치가 바뀌었다 확인 — home-mixer/params/param.rs:342, 356, 908.
    • 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 옵션).
    • admission.reset_estimates가 서빙 admission controller의 EWMA를 zero로 리셋한다.
    • copy_port_client 로깅에 GB/s를 함께 적는다.
  • ✅ trainer_recsys의 valid_step 게이팅이 통일됐다. 이제 dense·embedding 중 어느 한쪽이라도 실패하면 양쪽 다 원본을 유지한다. Muon+GB300에서 dense·emb grad 스케일 divergence의 잔여 영향을 원천 차단한다.
    • dense sync와 async_no_deferred 두 경로에서 param update 순서가 재정렬됐다.
    • 이전에는 dense params가 dense grad-norm 검사만 통과하면 갱신되고, emb grad-norm이 실패하면 emb table만 롤백돼 몇 스텝 동안 비대칭 상태가 생길 수 있었다.
    • 통일된 조건은 keep_step = valid_step & emb_valid_step이다.
    • async 경로도 AsyncEmbGradientUpdate.pending=keep_step으로 동기화했다.
    • _create_async_emb_executables의 grad_norm_keep_threshold is None assert가 제거됐다(async에서도 threshold를 실제로 소비한다).
  • ✅ grox reply_spam의 LM 프롬프트에 팔로워 카운트와 bio를 넣는 옵션이 생겼고, Manhattan 쓰기가 monotone-only가 됐다. 답글 랭킹 스코어가 시간에 따라 낮아지기만 하고 오르지 않아 label flapping을 원천 제거한다.
    • PostRenderer.render에 include_follower_count, include_bio 두 옵션이 추가돼, 유저 메타를 Handle / Follower Count / Name / Bio 4단으로 나눠 렌더링한다.
    • ThreadRenderer._render_thread는 include_follower_count가 켜지면 스레드의 모든 포스트에 팔로워 카운트를 넣고, bio는 post 0(원본)만 렌더링한다.
    • # Reply Author Info 헤더로 판정 대상을 명시한다.
    • _publish_to_reply_ranking_manhattan은 ReplyRankingScoreStratoLoader.fetch_reply_ranking_score로 기존 스코어를 조회한다. 새 스코어 > 기존이면 쓰기를 건너뛴다(higher_than_existing 카운터).
  • ✅ VF TesHydrator에서 media fallback cache가 전부 제거됐다. 4세대+8의 VMRanker 대폭 단순화와 방향이 같은 "안정화 뒤 안전망 걷어냄" 흐름이다(변경 이력의 4세대+9).
    • 292줄이 삭제됐다. 1M-entry LRU, serve-stale/shadow 모드, 두 env 플래그(VF_MEDIA_FALLBACK_CACHE_SERVE_STALE_ENABLED, ..._POPULATE_ENABLED)가 모두 사라졌다.
    • VF 재작성 초기의 안전망이, 4세대+7 reference_compare 하니스로 정합성을 감사할 수 있게 되고 TES가 안정된 뒤에 걷혔다는 흐름이다.
  • ✅ dark_traffic이 canary 워크로드를 미러링하지 않게 됐다. canary 이슈가 shadow verdict를 오염시키는 사고를 방지한다.
    • should_enable이 should_mirror(workload, ordinal, max_ordinal)로 바뀌었다.
    • workload=None이면 fail-closed이고, -canary 접미사가 붙은 워크로드는 mirror하지 않는다.
  • ✅ 광고 conversion 헤드 3종이 proto에 새로 생겼다. 광고주 픽셀 발화를 랭커가 직접 예측하고, 픽셀과 long dwell의 결합도 별개 헤드로 둔다.
    • proto ActionName 191 ADS_PIXEL_FIRE, 192 ADS_LONG_DWELL_AND_PIXEL_FIRE, 193 P_LONG_DWELL_AND_PIXEL_FIRE다.
    • constants.py·recsys_model.py에는 아직 반영되지 않았다(proto 신설만). 학습·서빙 연결은 다음 커밋에서 볼 관측 포인트다.
  • ✅ 광고 asset 재사용 이력이 서빙 요청에 실린다. 같은 크리에이티브가 여러 광고주에 노출되거나 재노출된 케이스를 attribution한다.
    • PredictNextActionsRequest.conv_asset_ids가 연결됐다. 새 ConvAssetIds message(impressed_time_ms / author_id / asset_id 3-array)가 필드 18로 추가됐다.
  • ✅ home-mixer 로컬 SlateContext에 재구성 metrics용 필드 두 개가 추가됐다. served_slate_context 재구성 metrics를 세분화한다. proto 이관은 대기 중이다.
    • 필드는 SlateContext.recon_count_above와 recon_gap_above이고, 둘 다 Option<u32>다.
  • ✅ retrieval 유저 타워에 use_history_segment_ids 플래그가 새로 생겼다. 기본은 꺼져 있다.
    • 기본값은 RecsysTwoTowerModelConfig.use_history_segment_ids=False다.
    • user_tower_segment_ids(B, T, ...)가 켜지면 히스토리 토큰에 zeros 대신 HISTORY_SEGMENT_ID를 부여한다.
    • pallas varlen flash-attention의 segment mask가 유저 히스토리 세그먼트를 다른 특수 토큰(예: user_features 토큰)과 격리한다.
  • 심층 분석 필요 (신규):
    • save_concurrent_gb 프로덕션 튜닝 값과 저장 시간 증가폭
    • load_checkpoint_streamed가 기본 load_checkpoint를 언제 대체하는지(현재 두 함수 병존)
    • warm_start_staging_spec의 첫 활성 config(flagship에 이미 반영됐는지)
    • admission reset_estimates의 실제 호출 트리거(hot-swap 완료? traffic shift 감지?)
    • Manhattan reply_ranking monotone-only write가 실제 label flap 관측을 뒤집는다는 데이터
    • VF media fallback cache 제거 후 TES 실패 시 media feature fail-close 비율
    • ADS_PIXEL_FIRE·ADS_LONG_DWELL_AND_PIXEL_FIRE의 학습·서빙 연결(constants.py 반영 시점)
    • conv_asset_ids의 실제 소비 지점(recsys_model에서 어떤 헤드가 이 asset 이력을 쓰는지)
    • use_history_segment_ids의 프로덕션 승격 시점(flagship에서 True로 바뀌는 커밋)
    • recon_count_above/recon_gap_above의 계산식과 proto 이관 시점

4세대+10 공개로 해소된 것 / 신규 (2026-08-28)

2026-08-28 커밋(4세대+10)에서 광고 블렌더 신설, VF 안전 라벨 캐시 워머, Phoenix 체크포인트 복원과 서빙 수정이 공개됐다 확인(블렌더, 워머, Phoenix). 롤아웃 비율, 초기 설정값, 프로덕션 지표 같은 운영 값은 새 질문으로 남았다.

  • ✅ 광고 블렌더에 네 번째 옵션인 MultiRiskAdsBlender가 생겼다. 광고의 BSR 등급과 포스트의 위험 등급을 조합한 인접성 매트릭스로 광고 슬롯을 정한다.
    • 기존 옵션은 safe_gap·partition_organic·time_gap이고, 이번에 multi_risk가 더해졌다.
    • 광고는 BsrNormal/BsrHigh 2단, 포스트는 Safe/MediumRisk/HighRisk 3단으로 나눈다.
    • BSR-high 광고에게만 medium-medium 슬롯을 허용한다. 광고주가 medium-risk 컨텍스트 노출을 opt-in하면 노출 총량이 늘어난다.
    • HighRisk 포스트는 어떤 광고와도 인접할 수 없다.
    • 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가 같은 방식으로 통일됐다.
    • replicated ownership은 in-order를 유지한다.
    • 4세대+7의 노드-serialize·concurrent_gb 하드닝을 peer 축까지 확장한 변경이다.
  • ✅ home-mixer에 polling 요청을 관측하는 카운터 3종이 생겼다. 정기 polling refresh와 유저의 초기 요청을 서빙 지표에서 나눠 볼 수 있다.
    • 새 파일 home-mixer/util/strato_context.rs(신규 75줄)가 stratocontext[-bin] metadata에서 StratoContext.is_polling proto 필드를 뽑는다.
    • response_stats_side_effect.rs에 stat_request_context 함수가 연결됐다.
    • 요청마다 3종 카운터를 올린다.
      • <feed>.request.polling{polling=true|false, client}
      • <feed>.request.request_context{request_context, client}
      • <feed>.request.client{client, client_version}
  • ✅ 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를 다루고 있다. 이번에는 테스트만 추가됐고, 목적은 시맨틱을 잠그는 것이다.
  • ✅ Brazil2026ElectionFilter의 계정 리스트 주석이 다시 정리됐다. 필터 시맨틱은 그대로다(팔로우 예외 유지).
    • 이전에는 665개 계정을 no live account was found / deleted account 등 단일 반복 주석으로 나열했다.
    • 이번에는 10개의 "not the candidate's actual account" 판정과 다수의 no live account found로 다시 정리했다.
    • 4세대+8의 리스트 갱신 관행과는 별개로, 이번 변경은 문서와 주석의 톤을 정리한 것이다.
  • ✅ simclusters의 SimClustersInterestedIn2020 스냅샷 읽기에 폴백이 연결됐다. 배치 잡이 스냅샷 결측에 더 관대해졌다.
    • EntityToSimClustersEmbeddingsJob.scala에서 InterestedInSources.simClustersInterestedIn2020Source(dateRange, timeZone) 헬퍼를 직접 인라이닝했다.
    • 인라이닝한 코드는 세 부분으로 이뤄진다.
      • DAL.readMostRecentSnapshot(..., dateRange.prepend(Days(28)(timeZone)))
      • ExplicitLocation(ProcAtla)
      • fallbackPath = "viewfs://.../simclusters_v2_interested_in_20M_145K_2020/_TMP_RECOVERY/{timestamp}"
    • 28일 룩백, 특정 데이터센터 pin, 임시 복구 경로 폴백을 함께 쓴다.
  • 이 커밋으로 새로 생긴 심층 분석 질문은 다음과 같다.
    • multi_risk 블렌더의 experiment 롤아웃 비율과 BSR-high opt-in 광고주 비중
    • 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세대+7 reference_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 노출을 훔치는 시간축 담합 스팸"이다.
    • LM 출력에는 3단 안전장치가 있다: newest reply not flagged → drop, other-author → drop, singleton → drop.
    • 스팸으로 판정하면 score = 0.0으로 강제 저장한다.
    • ReplyRankingTaskGenerator·RecoveryTaskGenerator 두 스트림에 plan이 주입된다.
    • Coordinated_spam(계정 담합)과 나란한 시간축 담합 축이 canon에 추가된다.
  • ✅ PTOS 심판 스택에서 카테고리마다 다른 모델을 쓰는 첫 실체가 확인됐다.
    • 4세대+7은 3개 카테고리(ChildSafety·ViolentMedia·IllegalAndRegulatedBehaviors)의 SafetyPtosPolicyCrossValidator에 단일 모델 EAPI_GROK_4_5_X_ALGO를 배정했다.
    • 이번 커밋에서 IllegalAndRegulatedBehaviors만 EAPI_GROK_4_1_FAST_X_ALGO로 이관됐다.
    • 이 호출은 Breaker 없이 직접 하고, 메트릭 이름도 ..._with_grok_4_1로 바뀌었다.
    • X가 심판 모델을 카테고리별로 따로 튜닝하는 정책이 명시화됐다.
  • ✅ Gemma 4가 IllegalAndRegulatedBehaviors의 first-pass로 승격됐고, Gemma 확률 dial이 제거됐다.
    • USE_GEMMA_CATEGORIES = {Spam, IllegalAndRegulatedBehaviors}가 됐다(이전 Spam만).
    • if random.random() < self.use_oai_gemma4_dial 확률 게이트가 아예 제거돼, 두 카테고리가 100% Gemma로 라우팅된다.
    • 지워진 dial 값은 4세대 공개부터 1.0이었으므로, Spam 쪽 동작은 바뀌지 않았다 확인 — 47c1bcd와 85ac72a의 classifier.py. 실제 변화는 IllegalAndRegulatedBehaviors가 Gemma로 간 것이다.
    • task_safety_ptos_policy.py도 재분류 트리거에 IllegalAndRegulatedBehaviors를 추가했다. 이로써 3단 스택(Gemma first-pass → deluxe cross-model → Grok 4.1-fast judge)이 완성됐다.
  • ✅ Phoenix 서빙에서 SID retrieval 사이드카가 완전히 제거됐다. 이제 히스토리 SID는 요청에 실려 들어온다.
    • 삭제된 것은 다섯 가지다.
      • sid_client.rs(189줄)
      • sid_retrieval_runner.py(17줄)
      • --sid_endpoint CLI
      • xai-recsys-sid-proto 의존성
      • PySemanticIdClient 파이썬 바인딩
    • 로그에는 "Parsing history SIDs from the request (sid_num_levels=%d); no sid_endpoint"라는 문구가 남았다.
    • SID codeword 조회 책임이 online serving 밖으로 옮겨졌다. home-mixer(또는 candidate hydration 층)가 request를 만들 때 이미 SID를 담아 보낸다는 계약이다.
    • 요청당 gRPC 호출이 하나 줄고, SID 인프라의 fault domain이 줄어든다.
  • ✅ Phoenix use_pinned_d2h의 기본값이 False에서 True로 바뀌었다.
    • D2H 전송 속도는 CUDA pinned host memory로 ~50 GB/s이고, JAX 기본은 ~3 GB/s다. 추론당 ~29ms를 절감한다.
    • 이 옵션은 4세대+6에서 도입됐고, 이번 커밋에서 기본값이 바뀌었다.
  • ✅ Phoenix 학습에 H100용 remat 정책이 생겼다. GB300 외에 H100에서도 학습이 도는 첫 코드 신호다.
    • 새 정책은 SAVE_H100_RECSYS = 32이고, custom_remat_policy에 새 분기로 들어갔다.
    • GB300과 같은 이름 리스트(attn_outputs·dense_outputs·gate_up_proj·query_heads·key_heads·value_heads 등)를 rematerialization 저장 대상으로 지정한다.
    • _H100_OVERRIDES 같은 hardware-specific config block은 이 커밋에 아직 없다.
    • X 학습 하드웨어가 GB300만이 아니라는 사실 자체가 canon에 새로 반영할 내용이다.
  • ✅ FA4 attention 커널이 가변 길이 히스토리를 네이티브로 지원하게 됐다.
    • build_dense_block_sparse_layout 시그니처에 hist_valid_len이 추가됐다.
    • layout 계산이 numpy에서 jax.numpy로 옮겨졌다(캐시 제거).
    • valid_block_upper·valid_block_lower 두 마스킹 필드가 추가돼, 커널이 partial-history block을 직접 처리한다. partial-history block은 tile 안 유효 토큰이 tile 크기 이하인 block이다.
    • 신규 유저와 짧은 세션에서 학습·추론 정확성이 개선된다.
    • 4세대+6 cutedsl_ranker_varlen_attn의 varlen 시맨틱이 히스토리 축까지 확장됐다.
  • ✅ visibility-filtering의 hydration과 safety-label 코드가 크게 단순화됐다. 800+ 줄이 순 감소했다.
    • 줄어든 파일은 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에 없음)
    • FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION = 1000 튜닝 이력
    • multi-step 판정 결과가 experiments에서 답글 랭킹에 어떻게 반영되는지
    • home-mixer 또는 candidate hydration 층에서 SID를 채우는 hydrator의 이름·소스(sid_client 제거 이후 SID를 request에 담는 지점)
    • SID codeword storage 백엔드(Manhattan? Strato? phoenix-rankall 자체?)
    • SAVE_H100_RECSYS의 첫 활성 config(어느 recipe가 H100에서 학습되는지)
    • _H100_OVERRIDES 등장 시점과 H100 vs GB300 batch/attn/remat 튜닝 차이
    • safety_label_source/codec.rs 206줄 삭제 실체(어떤 wire 포맷·라벨 소스를 걷어냈나)
    • EAPI_GROK_4_1_FAST_X_ALGO가 4.5 X_ALGO와 어떻게 다른지(latency·비용·정확도 trade-off)
    • 다음 카테고리 확장 후보(Spam·IllegalAndRegulatedBehaviors 다음 Gemma 승격 대상, ChildSafety·ViolentMedia 심판을 4.1-fast로 이관하는 시점)

↑ 목차

출처

xai-org/x-algorithm(2026, 공개 소스코드, Apache 2.0)의 git 로그는 2026-10-21 기준으로 직접 확인했다. 그 시점까지 4세대 이후 커밋은 46개이고, 가장 최근 커밋은 2026-10-20 커밋(4세대+45)이다.

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

아래 절과 표 행은 기준 커밋이 따로 정해져 있다.

기준 커밋 이 커밋을 기준으로 쓴 곳
e62790c 변경 이력의 4세대+36 소절의 줄 번호 인용
78460ca 변경 이력의 4세대+37 소절, 후보 소스 표의 PopularPostsSource 행, 가중치 표의 PostUnexploredWeight 행
35650fb 변경 이력의 4세대+38 소절, 피드 지면 절의 영상 캐러셀 항목, 랭킹 절의 dwell_time CREAD 단락, "최종 배치" 절의 캐러셀 항목
cb45b55 변경 이력의 4세대+39 소절
e1a0375 변경 이력의 4세대+40 소절, 가중치 표의 video_open·cont_dwell_time·react_with_video 행
17c96bf 변경 이력의 4세대+41 소절, 가중치 표의 favorite·not_interested 행
92f0918 변경 이력의 4세대+42 소절, 피드 지면 절 캐러셀 항목의 정렬 점수 하위 불릿
bc3948b 변경 이력의 4세대+43 소절, 후보 생성 절 PopularPostsSource 세부의 라벨 문장
862a123 변경 이력의 4세대+44 소절, 피드 지면 절 캐러셀 항목의 중복 제거 하위 불릿
74d49a4 피드 지면 절 캐러셀 항목의 미디어 중복 제거 불릿, 다양성 절의 토픽 새로움 측정 단락, Phoenix retrieval 인덱스 절의 6시간 영상 window 불릿

↑ 목차

변경 이력 (커밋별 상세)

4세대 공개 뒤에 나온 X 공개 저장소의 커밋마다 무엇이 바뀌었는지를 날짜순으로 남긴 기록이다. X For You의 현재 동작은 앞쪽 주제 절이 설명하고, 이 기록은 각 변화가 어느 커밋에서 들어왔는지를 보여 준다.

커밋 요약표

세대 레포 공개 언어 상태
4세대+1 같은 레포 @ c65aa17 2026-08-14 Rust/Python 브라질 선거 필터와 기본 off인 콜드스타트 Thompson sampling이 새로 생겼다(daily). +2,271/−32 (파일 21). Brazil2026ElectionFilter 신설(1,573줄, 계정 665개 하드코딩), 가중치 오해 해설을 param.rs·ranking_scorer.rs 주석으로 명시, 콜드스타트 Thompson sampling(off by default), Phoenix에 IS_STALE_POST14D·IS_AUTHOR_FOLLOWED_BY_VIEWER 등 bool 시퀀스 3개 신설, BDSM enforcement note 생성기 부분 공개(gate와 dominant head만), grox reply-spam 임계 15k→30k, phoenix-rankall에 video_14day SID window 추가
4세대+2 같은 레포 @ b089ce6 2026-08-17 Rust/Python 광고 브랜드 안전 판정에 V2가 생기고, 성인 콘텐츠 판정이 엇갈리면 Grok 4.5 심판을 부르는 태스크가 추가됐다(daily). +751/−162 (파일 34). 광고 브랜드 안전 compute_verdict_v2 신설(medium 14→16, low 3, Grok 라벨 6종 _V2 마이그레이션, strip_v1_grok_written 헬퍼로 V1 dual-write 라벨 필터 [PROMPT_OWNED_RULES = 1400/1410/1420/1500/1510/1610/1700]), v2_mirrors_v1_across_tier_matrix 검증 테스트, 성인 콘텐츠 판정 cross-validation 태스크(task_safety_ptos_adult_content_cross_validation.py 162줄 신설, safemodel↔PtOS 판정이 엇갈리면 Grok 4.5 심판 호출, 강등만 허용), phoenix-rankall-strato의 build1FavIndex·buildMMEmbMetadataDump에서 favoriteCount>=1 게이트 제거, 1fav_uec_count_race 카운터 신설
4세대+3 같은 레포 @ 11a71f8 2026-08-18 Rust/Python 광고 브랜드 안전 판정은 V2 라벨이 없으면 V1로 돌아가도록 바뀌고, VMRanker에 SID 필드가 연결됐다(daily). +208/−70 (파일 14). strip_v1_grok_written 하루 만에 삭제, V2_WRITTEN_LABELS 4개(GROK_SFA_V2·GROK_NSFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_EXPANDED_V2) 게이트로 V2 판정 시맨틱 재설계(V2 라벨 있으면 순수 V2, 없으면 V1 폴백), 새 테스트 v2_defers_to_v1_when_v2_has_not_ruled 추가; home-mixer/candidate_hydrators/vf_following_candidate_hydrator.rs 95줄 신규(reverse_chron_posts_pipeline이 이 hydrator를 씀, VF 결과만 남기고 다른 후보 필드는 ..Default::default()); home-mixer/scorers/vm_ranker.rs에 SID 필드 7개 연결(sid_known, sid_k_l1/2/3, sid_gap_l1/2/3); author_cold_start.rs의 count_tracked_ids가 tracked 필터와 author_id 카운트를 없애고 tweet_id를 항상 세도록 단순화; grox reply_spam/generators.py PostStreamTaskGenerator 삭제, ReplyRankingTaskGenerator에 PlanSpamComment·PlanCoordinatedSpam 흡수(단일 흐름 통합), task_filter.py follower 임계 30k → 60k; Phoenix postCreationEventForwarder v3 토픽 마이그레이션, Kafka 클러스터 이동, KeyValue(key=Long) 매핑; visibility-filtering/server_deps.rs에 Gizmoduck Strato 클라이언트 timeout 80ms 명시
4세대+4 같은 레포 @ aad7179 2026-08-19 Rust/Python Phoenix dense 파라미터의 optimizer가 Adam에서 Muon으로 바뀌고, Phoenix 검색 지면용 config가 생겼다(daily). +3,303/−651 (파일 79). phoenix/xrex/optimizers/recsys/muon.py(240줄 신규)와 dense_optim.py(47줄 신규)로 Phoenix dense-param optimizer가 Adam → Muon 전환(embedding·logits·norm은 Adam 하이브리드, muon_adam_patterns="embed,_emb,logits,vocab,norm,table"), base LR 1e-3 → 7.1e-4, muon_split_fused="qkv:128", muon_consistent_rms=0.2. 새 config xrecsys_search(Phoenix가 검색 지면으로 확장, vocab 100M/100M/30M, use_product_surface·condition_search_relevance_on_prompt). Proto에 RewardOutputs(values와 policyLogits)와 ScoreInfo.rewardRerankSlotProb 신설, link-open 확률 P10/P25/P50/P75/P90 5분위 헤드 신설. home-mixer/filters/following_viewer_muted_keyword_filter.rs(65줄 신규)와 candidate_hydrators/quoted_post_text_hydrator.rs(106줄 신규)로 Following 파이프라인 muted-keyword·quoted-text 하드닝. grox PTOS classifier.py에 Grok 4.6 internal 10% dial(_GROK_4_6_INTERNAL_DIAL = 0.1), prior_nsfw.py(34줄 신규)의 Manhattan lookup으로 재분류 건너뛰기. visibility-filtering/dark_traffic_setup.rs(109줄 신규)로 env-gated shadow deployment(xai-vf-shadow) 미러링. enable_stale_post·enable_is_author_followed_by_viewer·enable_is_author_following_viewer·enable_engagement_counts train-default ON 승격
4세대+5 같은 레포 @ d0cef2f 2026-08-20 Rust/Python 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 필드 신설
4세대+6 같은 레포 @ 28e414f 2026-08-21 Rust/Python Phoenix proto에 광고 conversion 액션 20개가 생기고, 샘플 출처별로 헤드를 나눠 학습하는 config가 추가됐다(daily). +856/−152 (파일 35). Phoenix proto ActionName 171–190에 광고 conversion 20 액션 신설: MACT(모바일앱 컨버전 트래킹) 6종 인앱 이벤트(purchase·add_to_cart·level_achieved·tutorial_complete·sign_up·custom) × click/view = 12개와 view-through conversion 8개(웹 컨버전). constants.py에 MACT_IN_APP_LOSS_ACTION_INDICES·VIEW_THROUGH_ACTION_INDICES·SOURCE_SPLIT_CONVERSION_HEAD_INDICES 3개 리스트와 새 metric group ads_slim(긍정 신호 3개, 부정 신호 4개, non-primary conversion click). NEGATIVE_FEEDBACK_HEAD_INDICES에 CLIENT_TWEET_SEARCH_QUERY_REFORMULATED 추가. models/recsys_model.py(+99/−1)에 split_head_training_by_source config와 sample_source batch 필드(is_delayed_feedback 컬럼에서 유입). fresh 샘플은 engagement head만, delayed 샘플은 conversion head만 학습(loss mask 갈림), fresh/delayed_clicked/delayed_non_clicked 메트릭 슬라이스, mact_in_app_loss_weight·train_view_through_heads·conversion_keep_mask 튜닝 노브. TRAINING.md·README.md 재작성: flagship ranking config와 home_direct_packed_nano가 프로덕션 Muon 레시피로 end-to-end 학습(nano조차 GB300 오버라이드 상속), 임베딩 rowwise AdaGrad에 accumulator half-life decay, lazy per-row decay, decoupled weight decay 세 옵션 포함. _GB300_OVERRIDES 신설: bs_per_device=512·ep=64·attn_impl="cutedsl_ranker_varlen_attn"·remat_policy=RematType.SAVE_GB300_RECSYS·unroll_layer_stack=True·LR 5e-4·checkpoint_every_n=300·Muon config. phoenix/crates/storage/xai-o2/(154줄 신규): BaseO2Client trait와 15 builder method 인터페이스 공개. 다만 build()가 오픈소스 빌드에서 항상 "the xai-o2 backend is not included in this build; use the default object_store backend" 에러 반환(구현 미공개). Proto NextActionDistribution에 SlateContext slateContext = 9 필드 추가(action distribution마다 slate context). grox/flows/reply_spam/task_filter.py follower 임계 60k → 80k(4단계 중 1.33배). visibility-filtering/dark_traffic_setup.rs shadow → staging 전환: xai-vf-shadow.prod.visibility → xai-vf-service.staging.visibility:grpc, TLS 도메인 .prod. → .staging.. grox/flows/ptos/classifier.py Grok 4.5 internal 완전 제거(dial 0.3 → 100%, _eapi_4_5_internal_breaker·_sample_4_5_internal·_GROK_4_6_INTERNAL_DIAL 삭제). phoenix/xrex/train/trainer_recsys.py에 _forward_chunked(retrieval post-embedding 65k row 청킹). phoenix/xrex/inference/model_runner.py·launch_inference.py에 --hotswap_malloc_trim CLI와 _glibc_malloc_trim()(도움말: "glibc slots otherwise ratchet ~196 MiB/cycle. No-op under jemalloc"). phoenix/xrex/data/parquet_recsys.py·kafkaloader.py에 conversion_keep_mask·sample_source 패딩·exclude_required_columns 추가. xrecsys_two_tower.py에 qk_norm=True·attn_logit_cap=-1·right_anchored_rope=True 3옵션. EVERGREEN retrieval dataset 파일이 evergreen_video_1825day → video_4to14day로 축소(이름과 실체 어긋남, 임시적일 가능성). request_metrics.rs deadline_shed·inflight_cap 이중 카운트 수정. checkpoint_cloud.py·metadata.py에 orbax tmp-dir의 커밋 안 된 checkpoint를 건너뛰는 헬퍼
4세대+7 같은 레포 @ d011592 2026-08-24 Rust/Python 광고 브랜드 안전 판정에 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으로 로깅 노출
4세대+8 같은 레포 @ 0d3cdd8 2026-08-25 Rust/Python 스코어러가 요청한 조치를 AES가 검증해 실행하는 경로가 생기고, VMRanker는 rank server 점수를 그대로 통과시키게 됐다(daily). +5,850/−586 (파일 47). abuse-enforcement-service/service-lib/src/generic_actions.rs(663줄 신규), growthbook.rs(+126), lib.rs(+276/−19), rules.rs(+234): AES generic_actions 패스스루 신설. 스코어러가 requested_actions 리스트를 추가해 보내면 AES가 하드코딩 kind allowlist(user 5종·post 2종, MAX_REQUESTED_ACTIONS_PER_MESSAGE=16)와 GrowthBook 동적 allowlist(kinds·suspend_policies·labels)로 이중 검증 후 실행, 거부된 요청은 outcome.info["requested_actions_skipped"]에 JSON으로 남겨 관측성 유지. Decision::ActRequestedActions variant와 enforcement_user.yaml의 규칙 짝(platform_row_without_requested_actions skip, act_requested_actions act). 새 스코어러 유입 예: panda_reports_embedding_v10_rough_spam(embedding 기반 spam 검출기 v10, 임시 PlatformManipulation suspend). home-mixer/scorers/vm_ranker.rs(+11/−128): VMRanker 경로 대폭 단순화. AUTHOR_DIVERSITY_VALUE_MODEL_ID·MPN 폴드백 로직·AuthorColdStart 후처리 모두 삭제, 새 상수는 DPP_VALUE_MODEL_ID만. 이제 VMRanker는 rank server가 돌려준 점수를 그대로 통과시킨다. ranking_scorer.rs(+28/−201): effective_head_weights 메서드 88줄 삭제(client-side head weights 생성이 사라짐, 서버 측 자체 로직으로 이동). home-mixer/candidate_hydrators/following_blocked_by_hydrator.rs(65줄 신규), filters/author_socialgraph_filter.rs 재연결, candidate_pipeline/reverse_chron_posts_pipeline.rs(+27/−2): Following 파이프라인 블록 관계 확장. 뷰어→저자 방향 blocked/muted 외에 네 갈래를 더해 여섯 갈래로 필터링: 저자가 뷰어를 블록, quoted 저자가 뷰어를 블록, 뷰어가 quoted 저자를 블록, 뷰어가 retweeted 저자를 블록. SocialGraphClient가 check_blocked_by를 batch로 호출. home-mixer/filters/brazil_2026_election_filter.rs(+3,379/−5, 파일 전체 4,947줄): Brazil2026ElectionFilter 계정 리스트 갱신(TSE dadosabertos 2026-08-25 판 반영), README Notable Updates에도 "(Account list updated August 25, 2026.)" 명시. user_id는 obfuscated로 유지하고 @username은 그대로 노출(투명성/프라이버시 절충). phoenix-rankall/src/store/base.rs(+202/−61), config/mod.rs(+51/−12), tests/integration_test.rs(+92): rankall prefloor 스냅샷 연결. min_age 있는 window에 대해 prefloor_<window>.parquet 동시 로드, PREFLOOR_SLACK_SECS = 6.0 * 3600.0(6시간). 신규 후보가 min_age 문턱 통과 전에 별도 pool에서 유지되다가 통과 즉시 프로덕션에 반영. phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs(+155/−0): tokio::spawn 하드닝. join_all(futures) → join_all(futures.map(tokio::task::spawn))으로 rate limit이 순차 폴링 없이도 in-flight cap을 정확히 지키게 함. 회귀 테스트 2개(rate_limit_caps_in_flight_despite_spawn, rate_limit_paces_between_spawned_batches). README Latest Updates → Notable Updates 이름 변경. URT 마셜링 층(ad_marshaller +18, client_event +27, wtf_marshaller +14, reverse_chron_following +12)에 소소한 확장. grox upa/constants.py(+2)·generators.py(+11) 새 연결(내용 미확인). engagement_counts_hydrator +16
4세대+9 같은 레포 @ 45b48ba 2026-08-26 Rust/Python VF의 TES media fallback cache가 지워지고, canary 워크로드가 dark traffic 미러링에서 빠졌다(소절, daily).
4세대+10 같은 레포 @ 24c6094 2026-08-28 Rust/Python VF 안전 라벨 캐시 워머와 multi-risk 광고 블렌더(당시 기본 아님)가 생기고, 학습기가 streamed restore를 쓰게 됐다(소절, daily).
4세대+11 같은 레포 @ bc8e5f0 2026-08-28 Rust VF 규칙 파일이 정책 변화 없이 얇아지고 90건 골든 코퍼스가 생겼으며, Phoenix 요청에 리랭커 헤드 태그가 실렸다(소절, daily).
4세대+12 같은 레포 @ 6384ca7 2026-09-01 Rust/Python 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 랭킹과 직결 관계 미확인)
4세대+13 같은 레포 @ 7ba7768 2026-09-01 Rust/Python VF 규칙을 순수 리팩터로 재구조화하고, SID 재구성 임베딩·StableHLO 번들 export·암호화 체크포인트를 기본 off로 추가했다(daily). +2,964/−2,861 (파일 34). visibility-filtering rules 재구조화: 9개 삭제(nsfw_age_gating·nsfw_interstitial·nullcast_rule·socialgraph_rules·tes_rules·tweet_flag_rules·tweet_label_drops·user_label_drops·user_rules, 총 2,670줄) → 3개 신설(rule_spec.rs 89·tweet_rules.rs 1125·author_rules.rs 354). 규칙마다 struct + impl Rule이던 것을 공용 enum RuleSpec(Tweet/Author/Custom 3변형)로 바꾸고, Arc<NsfwGatingCountries>를 규칙 필드에서 RuleContext 필드로 이동해 규칙을 zero-sized 리터럴로 만들었다. 순수 리팩터: wired_rule_order_matches_pre_migration_sequence 테스트가 홈 28개와 recs 26개 규칙의 이름 순서를 리터럴로 잠근다. phoenix/xrex/models/recsys_sid.py(72줄 신규): 오프라인 .safetensors에서 RQ-VAE 형태 stacked residual codebook(stages: (num_levels, codebook_size, code_dim))과 2-layer MLP decoder를 로드해 랭커 post tower에 dense reconstruction을 주입, stop_gradient, L2 정규화, 학습 가능 project 하나. RecsysAggregatedModelConfig.sid_embedding_mode: Literal["learned", "recon"] = "learned"·sid_decoder_path: str = "" 게이트, 기본 off. phoenix/xrex/train/recsys_bundle_export.py(634줄 신규): 랭킹 forward를 jax.export로 낮춰 StableHLO MLIR bytecode(.mlirbc), .jax_export, MANIFEST.json으로 export, batch bucket(export_bundle_bs_per_device="1,2,4")마다 별도 프로그램, target ("cuda",). 임베딩 조회는 export 그래프 밖(입력이 pre-gathered merged_embeddings), MANIFEST.embedding이 shard·width·slice·hash table 지오메트리를 담아 서빙 임베딩 서버 조회 결과를 서빙 측이 concat. export_stablehlo_bundle: bool = False로 기본 off. phoenix/python/training/xai-checkpointing/xai_checkpointing/orbax_encrypted.py(288줄 신규): envelope-encrypted Orbax writer, 매 파일이 b"XAIENC01" 매직 헤더로 시작해야 하고 assert_all_enveloped가 finalize 후 평문이 남으면 커밋 거부, KMS 발급 DEK(_DEK 파일)와 AES 청크(기본 8 MiB). 암호화 체크포인트에서 restore 후 평문 저장은 명시적으로 거부(_restored_encrypted 플래그). CheckpointConfig.encrypt=False로 기본 off. DPA proto DpaProductInfo에 repeated int32 sid_codes = 8 추가: 광고 DPA 제품 SID가 서빙 wire format에 실린다(유기 post SID와 별개). 액션 enum에 reserved 214 to 252로 39자리 예약. mm_embedding_client zero-copy 리팩터, search_query_embeddings를 non-padding real candidates에만 반복 기록(전 후보 복제 폐기). grox mm-emb Kafka publisher가 MultiRegionKafkaProducer로 마이그레이션
4세대+14 같은 레포 @ 85ac72a 2026-09-02 Rust/Python home-mixer가 Phoenix backbone 점수를 요청하기 시작했고, scarecrow 봇이 XReview 큐에 리포트를 넣는 경로가 생겼다(소절, daily).
4세대+15 같은 레포 @ 9b0dc31 2026-09-04 Rust/Python grox에 스레드 단위 답글 스팸 경로가 생기고, PTOS 불법·규제 카테고리가 Gemma와 Grok 4.1-fast로 옮겨 갔다(소절, daily).
4세대+16 같은 레포 @ 902a06f 2026-09-04 Rust/Python 콜드스타트 실험 두 arm의 impression 기록이 대칭이 되고, Phoenix 요청에 실험 param override가 생겼다(소절, daily).
4세대+17 같은 레포 @ 49815da 2026-09-08 Rust/Python 행동별 가중치 perturbation(기본 0.0)과 30초 이상 영상의 앞 10초 재판정 경로가 생겼다(소절, daily).
4세대+18 같은 레포 @ 75d93d9 2026-09-09 Rust/Python VF가 명시적 조치 값으로 후보를 거르게 됐고, 여섯 번째 광고 포맷과 라이브 방송 PTOS 경로가 생겼다(소절, daily).
4세대+19 같은 레포 @ fee1d0f 2026-09-10 Rust/Python VF의 TES·Gizmoduck 호출 timeout이 390 ms로 통일되고, TES가 실패해도 작성자 규칙을 판정하게 됐다(소절, daily).
4세대+20 같은 레포 @ 6bb4594 2026-09-12 Rust/Python VMRanker DPP가 seed 포스트와 가까운 후보의 이득을 깎게 됐고, 세계 단위 bystander 신고가 자동으로 걸러지지 않게 됐다(소절, daily).
4세대+21 같은 레포 @ 2d4a03c 2026-09-15 Rust/Python 응답의 다양성을 세 단계에서 재는 관측 경로가 생기고, 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
4세대+22 같은 레포 @ fad2f71 2026-09-16 Rust/Python 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 지정, EnableAdsBrandSafetyVerdictV2 true → 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줄, 동작 무변화)
4세대+23 같은 레포 @ 42266f3 2026-09-17 Rust/Scala fav 수 구간별 홀드아웃 필터가 기본 off로 생기고, 콜드스타트 포스트 나이 상한의 기본값이 24시간에서 48시간으로 늘었다(daily). +2,288/−3,627 (파일 64). home-mixer/filters/fav_holdout_filter.rs(166줄 신규): fav count 계단별 홀드아웃 필터가 InventoryHoldoutFilter 다음(파이프라인 마지막)에 연결. 10K+ fav = 15%, 5K+ = 15%, ..., 100+ = 7%, 20+ = 5%, 2+ = 3%, 1+ = 2%, ≤1 = 0%(23-tier 계단 테이블). 홀드아웃 판정은 InventoryHoldoutFilter::is_held_out(post_id, viewer_id, percent) 재사용(splitmix64 계열 이중 mixing 해시로 결정론적). Param EnableFavHoldout=false(기본 off). home-mixer/scorers/ranking_scorer.rs(+10/−32): 4세대 공개부터 있던 EnableClickDwellLowFavRatePenalty와 4개 튜닝 param(baseline·alpha·floor·cap)이 모두 삭제되고, 그 자리에 새 파라미터 하나 EnableCdwellOnImpr(기본 off)가 들어옴. 켜지면 click_dwell_term = click_dwell_time × click_score(즉 클릭 확률·dwell 곱)로 계산. 게이트 라벨도 gate.click_dwell_low_fav_rate_penalty → gate.cdwell_on_impr. home-mixer/scorers/author_cold_start.rs(+7/−50): cold-start scorer의 Treatment 팔에서 is_phoenix_moe(candidate) AND 게이트가 제거되고(corpus == Treatment만 확인), 별도 cold_start_freshness_eligible(Treatment 팔에만 max_age 적용)이 삭제되고, apply_cold_start가 모든 팔에 대해 duration_since_creation_opt(c.tweet_id).is_some_and(|age| age <= params.max_post_age)를 필터에 걸도록 통합. ColdStartMaxPostAgeSecs 기본 86400 → 172800(24h → 48h). Phoenix proto ActionName: LLM_JUDGE_RELEVANT_TO_SEARCH=107·LLM_JUDGE_NOT_RELEVANT_TO_SEARCH=108(4세대+20 도입)를 5일 만에 비우고, 그 자리에 CLIENT_TWEET_LIVE_STREAM_REPLY=107·RESERVED_108. 광고 delayed conversion attribution 3-tier로 확장(20종 신설, 214-233): 214-223 *_DELAYED_TIER_2 10종(ADS_ATTRIBUTED_KEY_CLICK·ADS_ATTRIBUTED_CLICK·ADS_PURCHASE·ADS_MID_FUNNEL·ADS_ADD_TO_CART·ADS_UPPER_FUNNEL·ADS_WEB·ADS_SEARCH·ADS_SIGN_UP·ADS_CHECKOUT_INITIATED), 224-233 *_DELAYED_TIER_3 대칭 10종. reserved 214 to 252 → reserved 234 to 252. VF twemcache 서브트리 전체 제거(2,900줄): visibility-filtering/twemcache/ 아래 11개 파일 삭제 후 safety_label_source/twemcache.rs가 공용 crate xai_cache::{CacheClient, KVCacheError, Key, Value}에 rebase. FallbackReason 매핑 재정렬(KVCacheError::Timeout(_) → Timeout, Backpressure → Backpressure, _ → Other). VF evaluate_tweets RPC와 Thrift Action payload 디코더 신설: visibility-filtering-client/evaluated.rs(122줄 신규)이 decode_action(bytes) → Result<Action>을 노출. 64KB payload 상한, Compact Thrift, trailing byte 금지, Action::NotEvaluated 거부. EvaluationResult { Evaluated(Box<Action>), NotEvaluated, Failed }. VisibilityFilteringService 트레이트에 async fn 추가(스텁은 Err(Status::unimplemented("unused"))). UTH 일일 takedown 라벨 재현 잡 스택: UthDailyPostTakedownLabelsJob.scala(283줄)·TakedownDeltas.scala(169줄)·TakedownLabels.scala(103줄)·TakedownReplay.scala(80줄) 신규, underTheHoodLabels.strato(+164), UthPctdAccountTakedownEventsJob.scala(+392/−63), UthUserMonthMhPublisherJob.scala(+140/−34). Takedown 사유 편집 필터: takedowns/gizmoduck/RedactUnenforcedTakedownsFilter.scala(81줄 신규), ReadFilters.scala(39줄), tweet-entity-service/TakedownReasonsTransform.scala(73줄)·TakedownReasonsColumn.scala(25줄). Phoenix serving/train 소폭 조정 8개 파일. param.rs mirror sync 2026-09-15T16:25:02Z → 2026-09-16T16:23:12Z
4세대+24 같은 레포 @ c279172 2026-09-18 Rust/Python 광고 delayed feedback 설정 세 개가 기본 off 스위치 하나로 합쳐지고, grox에 영상 스팸 클론 비교 흐름이 생겼다(소절, daily).
4세대+25 같은 레포 @ 8b25829 2026-09-18 Rust/Python home-mixer의 dwell-regret 모드와 GateModel이 롤백되고, Phoenix retrieval에 콜드스타트 슬라이스가 연결됐다(daily). +2,877/−1,828 (파일 41). 홈믹서 gated_dwell_regret 스코어링 모드와 GateModel 전부 롤백: home-mixer/scorers/value_model_gate.rs 파일 전체 삭제(537줄, 19-피처 유저 게이트 로지스틱: seq_len·n_fav·n_reply·n_rt_quote·n_vqv·n_click·n_bm_share·n_profile_follow·n_photo·n_negfb·n_7d·n_1d·active_days·active_days_7d·days_since_last·span_days·followers·followings·account_age_years). ranking_scorer.rs(+12/−465)에서 DwellRegretWeights·compute_dwell_regret_base_scores·centered_ratio·sigmoid 헬퍼·use_dwell_regret 분기·회귀 테스트 7개 전부 삭제. param.rs(+8/−110)에서 ValueModelMode와 DwellRegret* 파라미터 17개(Temperature=10.0·DwellFloor=1.0·AlphaFavorite/Reply/Retweet/Quote/Share/ShareViaDm/ShareViaCopyLink=1.0·NegNotInterested=-10000·NegBlockAuthor=-8000·NegMuteAuthor=-15000·NegReport=-60000·GateWeights·GateBias=1.033918·GateThreshold=-0.634264·GateHysteresisBand=0.0) 전부 삭제. 홈믹서 스코어링은 다시 weighted 단일 경로. Phoenix retrieval cold-start 슬라이스 정식 연결: 새 param PhoenixColdStartMaxResults(feature switch rust_home_mixer_phoenix_cold_start_max_results, 기본 0)가 생기고, phoenix_source.rs(+67/−19)가 retrieval RPC에 두 상한(PhoenixMaxResults·PhoenixColdStartMaxResults)을 함께 실어 보내고, 응답 후보의 dataset_type: u32== HOME_COLD_DATASET_TYPE(=13)이면 served_type이 새 enum ForYouPhoenixRetrievalCold로 매핑됨(그 외는 기존 ForYouPhoenixRetrieval). scored_stats_side_effect.rs(+25): 새 함수 record_phoenix_retrieval_cold_stats가 PhoenixRetrievalColdTweets 접두로 selected/non-selected 크기 통계 발행. MoE inference cluster ID Experiment2Memy04 → Experiment3Memy04 롤포워드. Phoenix bundle export의 two-tower retrieval 경로 신설: phoenix/xrex/train/recsys_bundle_export.py(+559/−107)에 새 상수 KIND_RANKING="recsys_ranking_forward"·KIND_RETRIEVAL="recsys_retrieval_forward", RetrievalExport dataclass(large_k·target_dataset_types·use_async_topk·use_radix_select_topk·post_table_shape/dtype), wire key 4종(POST_TABLE_KEY="post_embeddings.embeddings"·POST_DATASET_TYPES_KEY="post_embeddings.dataset_types"·POST_IDS_KEY·AUTHOR_IDS_KEY), _make_retrieval_forward_fn(user tower → post table 전체 대비 target dataset type별 top-K), _user_tower_sequence_len(retrieval은 candidate 축 없음, prefix와 history만) 추가. mesh_devices: int = 1 인자로 retrieval SPMD export 지원(ranking은 여전히 single-device 강제, seqpack과 two_tower 조합 금지, enable_int8_post_table·enable_bloom_filter·enable_topic_filter 미지원). build_bundle이 RecsysTwoTowerModelConfig까지 받음. VF evaluate_tweets gRPC 서버 구현: 새 파일 visibility-filtering/evaluate_tweets.rs(+258)이 EvaluateTweetsEndpoint::handle을 구현: 3개 safety_level(FILTER_ALL·TIMELINE_HOME·TIMELINE_HOME_RECOMMENDATIONS)만 허용, 요청을 FilterTweets::run으로 전달, 응답을 tweet별 Outcome으로 조립. server.rs(+86/−2)의 스텁 Err(Status::unimplemented("EvaluateTweets is not served yet")) → self.evaluate_tweets.handle(request).await로 교체, evaluate_tweets_loopback 통합 테스트로 gRPC 왕복 잠금. filter.rs(+336/−13): 새 enum EvaluationStatus { Evaluated, UnresolvedAuthor, Failed }가 FilterOutcome에 추가, HydrationOutput에 failed_ids: HashSet<TweetId> 필드로 hydration 실패 tweet 구별. TES visibility 필드 → Strato 컬럼 하이드레이터 이관: 새 파일 visibility-filtering/hydration/tes_composite.rs(+607)이 Strato 컬럼 tweetypie/federated/tweetForVisibility.Tweet(operation fetch)에서 11-필드 TweetForVisibility(author_id·source_tweet_id·is_nullcast·nsfw_user·nsfw_admin·has_takedown·takedown_reasons·media·is_community_tweet·edit_control·exclusive_conversation_author_id)를 MVal batch call로 조회. tes_hydrator.rs(+113/−320): 기존 TweetHydration 7-필드 struct(nullcast·community·nsfw_user·nsfw_admin·takedown_reasons·edit_control·media) 삭제, TweetHydrationBatch<TweetForVisibility> 하나로 통합. TES는 여전히 PureCoreData(author_id·text·source_tweet_id)를 담당하고, visibility 필드는 새 Strato 컬럼이 담당. hydration/mod.rs(+78/−22): Completeness<T> 래핑으로 부분 hydration 상태 노출. xrex_cuda_kernels 외부 패키지 분리: phoenix/xrex/cuda/{adler32,top_k_by_key,unique}/__init__.py 3개 커널 로더가 from xrex.cuda.<name>.src import → import xrex_cuda_kernels.<name>_api로 재연결, ModuleNotFoundError 시 zlib pure_callback 참조 경로 fallback과 logging.warning. grox media_injected_adult_infrared_video_spam_detection fav-count reasoning 게이팅: task_safety_ptos_media_injected_adult_infrared_video_spam_detection.py(+14/−9)에서 새 상수 MEDIA_INJECTED_REASONING_FAV(값 미공개) 이상이면 _strip_thinking_restrictions(prompt)와 THINKING_CONTROL_END 마커 비움으로 reasoning 모드 활성. param.rs mirror sync 2026-09-17T20:46:44Z → 2026-09-18T16:21:20Z
4세대+26 같은 레포 @ 3aa0fa33 2026-09-22 Rust/Python 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다.
4세대+27 같은 레포 @ 1b3fec20 2026-09-23 Rust/Python 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 클러스터 상수가 교체됐다.
4세대+28 같은 레포 @ 44d37eb 2026-09-24 Rust/Python 홈믹서의 랭킹 스코어러 파일 전체가 삭제되고, 전날 인라인으로 호출하던 공유 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 소스 분포를 관측한다.
4세대+29 같은 레포 @ bf7db1b 2026-09-25 Rust/Python VF 하이드레이션이 데이터 테이블과 스케줄러 구조로 다시 쓰였고, 뷰어 국가를 About This Account에서 조회하게 됐다(소절, daily).
4세대+30 같은 레포 @ 4c5cfe8 2026-09-26 Rust/Python abuse-enforcement에 overturn-hold 게이트가 생기고, Phoenix Cold 채널 후보가 cold-start Treatment 팔에만 노출됐다(소절, daily).
4세대+31 같은 레포 @ a707cc2 2026-09-29 Rust/Python overturn-hold ledger 서버가 공개되고, 클릭 가중치가 0.3으로 내리고 클릭 후 체류 가중치가 0.4로 올랐다(소절, daily).
4세대+32 같은 레포 @ 77d431a 2026-09-30 Rust/Python VF hydration이 키별 요청 상태를 기억하는 구조로 바뀌고, 홈믹서 cold-start 파라미터 6개가 크게 바뀌었다(소절, daily).
4세대+33 같은 레포 @ b79b947 2026-10-01 Rust/Python VF 판정 응답에 제한 사유별 막을 액션 목록과 문구가 담기고, 제한 사유가 여러 개 쌓일 수 있게 됐다(소절, daily).
4세대+34 같은 레포 @ 76843a5 2026-10-02 Rust/Java/Python generative SID retrieval의 microservice 클라이언트, 학습 코드, 기본 off 후보 소스가 함께 공개됐다(소절, daily).
4세대+35 같은 레포 @ b412112 2026-10-03 Rust/Python SID retrieval 서버 크레이트와 기본 off 인기 포스트 소스가 생기고, 광고 V2 판정 스위치가 다시 on이 됐다(소절, daily).
4세대+36 같은 레포 @ e62790c 2026-10-06 Rust/Python value model에 가중 0인 체류 초 항 3개가 생기고, 인기 포스트 소스가 오프라인 전역 목록을 읽게 됐다(소절, daily).
4세대+37 같은 레포 @ 78460ca 2026-10-07 Rust/Python 인기 포스트 소스가 기본 on이 되고, home-mixer 미러의 새-작성자 부스트가 0.015로 내려갔다(소절, daily).
4세대+38 같은 레포 @ 35650fb 2026-10-08 Rust/Python For You 세로 영상 캐러셀이 기본 off로 생기고, 체류 시간 헤드가 CREAD 손실로 학습하게 됐다(소절, daily).
4세대+39 같은 레포 @ cb45b55 2026-10-09 Rust/Python Phoenix 랭킹 요청에 피드 요청 맥락 필드가 생겼고(읽는 곳 없음), H100 홈 랭커 학습 프리셋이 바뀌었다(소절, daily).
4세대+40 같은 레포 @ e1a0375 2026-10-13 Rust/Python home-mixer 미러의 favorite·cont_dwell_time 가중이 오르고 video_open이 0.02로 내렸으며, retrieval에 체류 헤드가 생겼다(소절, daily).
4세대+41 같은 레포 @ 17c96bf 2026-10-14 Rust/Python immersive 지면용 페이지 RL 재순위기 학습 코드가 공개됐고(서빙 off), home-mixer 미러의 favorite·not_interested가 1.1배가 됐다(소절, daily).
4세대+42 같은 레포 @ 92f0918 2026-10-15 Rust/Python 영상 캐러셀에 VQV 예측 항(가중 0.0)이 생기고, 광고 작성자 브랜드 안전 fallback이 기본 on이 됐다(소절, daily).
4세대+43 같은 레포 @ bc3948b 2026-10-16 Rust/Python 인기 포스트 후보가 자체 라벨을 달게 됐고, VF는 보조 조회가 실패해도 판정을 완료로 친다(소절, daily).
4세대+44 같은 레포 @ 862a123 2026-10-17 Rust/Python 영상 캐러셀 중복 제거 3종이 기본 on이 되고, Who-to-Follow가 있으면 캐러셀을 넣지 않게 됐다(소절, daily).
4세대+45 같은 레포 @ 74d49a4 2026-10-20 Rust/Python 캐러셀이 메인 피드와 같은 미디어를 빼고, 토픽 새로움 측정이 기본 off로 생겼다(daily).

릴리스별 요약

4세대 공개(2026-08-13)에서 가중치 숫자가 공개된 뒤, 후속 커밋들은 광고 브랜드 안전 판정, Grok 기반 정책 판정, Phoenix 학습과 서빙, visibility-filtering을 여러 차례 고쳤다.

  • 2026-08-13 4세대 공개에서 가중치 숫자가 프로덕션 크론 미러 파일로 공개됐다. (daily)
    • 공개된 파일은 home-mixer/params/param.rs다.
    • 값의 예는 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)
    1. Phoenix 학습이 Adam에서 Muon optimizer로 전환됐다.
      • dense-parameter 기본 optimizer가 optax 기반 Muon(Newton-Schulz orthogonalization)으로 바뀌었다.
      • embedding·norm·logits는 여전히 Adam을 쓰는 하이브리드다.
      • 새 base learning rate는 7.1e-4, 이전 값은 1e-3이다.
    2. Phoenix가 검색 랭킹으로 확장됐다.
      • 새 config xrecsys_search가 등장했고, 별도 vocab(100M user/item, 30M author)을 쓴다.
      • history_seq_len은 1022이고, use_product_surface=True와 condition_search_relevance_on_prompt=True로 설정됐다.
    3. Phoenix proto에 RewardOutputs 메시지(values, policyLogits)와 rewardRerankSlotProb 필드가 신설됐다.
      • 이 둘은 reward-based 재순위를 위한 스캐폴딩이다.
      • link-open 확률 5분위 헤드(P10/P25/P50/P75/P90)도 추가됐다.
    4. Following-side 전용 FollowingViewerMutedKeywordFilter와 QuotedPostTextHydrator가 새로 생겨 Following 파이프라인 하드닝이 계속됐다.
      • 새로 생긴 두 코드는 순서대로 65줄, 106줄이다.
    5. grox PTOS에 Grok 4.6 internal 10% dial이 도입됐다(_GROK_4_6_INTERNAL_DIAL = 0.1).
      • 새 prior_nsfw.py도 신설됐다. Manhattan 조회로 이미 NSFW 플래그된 포스트는 재분류를 건너뛴다.
    6. visibility-filtering에 dark_traffic_setup.rs(109줄 신규)가 생겼다.
      • env-gated로 특정 ordinal pod만 요청을 xai-vf-shadow.prod.visibility 섀도 배포로 미러링한다.
    7. Phoenix 기본 학습 config _home_direct_packed_base()에서 네 피처가 train-default ON으로 승격됐다.
      • 네 피처는 enable_stale_post=True·enable_is_author_followed_by_viewer=True·enable_is_author_following_viewer=True·enable_engagement_counts=True다.
      • 2026-08-14 커밋(4세대+1)에서 값이 채워진 bool 3종이 이 커밋부터 학습 기본 입력이 됐다.
  • 2026-08-20 커밋(4세대+5)에서 For You 후보에 AI trend 라벨을 다는 hydrator가 생기고, 서빙이 산출한 slate context가 랭킹에 연결되기 시작했다. (d0cef2f, daily)
    1. AI trend feedback context hydrator가 신규로 생겼다(157줄).
      • For You 후보에 "AI trend" 라벨을 topic feedback과 나란한 두 번째 컨텍스트 채널로 붙인다.
      • top-2 trend를 후보 빈도로 뽑고, trend마다 후보 1개를 랜덤으로 고른다.
      • 랜딩 URL은 https://x.com/i/trending/{trend_id}다.
      • 대상 조건은 standalone original post, non-topic, non-in-network다.
    2. served_slate_context 연결이 시작됐다.
      • 2026-08-19 커밋(4세대+4)에서 준비된 proto를 실제 파이프라인이 소비하기 시작한 것이다.
      • PhoenixScorer가 예측 응답에서 candidate_slate_context()를 뽑아 세팅한다.
      • RankingScorer는 UseServedSlateContext 게이트에 따라 로컬 계산·캐시 대신 서빙이 산출한 slate context를 그대로 쓴다. 폴백은 세 갈래다.
      • 이 slate context에는 pool_rank, fatigue, pre_diversity, SID 3계층, SID gap 3계층이 담긴다.
    3. emb_table.rs가 리팩터됐다(+189/−169).
      • load_tensor를 pyo3-free core(load_tensor_into)와 Python wrapper로 나눴다.
      • tokio runtime의 unwrap()을 map_err로 바꿨다.
      • checkpoint path too short 방어를 넣었다.
    4. Grok 4.6 internal dial이 0.1 → 0.3으로 올랐다. 하루 만에 3배다.
    5. grok_sampler에 stop_predicate callback과 aclosing이 붙었다.
      • 스트리밍 조기 종료와 llm.sample.early_stop.count 카운터가 함께 들어갔다.
    6. grox User 스키마에 profile_image 필드가 신설됐다.
  • 2026-08-21 커밋(4세대+6)에서 광고 conversion 액션 20개와 delayed feedback의 학습 손실 분기가 생겼다. Muon optimizer는 export에 정식 포함됐고, Grok 4.5 internal은 완전히 빠졌다. (28e414f, daily)
    1. 광고 conversion 액션 20개가 proto ActionName 171–190에 신설됐다.
      • MACT 인앱 이벤트 6종 × click/view 2계열 = 12개다. 이벤트는 purchase·add_to_cart·level_achieved·tutorial_complete·sign_up·custom이다.
      • 여기에 view-through conversion 8개가 더해진다: purchase/add_to_cart/checkout_initiated/sign_up/site_visit/session/landing_page_view/upper_funnel.
    2. split_head_training_by_source가 delayed feedback을 학습 손실에서 분기한다.
      • kafka에 is_delayed_feedback 컬럼을 흘리면 sample_source batch 필드로 들어온다.
      • 이때 engagement head는 fresh 샘플에만, conversion head는 delayed 샘플에만 학습하도록 loss mask가 갈린다.
      • fresh/delayed_clicked/delayed_non_clicked 메트릭 슬라이스가 신설됐다.
      • 튜닝 노브로 mact_in_app_loss_weight·train_view_through_heads·conversion_keep_mask가 노출됐다.
      • 새 metric group ads_slim은 긍정 3개, 부정 4개, non-primary conversion click으로 이뤄진다.
      • 부정 피드백에 CLIENT_TWEET_SEARCH_QUERY_REFORMULATED가 추가됐다.
    3. Muon optimizer가 export에 정식으로 포함됐다.
      • TRAINING.md와 README는 "flagship ranking config + home_direct_packed_nano가 프로덕션 Muon 레시피로 end-to-end 학습"으로 재작성됐다.
      • embedding rowwise AdaGrad에는 accumulator half-life decay, lazy per-row decay, decoupled weight decay 세 옵션이 포함됐다.
    4. nano config가 4세대 공개부터 있던 NVIDIA GB300 하드웨어 오버라이드의 LR·optimizer 설정을 물려받았다.
      • _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도 같은 오버라이드에 정의돼 있다.
      • home_direct_packed_nano가 이 오버라이드를 상속한다.
    5. xai-o2 스토리지 crate의 인터페이스가 공개됐다.
      • S3 호환 오브젝트 스토리지 클라이언트의 BaseO2Client trait과 15개 builder method가 노출됐다.
      • 다만 build()는 항상 "the xai-o2 backend is not included in this build" 에러를 반환한다. 구현은 공개되지 않았다.
    6. SlateContext가 NextActionDistribution의 필드 #9로 추가됐다(action distribution마다 slate context).
    7. grox reply_spam의 follower 임계가 60k → 80k로 올랐다.
      • 10일간 4단계 상향이 이어졌고, 이번은 1.33배로 완만하다.
    8. 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 서술이 재해석된다.
    9. 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이 전부 삭제됐다.
    10. retrieval post-embedding에 65k row 청킹이 들어갔다.
    11. hotswap에 --hotswap_malloc_trim 옵션이 붙어, 사이클당 ~196MiB 메모리 누수가 문서화됐다.
  • 2026-08-24 커밋(4세대+7)에서 VF 판정을 레퍼런스 VF와 매 요청 대조하는 스테이징 전용 하니스가 생기고, 광고 브랜드 안전 판정이 4단계로 늘었다. Grok 4.5는 PTOS 세 카테고리에서 강등만 허용하는 judge로 다시 들어왔다. (d011592, daily)
    1. VF 레퍼런스 비교 하니스가 새 파일 visibility-filtering/reference_compare.rs(812줄)로 생겼다.
      • 이 하니스는 Strato 기반 레퍼런스 VF인 StratoVfClient를 매 요청마다 병렬로 호출하고, 서비스가 산출한 verdict와 엄격 비교한다.
      • action과 reason이 완전히 일치해야 같다고 본다(is_exact_match).
      • 다르면 JSON 라인으로 로그를 남긴다. 라인 예산은 12KiB다.
      • 메트릭은 vf_reference_compared/exact_match/differed/error/skipped/enabled다.
      • 환경변수 VF_DUAL_CALL_HARNESS_ENABLED와 APP_ENV로 게이트한다. APP_ENV=prod면 부팅 시 panic을 일으켜 스테이징 전용을 강제한다.
      • dark_traffic(트래픽만 미러링)과 별개로 실제 verdict 대조 canary가 신설된 것이다.
    2. 브랜드 안전 판정이 4단계로 확장됐다.
      • BrandSafetyVerdict::HighRisk = 4가 신설됐다.
      • 기존 medium에 있던 5종이 HighRisk로 승격됐다: NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW.
      • 신규 SOFT_NSFW도 HighRisk다.
      • V2도 대칭으로 이동했다.
      • 광고 유틸 has_avoid()는 Medium과 High를 둘 다 avoid로 취급한다.
      • ads_brand_safety_vf_hydrator.rs의 NSFW ancestor 승격이 Medium → High로 바뀌었다.
      • scored_posts_server.rs가 V2 라벨 5종의 proto HM enum 매핑을 신설해 다운스트림에 노출한다. 라벨은 GROK_SFA_V2·GROK_NSFA_LIMITED_V2·GROK_NSFA_V2·GROK_NSFA_EXPANDED_V2·PTOS_REVIEWED다.
    3. PTOS 정책 판정 cross-validator가 3개 카테고리로 확장됐다.
      • 2026-08-21 커밋(4세대+6)에서 Grok 4.5 internal이 빠진 뒤, 새 심판 클래스는 4세대+2부터 성인 콘텐츠 심판이 쓰던 EAPI_GROK_4_5_X_ALGO를 쓴다. primary가 아니라 judge 전용이다.
      • 신규 클래스 SafetyPtosPolicyCrossValidator는 ChildSafety·ViolentMedia·IllegalAndRegulatedBehaviors 세 카테고리에서 primary verdict를 4.5로 재확인하고, 강등만 허용한다.
      • disagreement가 나면 NoViolation 또는 lighter 정책으로 바뀐다.
      • 2026-08-17 커밋(4세대+2)의 성인 콘텐츠 judge(SafetyPtosAdultContentCrossValidationJudge)와 나란히 존재한다.
      • USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm}와 USE_THREAD_RENDERER_CATEGORIES가 신설됐다.
      • 이 두 카테고리에서만 thinking 제약을 풀고, 부모 스레드까지 렌더러에 넣는다(맥락 없이는 위해 판정 불가).
    4. Phoenix 체크포인트 로더가 다중 노드 프로덕션에 맞게 하드닝됐다.
      • xai_checkpointing/load.py에 다음 인자가 신설됐다.
        • domains: per-tensor ts.IndexDomain 슬라이스
        • tag: 체크포인트 서브디렉토리, 기본 "orbax-ckpt"
        • concurrent_gb: 동시 디코딩 바이트 스로틀
      • XAI_RESTORE_NODE_SERIALIZE 환경변수(기본 on)는 노드마다 fcntl.flock()으로 restore를 직렬화한다. 경로는 /dev/shm/xai_restore_node_lock이다.
      • 배치 사이에는 gc.collect()와 malloc_trim(0)을 부른다.
      • Muon 세대 이후 Phoenix 모델이 커져 다중 워커 동시 restore가 호스트 OOM을 유발하는 문제에 대응한 것이다.
    5. 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).
    6. reply_spam follower 임계가 80k → 100k로 올랐다.
      • TaskSpamFilter와 TaskReplyRankingFilter가 둘 다 상향됐다.
      • 11일간 다섯 번(15k → 30k → 40k → 60k → 80k → 100k) 올라 스팸 판정 대상이 계속 넓어졌다.
    7. grox에 X Space(오디오) 렌더링이 들어갔다.
      • SpaceMetadata 클래스가 신설됐다. 이 커밋 시점에는 title만 있다.
      • Post에 space_metadata 필드가 생겼고, post_mapper.py가 Strato에서 이 값을 매핑한다.
      • lm/post.py 렌더러는 이를 오디오 스페이스 카드로 LLM 입력에 넣는다.
      • Grok 안전 판정이 오디오 스페이스가 붙은 포스트를 인식하기 시작했다.
    8. ads_injection_logging_side_effect.rs가 EnableAdsBrandSafetyVerdictV2 experiment의 버킷을 ddg_experiment_bucket으로 로깅에 노출한다.
      • DDG(광고 리포팅 파이프라인)가 V2 실험군을 추적하기 시작했다.
  • 2026-08-25 커밋(4세대+8)에서 스코어러가 요청한 액션을 abuse-enforcement-service가 이중 검증 뒤 실행하게 됐고, VM Ranker 경로가 크게 단순화됐다. quote·retweet을 거친 블록 우회도 Following에서 막는다. (0d3cdd8, +5,850/−586, 파일 47개, daily)
    1. 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).
      • 거부된 요청은 outcome.info["requested_actions_skipped"] JSON과 메트릭으로 명시적으로 관측된다.
      • 새 규칙 act_requested_actions와 skip 규칙 platform_row_without_requested_actions가 짝으로 연결됐다.
      • 첫 클라이언트는 panda_reports_embedding_v10_rough_spam(embedding 기반 spam 검출기 v10)이고, 액션은 임시 PlatformManipulation suspend다.
    2. VM Ranker 경로가 대폭 단순화됐다.
      • 다음이 모두 삭제됐다.
        • AUTHOR_DIVERSITY_VALUE_MODEL_ID
        • MPN 폴드백 로직(128줄)
        • AuthorColdStart 후처리
        • ranking_scorer::effective_head_weights(88줄)
      • 새 상수는 DPP_VALUE_MODEL_ID = "dpp" 하나뿐이다.
      • VMRanker는 rank server가 반환한 점수를 그대로 통과시키고, DPP 다양성은 서버 측 자체 로직이 담당한다.
      • 2026-08-20 커밋(4세대+5)의 slate context 이관과 짝을 이루는 아키텍처 리팩터링이다. home-mixer는 얇게 두고 gRPC 서버들이 계산을 맡는다.
    3. Following 파이프라인의 블록 관계가 확장됐다.
      • following_blocked_by_hydrator.rs(65줄 신규)가 각 candidate의 quoted/retweeted 저자 리스트를 SocialGraphClient에 batch로 check_blocked_by 조회한다.
      • author_socialgraph_filter.rs는 여섯 갈래로 필터링한다: 뷰어→저자 mute/block, 저자→뷰어 block, quoted 저자↔뷰어 block, retweeted 저자→뷰어 블록.
      • quote/retweet 체인의 원 저자까지 블록 관계를 존중한다. 즉 quote/retweet을 통한 블록 우회를 명시적으로 차단한다.
      • reverse_chron_posts_pipeline.rs(+27/−2)에 연결됐다.
    4. Brazil2026ElectionFilter의 계정 리스트가 TSE dadosabertos 2026-08-25 판으로 갱신됐다(+3,379줄, 파일 전체 4,947줄).
      • README에도 문구가 명시됐다: "(Account list updated August 25, 2026.)".
      • user_id는 obfuscated로 유지하고 @username은 그대로 노출한다(투명성/프라이버시 절충).
    5. phoenix-rankall에 prefloor 스냅샷이 연결됐다.
      • store/base.rs(+202/−61)와 config/mod.rs(+51/−12)가 바뀌었다.
      • min_age가 있는 window에 대해 prefloor_<window>.parquet을 동시에 로드한다. PREFLOOR_SLACK_SECS = 6.0 * 3600.0이다.
      • 신규 후보는 min_age를 통과하기 전에는 별도 pool에서 유지되다가, 통과하는 즉시 프로덕션에 반영될 수 있다.
    6. Phoenix copy_port rate-limit이 하드닝됐다.
      • copy_port_client.rs(+155)는 in-flight cap이 순차 폴링 없이도 정확하게 지켜지도록 join_all(futures)를 join_all(futures.map(tokio::task::spawn))로 바꿨다.
      • 회귀 테스트 2개가 새로 생겼다.
    7. README의 Latest Updates가 Notable Updates로 리네이밍됐다.
      • 매일 나오는 후속 커밋을 README에 매일 반영하지 않겠다는 선언이다 추정.
    8. 그 밖에 다음 변경이 있었다.
      • URT 마셜링 층에 소소한 확장이 있었다(ad_marshaller +18, client_event +27, wtf_marshaller +14, reverse_chron_following +12).
      • 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)
    1. NSFW 게이팅 대상 16개국이 처음으로 코드에 명시됐다.
      • visibility-filtering/params.rs(216줄 신규)가 ar au br ca de es fr gb id it kr mx nl ph pt th를 default로 담는다.
      • feature switch rust_vf_nsfw_gating_countries에서 30초마다 refresh한다.
      • refresh마다 sibling YAML country_specific_nsfw_content_gating.yml(Scala VF 스택)과 drift를 비교하고, nsfw_gating_countries_drift 카운터로 관측한다.
      • 잘못된 원소가 섞이면 부분 리스트가 아니라 리스트 전체를 default로 fallback한다.
      • SensitiveViewerNoStatedAgeDropRule은 이 국가 리스트를 참조해, 나이 미확인 뷰어에게 16개국에서만 age-gating을 강제한다.
    2. PlanSafetyPtosSpecialVideo 8단 DAG가 신설됐다.
      • 새로 생기거나 바뀐 파일에는 다음이 있다.
        • 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)
      • fav count가 deluxe_fav_count_threshold 이상인 영상만 진입한다(비용 관리).
      • motion_reveal_frames가 있으면 스크리닝에 걸린다.
      • motion-reveal은 정적 층을 빼고 미약한 움직임 층을 증폭한 프레임이다. 정적 오버레이(캡션·워터마크·투명 마스크)로 가려진 회피 콘텐츠를 노출한다.
      • Grok 4.5 심판(EAPI_GROK_4_5_X_ALGO)은 원본과 motion-reveal 프레임을 함께 받는다.
      • 프롬프트에는 "옷이 벗겨 보일 수 있으니 원본과 대조하라"는 rubric까지 들어 있다(MOTION_REVEAL_DESCRIPTION 상수).
      • 이미지 전처리 파이프라인은 CLAHE, adaptive brightness, saturation boost, sharpen으로 이뤄진다.
      • Special video 경로만 심판이 실패하면 fail-close로 Soft 강등한다. 다른 경로는 원 판정 유지가 default다.
    3. home-mixer/filters/brazil_2026_election_filter.rs(+222/−74)는 계속 유지 보수됐다.
    4. Phoenix serving/config 리팩터가 있었다.
      • phoenix/crates/common/xai-recsys/src/model_config.rs(+96/−2)와 mm_embedding_client.rs(+128/−55)에서 이뤄졌다.
    5. 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)
    1. 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다.
      • tweet_flag_rules.rs·tweet_label_drops.rs·user_label_drops.rs·user_rules.rs도 삭제됐다.
      • 새 규칙 파일은 tweet_rules.rs(1125)·author_rules.rs(354)·rule_spec.rs(89)다.
      • 규칙 목록과 순서는 그대로다. 회귀 테스트 wired_rule_order_matches_pre_migration_sequence가 홈 28개, recs 추가 26개의 이름을 리터럴로 검증해 순수 리팩터임을 잠근다.
      • Arc<NsfwGatingCountries>가 규칙 struct 필드에서 RuleContext 필드로 옮겨 가, 규칙이 zero-sized data literal이 됐다.
      • 부수적으로 tes_rules.rs가 홈 공통 규칙과 recs 전용 규칙으로 나뉘었다. 홈 공통은 stale/takedown, recs 전용은 DMCA/geo-restricted media다.
      • 이렇게 나뉘면서 recommendations 전용 정책 축이 명시적으로 드러났다.
    2. Phoenix 랭킹 모델에 SID recon 모드가 신설됐다.
      • phoenix/xrex/models/recsys_sid.py(72줄 신규)는 오프라인 .safetensors 아티팩트에서 두 가지를 로드한다.
        • RQ-VAE 형태 stacked residual codebook(stages, 형태 (num_levels, codebook_size, code_dim))
        • 2-layer MLP decoder(dec_w0/b0/dec_w1/b1)
      • SID 코드에서 dense vector를 복원한 뒤 L2 정규화, stop_gradient, 학습 가능한 project를 거쳐 랭커 post tower에 주입한다.
      • RecsysAggregatedModelConfig에 sid_embedding_mode: Literal["learned", "recon"] = "learned"와 sid_decoder_path: str = ""가 추가됐다.
      • 기본값은 그대로 learned다. 즉 wired-but-off다.
      • X가 RQ-VAE 계열 SID를 랭커 입력에 붙이는 연결을 코드로 처음 공개했다(retrieval 모델이 아니라 ranker 입력).
    3. StableHLO 번들 export 연결이 신설됐다.
      • phoenix/xrex/train/recsys_bundle_export.py(634줄 신규)는 랭킹 forward를 jax.export로 낮춰 StableHLO MLIR 바이트코드와 MANIFEST.json으로 저장한다.
      • 배치 크기 버킷마다 별도 프로그램을 만든다. 기본 버킷은 "1,2,4"다.
      • 임베딩 조회는 export된 그래프 밖에 있다. 입력은 pre-gathered merged_embeddings다.
      • MANIFEST.embedding이 shard·width·slice·hash table 지오메트리를 담아, 서빙 측이 임베딩 서버 조회 결과를 그대로 넘길 수 있게 한다.
      • target은 ("cuda",)다.
      • export_stablehlo_bundle: bool = False로 기본 off다.
      • X가 JAX 학습 → CUDA 서빙 핸드오프의 StableHLO 표준화를 처음 공개했다.
    4. envelope-encrypted Orbax 체크포인트 writer인 orbax_encrypted.py(288줄 신규)가 생겼다.
      • 모든 파일은 b"XAIENC01" 매직 헤더로 시작해야 한다.
      • assert_all_enveloped는 finalize 뒤 평문이 남으면 커밋을 거부한다.
      • KMS가 발급한 DEK와 AES 청크로 암호화한다. DEK는 파일 _DEK에 두고, 청크는 기본 8 MiB다.
      • 암호화된 체크포인트에서 restore한 뒤 평문으로 저장하려 하면 명시적으로 거부한다.
      • CheckpointConfig.encrypt=False로 기본 off다.
    5. DPA proto의 DpaProductInfo에 repeated int32 sid_codes = 8이 추가됐다.
      • 광고 DPA 제품 SID가 서빙 wire format에 실린다. 유기 post SID와는 별개 파이프라인이다.
      • 액션 enum에는 reserved 214 to 252로 39개 자리를 벽으로 예약했다.
    6. 그 밖에 다음 변경이 있었다.
      • mm_embedding_client가 zero-copy로 리팩터됐다.
      • search_query embedding을 후보마다 복제하지 않고 non-padding real candidates에만 반복 기록하도록 최적화했다.
      • grox mm-emb kafka를 MultiRegionKafkaProducer로 마이그레이션했다.
  • 2026-09-02 커밋(4세대+14)에서 home-mixer가 피드 엔드포인트 5개 모두에서 Phoenix에 backbone scores를 강제로 요청하게 됐다. (85ac72a, +1,081/−525, 파일 34개, daily)
    1. home-mixer의 다섯 개 gRPC 서버가 모두 backbone scores를 요청한다.
      • ScoredPosts·ForYouFeed·ForYouFeed 폴링·RankedFollowingFeed·FollowingFeed가 요청을 Phoenix로 넘기기 직전에 query.return_backbone_scores = true를 하드코딩으로 세팅한다.
      • Phoenix proto에 request 필드 returnBackboneScores(#20)가 신설됐다.
      • response 필드로는 backboneTopLogProbs(#10)와 backboneContinuousValues(#11)가 신설됐다.
      • PostCandidate.backbone_scores: Option<PhoenixScores> 필드가 생겼고, PhoenixScorer가 candidate_backbone_scores()를 파싱한다.
      • Phoenix 랭커가 공용 backbone과 reranker head 두 층이라는 점이 코드로 처음 확정됐다.
      • 4세대+11 커밋의 rerankerHeadTag는 여러 head를 노출했고, 이번 커밋은 그 아래 backbone 층의 출력을 노출한다.
    2. scarecrow에서 XReview 인테이크로 가는 gRPC 파이프라인이 신설됐다.
      • 새로 생긴 파일에는 다음이 있다.
        • scarecrow/legacy/XReviewIntakeRegistry.scala(179줄 신규)
        • XReviewReportBuilder.scala(59줄 신규)
        • XReviewReportSubmitter.scala(91줄 신규)
      • DownstreamServices.scala에 XReviewIntake가 등록됐다.
      • scarecrow 룰 DSL이 CreateXReviewReportProd/Staging 함수 유닛을 호출하면, SubmitReportRequest가 mTLS gRPC로 XReview 인테이크에 넘어간다.
      • 이 요청은 report_source="proactive"·report_surface="x_app"·subject="Botmaker report"·reporter_id="0"·detection_bot_id=<봇 id>로 채워진다.
      • entityType은 "post"/"profile" 화이트리스트다.
      • bystander_impersonation 같은 XReview allowlist report_type이 lane tag 라우팅의 discriminator다.
      • detection_timestamp_ms는 content-hash report id의 uniqueness에 들어간다.
      • XReview 큐가 유저 신고만이 아니라 자동 탐지 봇의 종착지 중 하나라는 사실이 코드로 확정됐다.
    3. VF rules 재구조화의 phase 2가 진행됐다.
      • Rule trait, evaluate_rules 함수, Vec<Box<dyn Rule>> 컬렉션이 모두 없어졌다.
      • 정책 세 개(FILTER_ALL_POLICY·TIMELINE_HOME_POLICY·TIMELINE_HOME_RECOMMENDATIONS_POLICY)가 &'static [&'static [RuleSpec]] 형태로 rodata에 실린다.
      • Policies는 상태를 더 이상 소유하지 않으며, 이름이 RuleEngine으로 바뀌었다.
      • 홈 공통 9 slice와 추천 전용 5 slice 구성은 그대로다.
      • 새 테스트가 15개 rule slice(홈 공통 9, 추천 전용 5, FILTER_ALL 1)의 이름이 유일한지 검증한다.
    4. 체크포인트 암호화 방식이 바뀌었다.
      • reader는 xai_kms.KvServe KMS-proxy KvStore 대신 xai_encrypted tensorstore 드라이버를 직접 쓴다. DEK를 한 번 unwrap한 뒤 로컬에서 대칭 복호화한다.
      • adopt_tree_dek는 _DEK 사이드카가 있으면 그것을 쓰고, 없으면 checkpoint 파일의 envelope 헤더에서 unwrap해 backward-compatible하다.
      • envelope 헤더는 4096 바이트이고, magic은 b"XAIENC01", 파생 형태는 xai-dek1:<salt>:<master>다.
      • Writer는 encryption_context에 domain·run 두 키를 필수로 요구한다.
      • mint_nonce = sha256(path)를 encryption_context에 자동으로 넣는다(체크포인트 경로 이동 시 KMS unwrap 실패).
      • _uses_tensorstore_save는 더 이상 encrypt와 결합되지 않는다.
    5. two-tower 학습 config의 default가 qk_norm=True·attn_logit_cap=-1로 바뀌었다.
      • 2026-08-21 커밋(4세대+6)이 nano config에 두 값을 넣은 지 12일 만에 combined base의 기본값이 됐다.
    6. Phoenix 서빙의 배치 준비에 zero-fill 방어가 들어갔다.
      • candidate embedding·search-query embedding 슬라이스를 fill 전에 zero로 초기화하고, copy_len > 0 조건을 붙였다.
      • 재사용 버퍼의 잔여 유출과 빈 슬라이스 panic을 막는 방어다.
    7. 프로덕션 config에서 PhoenixRetrievalAggregationType default 값이 DENSE_WITH_SHORT_DWELL → DENSE_WITH_LONG_DWELL로 바뀌었다.
      • param.rs 헤더 sync 시각은 2026-09-02T16:03:45Z다.

4세대+4 (2026-08-19) — visibility-filtering shadow deployment (xai-vf-shadow) 배선

2026-08-19 커밋(4세대+4)은 VF 서비스에 요청 미러링(dark traffic) 장치를 붙여, 프로덕션 요청을 shadow 배포로 복제해 보낼 수 있게 했다 확인. VF 정책 변경을 프로덕션 트래픽으로 shadow 검증할 기반이 생겼지만, 그 위에서 어떤 규칙 실험이 도는지는 공개되지 않았다. 이 커밋은 daily에서 다뤘다.

VF 서비스 자체에 요청 미러링(dark traffic) 인프라가 붙었다 확인 — visibility-filtering/dark_traffic_setup.rs (109줄 신규). 이 layer는 visibility-filtering/main.rs에서 연결됐다 확인.

미러링은 환경 변수 3개로 게이팅한다.

  • DARK_TRAFFIC_ENABLED(bool, default false)는 마스터 스위치다.
  • ORDINAL_NUMBER는 이 pod의 인덱스다(StatefulSet ordinal 등).
  • DARK_TRAFFIC_MAX_ORDINAL(default 1)은 ord < max일 때만 dark traffic을 켜는 기준값이다. 즉 기본으로는 ordinal 0번 pod만 미러링하고, 특정 배포에서만 max를 올려 여러 pod가 미러링한다.

미러링 타깃은 xai-vf-shadow.prod.visibility:grpc이고, StaticShadowDiscovery로 하드코딩돼 있다. 데이터센터별 domain override는 visibility.visibility-filtering-service.prod.{DATACENTER}.s2s.twttr.net이다(default atla). mTLS가 필수다(TlsMode::mtls_from_env).

⚠ 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 자원 예산에서 돌게 된다.

⚠ 4세대+16(2026-09-04, 902a06f)에서 미러링 대상이 staging과 devel 두 app_env로 확장됐다 확인 — visibility-filtering/dark_traffic_setup.rs.

  • 함수 이름이 parse_staging_listener에서 parse_mirror_listener로 바뀌었고, 두 app_env(STAGING_APP_ENV = "staging"·DEVEL_APP_ENV = "devel")를 순회한다.
  • MirrorChannelFactory는 리스너 xds_dest에 .devel.이 있으면 devel TLS 도메인을, 없으면 staging TLS 도메인을 골라 채널 팩토리를 배정한다.
    • devel TLS 도메인은 visibility.xai-vf-service.devel.{dc}.s2s.twttr.net이다.
  • 통과하는 예는 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에서 다뤘다.

dark_traffic이 트래픽만 미러링했다면, 이번 공개는 실제 verdict를 대조하는 canary를 붙였다 확인 — visibility-filtering/reference_compare.rs (812줄 신규). 함께 바뀐 파일은 visibility-filtering/filter_tweets.rs, visibility-filtering/server_deps.rs, visibility-filtering/config.rs다.

하니스는 응답을 바꾸지 않고, 별도 task에서 레퍼런스 VF의 판정과 대조한다.

  • 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 태그가 붙어 특정 빌드의 회귀를 추적할 수 있다.
    • vf_reference_compared(비교 성사 수), _exact_match(일치), _differed(불일치)
    • _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의 잔재인지, 별도 스택인지)
  • dark_traffic.yaml이 어떤 규칙으로 요청을 선정하는지
  • diff가 로그로만 나가는지, 별도 대시보드가 붙는지
  • 프로덕션 승격 임계값(예: "diff율 X% 이하면 promote")

Phoenix 체크포인트 스택 저장/복원 이중 하드닝 — 4세대+7 (복원, 2026-08-24) + 4세대+9 (저장·streamed 복원·warm-start staging, 2026-08-26)

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가 스로틀 예산보다 크면 경고한다.

restore를 노드 레벨에서 직렬화한다 확인.

  • 환경변수 XAI_RESTORE_NODE_SERIALIZE로 켜고 끈다. 기본값은 "1"이고, "0"|""|"false"면 꺼진다.
  • 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 청킹인지)
  • concurrent_gb의 프로덕션 튜닝 값
  • node-lock으로 restore 시간이 얼마나 늘어나는지

4세대+9 (2026-08-26, 45b48ba)는 trainer_recsys의 valid_step 게이팅을 통일했다 확인 — phoenix/xrex/train/trainer_recsys.py (+29/−19).

dense sync 경로와 async_no_deferred 경로, 두 곳에서 param update 순서가 바뀌었다. 이전에는 다음 순서로 진행했다.

  1. dense grad-norm을 검사해 valid_step을 얻는다.
  2. dense params update를 valid_step으로 게이팅한다.
  3. segment_sum을 거친 뒤 emb grad-norm을 검사해 emb_valid_step을 얻는다.
  4. emb table update만 emb_valid_step으로 게이팅한다.
  5. metric에서 valid_step & emb_valid_step을 계산한다.

즉 dense가 통과하고 emb만 실패하면, dense params는 새 값으로 갱신되고 emb만 롤백되는 몇 스텝짜리 비대칭 상태가 생길 수 있다.

바뀐 순서에서는 emb 검사가 dense update보다 먼저 온다.

  1. dense grad-norm을 검사해 valid_step을 얻는다.
  2. 먼저 segment_sum을 거친 뒤 emb grad-norm을 검사해 emb_valid_step을 얻는다.
  3. keep_step = valid_step & emb_valid_step을 계산한다.
  4. dense params update를 keep_step으로 게이팅한다.
  5. emb update도 sparse_update(..., valid_step=keep_step)로 게이팅한다.
  6. metric도 keep_step으로 통일한다.

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가 걸렸는지 진단할 수 있다.

4세대+9 (2026-08-26, 45b48ba)는 저장 쪽을 복원 쪽과 대칭으로 하드닝하고, streamed restore와 warm-start staging을 더했다 확인 — phoenix/xrex/utils/checkpointing.py (+215/−6). 함께 바뀐 파일은 phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+311/−88)와 phoenix/xrex/train/trainer.py (+39/−8)이다.

  • 저장 쪽 ThrottledD2HArrayHandler: 이제 save_checkpoint(..., save_concurrent_gb=<N>) 인자로 D2H 예산을 정할 수 있다.
    • Orbax ArrayHandler.serialize를 오버라이드해 텐서를 addressable_nbytes 기준으로 배치에 묶는다. 배치마다 super().serialize()를 부르고 future.result()를 기다린 뒤 다음 배치로 넘어간다.
    • 이전에는 Orbax의 write-limiter가 저장 병목을 96GB 기본으로 제한할 뿐, D2H(device-to-host copy) 자체는 전량 stage했고 이 단계에서 host OOM이 났다.
    • compressed면 ThrottledD2HArrayHandler를, 그렇지 않으면 ThrottledNoCompressionArrayHandler(NoCompressionArrayHandler와 다중 상속)를 등록한다.
  • 저장 노드-serialize: 4세대+7 restore의 노드-serialize 패턴과 완전히 대칭이다.
    • XAI_SAVE_NODE_SERIALIZE=1 env와 XAI_SAVE_NODE_LOCK_FILE(기본 /dev/shm/xai_save_node_lock)로 flock 배타 lock을 잡는다.
    • 배치 사이에 _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)로 정렬한다.
    • 완료한 배치의 old/new array 페어를 on_replaced 콜백에 넘기면, 호출자가 참조를 갱신하고 해제한다.
    • copy_aliased_arrays로 aliased 참조를 미리 분리한다.
  • 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는 유지한다.
    • RecsysTrainer는 오버라이드로 keep_emb_opt_state=True 옵션을 지원한다(emb_table_state만 유지).
    • 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에서 다뤘다.

4세대+8에서 abuse-enforcement-service/service-lib/src/generic_actions.rs(663줄 신규)가 추가됐다. 종전 AES는 enforcement_user.yaml / enforcement_post.yaml에 정적으로 인코딩된 규칙만 실행했다. 예를 들어 "llm_slop_user" in score.labels && "SpamHighRecall" in user.labels이면 suspend한다. 이제는 스코어러가 score.requested_actions 리스트를 직접 추가해 보내면, AES가 이를 통과시킬 수 있다.

요청은 두 겹으로 검증한다.

  • 하드코딩 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 값)는 공개 소스 밖에 있다.

4세대+9 (2026-08-26) — TesHydrator media fallback cache 통째 제거 + dark_traffic canary opt-out

2026-08-26 커밋(4세대+9)은 VF가 TES 조회에 실패했을 때 이전 조회의 media feature를 내줄 수 있던 fallback cache를 지우고, canary 워크로드를 dark traffic 미러링에서 뺐다 확인. 지운 cache는 VF 재작성 초기 이후 안정화 기간의 안전망이었다 추정. 이 커밋은 daily에서 다뤘다.

media fallback cache를 지웠다 확인 — visibility-filtering/hydration/tes_hydrator.rs (+4/−292). 함께 바뀐 파일은 다음 셋이다.

이전에는 TesHydrator(Tweet Entity Service, X 내부 트윗 메타데이터 조회 서비스)가 1M-entry LRU 기반 fallback cache를 들고 있었다. TES 호출이 실패해 media entity가 나오지 않으면 모드에 따라 다르게 동작했다.

  • FallbackCacheMode::ServeStale이면 이전에 성공한 조회의 stale하지만 유효한 media feature를 반환했다.
  • Shadow면 캐시는 채우되 서빙에는 쓰지 않았다.
  • Disabled면 fallback 없이 실패했다.

이 캐시가 이번에 전부 제거됐고, 관련 코드도 함께 지워졌다.

  • CACHE_CAPACITY = 1_000_000 상수와 FallbackCache::new("media", ...) 인스턴스 필드
  • resolve_hydration_batch 호출
  • 관련 회귀 테스트(media_stale_recovery_respects_cache_mode, no_media_tweets_cache_default_entries_that_serve_stale)
  • mock 클라이언트(MediaFailingAfterFirstClient)
  • 환경변수 두 개(VF_MEDIA_FALLBACK_CACHE_SERVE_STALE_ENABLED, VF_MEDIA_FALLBACK_CACHE_POPULATE_ENABLED)
  • config 헬퍼(media_fallback_cache_serve_stale_enabled(), media_fallback_cache_populate_enabled())

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가 줄었다.
  • 4세대+8의 VMRanker 대폭 단순화와 방향이 같은 "안정화된 뒤 안전망 걷어냄" 흐름이다.

다만 다른 hydrator(gizmoduck 저자 메타데이터 조회 등)의 fallback cache는 그대로 유지된다. fallback_cache_mode env도 계속 있다.

dark_traffic에 canary opt-out이 생겼다 확인 — visibility-filtering/dark_traffic_setup.rs (+40/−17).

  • 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를 쓰는 관행 추정이라, 접미사 매칭이면 충분하다.

4세대+10 (2026-08-28) — safety-label twemcache miss에 대한 sampled async 워머 배선

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에 채워 두는 워머 채널을 추가한다.

워머는 네 단계로 동작한다.

  1. RemoteSource::get(ids)에서 twemcache 결과는 네 종류로 갈린다. 이 가운데 Miss인 경우에만 별도 벡터 warm_ids에 모은다.
    • Hit
    • NotFound: 정의적 미존재
    • Miss: cache가 이 키를 모름
    • FallThrough(reason): cache 에러/타임아웃 등
    • 정의적 미존재인 NotFound와 폴백 이유가 있는 FallThrough는 워밍 대상이 아니다. 워밍의 목적은 캐시가 채워지지 않은 상태를 해소하는 것이지, 다른 이유의 miss에 대응하는 것이 아니라는 원칙 때문이다.
  2. Manhattan 폴백 요청이 나가기 전에 warmer.warm(warm_ids)를 호출한다(fire-and-forget). 요청 응답 지연에는 영향을 주지 않는다.
  3. SampledWarmer가 VF_CACHE_WARM_SAMPLE_PCT 환경변수(0–100, panic on out-of-range)로 확률 샘플링한다. 통과한 ID만 tokio bounded mpsc(용량 1024)에 try_send한다.
  4. 백그라운드 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에서 다뤘다.

이전에 3개(safe_gap·partition_organic·time_gap)였던 광고 블렌더 세트에 네 번째 변종 multi_risk가 추가됐다 확인 — home-mixer/ads/multi_risk_blender.rs (332줄 신규). 함께 바뀐 파일은 테스트 home-mixer/ads/tests/multi_risk_blender_tests.rs (215줄 신규)와 home-mixer/selectors/blender_selector.rs (+8/−4)다.

  • AdsBlenderType param이 "multi_risk" 문자열을 받으면 이 블렌더가 선택된다.
  • 같은 커밋에서 "partition_organic_low_risk" 앨리어스가 PartitionOrganicAdsBlender에 추가돼, 옵션은 모두 5개가 됐다.
    • safe_gap / partition_organic / partition_organic_low_risk / time_gap / multi_risk

슬롯 정책은 광고 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 포스트는 어떤 광고와도 인접할 수 없다.

안전 슬롯 예산은 세 상한 가운데 가장 작은 값이다.

safe_budget = min(num_ads, spacing_cap, safe_count / 2)
  • 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} 카운터로 집계한다.

  • drop/ok: BSR-low 광고 결정(버림 또는 배치)
  • handle_drop/keyword_drop: 자기간섭 검사에서 버림

의미:

  • 4세대+7 (2026-08-24) HighRisk tier 신설 → 4세대+9 광고 픽셀·conv_asset_ids → 4세대+10 tier×tier 매트릭스 블렌더 신설로 이어진 5일 흐름이 완결됐다. HighRisk가 tier로 분리됐고 광고 픽셀 컨버전 헤드가 붙었으니, 이제 광고주가 자기 노출을 tier 축에서 opt-in할 수 있는 blender 옵션이 나온 것이다.
  • 결정적으로 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 로깅)이 이 커밋에서 세 가지로 성숙했다.

  • trainer 쪽 소비자를 연결했다.
  • 서빙 응답 트레일러의 정합성을 복구했다.
  • copy_port의 peer-load를 고르게 나눴다.

1. trainer가 restore_streamed 플래그를 읽어 streamed restore를 실제로 쓴다 확인 — phoenix/xrex/train/trainer.py (+102/−31), phoenix/xrex/configs/xrecsys.py (+1), phoenix/python/training/xai-checkpointing/xai_checkpointing/load.py (+85/−30):

maybe_load_checkpoint가 이제 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로 시작하는 표준 흐름을 원자적으로 지원하게 됐다.

2. 응답 압축 경로가 gRPC 트레일러를 버리던 버그를 고치고, zstd encode 시간 히스토그램을 추가했다 확인 — phoenix/crates/serving/xai-recsys-engine/src/grpc_compression.rs (+109/−13), phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs (+22/−5), phoenix/crates/serving/xai-recsys-engine/src/request_metrics.rs (+17/−4):

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 인코딩이 응답 지연에 얼마나 기여하는지 나눠 볼 수 있어, 압축 레벨을 튜닝할 근거가 된다.

3. copy_port가 pod마다 shard schedule을 섞어 peer-load를 고르게 나눈다 확인 — phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs (+113/−10):

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.

VF 서브레포의 rule 파일 24개가 한꺼번에 얇아졌다(총 +1,522/−1,151, 순수 리팩터). RuleContext 정의는 mod.rs에서 새 파일 context.rs로 분리됐다. 규칙 판정 로직은 그대로 두고, context.candidate().foo 꼴의 직접 접근을 모두 도메인 메서드(context.is_stale_tweet(), context.viewer_country_in(...))로 통일했다. 이 통일을 뒷받침하려고 90건 규모의 회귀 코퍼스(golden_corpus.rs, +1039 신규)와 후보 빌더를 새로 넣었다. 후보 빌더는 fixtures.rs(+116 신규)다. 확인 — visibility-filtering/rules/context.rs, visibility-filtering/rules/golden_corpus.rs, visibility-filtering/rules/fixtures.rs, visibility-filtering/rules/registry.rs.

RuleContext는 도메인 메서드를 25여 개 노출한다. 확인 묶음별로 보면 다음과 같다.

  • 뷰어 상태: viewer_is_logged_out, viewer_is_underage, viewer_has_no_stated_age, viewer_allows_sensitive_media, viewer_country_in
  • 뷰어↔저자 관계: is_author_viewer, viewer_follows_author, viewer_blocks_author, viewer_mutes_author, viewer_mutes_retweets_from_author
  • 트윗 형태: is_retweet, is_stale_tweet, is_nullcast, is_community_tweet, has_media, has_dmca_media
  • NSFW: is_nsfw_flagged, has_tweet_nsfw_user_flag, has_tweet_nsfw_admin_flag
  • 국가 규제: legal_takedown_in_viewer_country, local_laws_takedown_in_viewer_country, media_restricted_in_viewer_country
  • 저자 상태: author_is_suspended/deactivated/erased/offboarded/protected/nsfw_user/nsfw_admin, author_has_user_label
  • 독점 콘텐츠: is_exclusive_tweet, viewer_is_conversation_author, viewer_super_follows_author

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 하니스가 이 커밋 뒤에도 계속 돌고 있다면, 실제 트래픽으로 검증되고 있는 셈이다.

4세대+11 (2026-08-28, bc8e5f0) — Phoenix serving: rerankerHeadTag 파라미터 배선 + emb_table gRPC 수신을 blocking pool로 분리

같은 커밋(bc8e5f0)의 서빙 쪽에서는 리랭커 헤드를 고르는 값이 Phoenix 요청에 실리기 시작했지만, 그 값을 쓰는 서빙 로직은 이 커밋에 없다 확인. 임베딩 테이블을 gRPC로 받을 때의 대용량 복사는 런타임 스레드를 붙잡지 않도록 blocking pool로 옮겨졌다 확인 — 이 커밋을 다룬 daily.

변경은 두 갈래다.

  • 리랭커 헤드를 고르는 파라미터가 home-mixer → proto 2곳 → Phoenix 요청 경로까지 전 구간 연결됐다. 서빙 쪽 소비 로직은 이 커밋에 없다.
  • emb_table의 gRPC 수신 콜백에서 non-temporal 복사와 Adler-32 계산을 spawn_blocking으로 분리했다. 이제 tokio 런타임 스레드가 대용량 memcpy에 붙잡히지 않는다.

1. RerankerHeadTag 파라미터가 home-mixer에서 Phoenix 요청까지 연결됐다 확인 — home-mixer/params/param.rs, home-mixer/models/candidate.rs, home-mixer/scorers/phoenix_scorer.rs, home-mixer/util/phoenix_request.rs, phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto:

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

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에 파라미터 값을 채워 요청에 싣는다.

proto 필드는 두 메시지에 대칭으로 추가됐다.

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

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

Rust proto와 Python proto가 같은 필드 번호로 갱신됐다. 확인

의미: 추정 기본값이 0이고 서버 쪽 소비 로직 변경이 이 커밋에는 없으므로, 헤드 라우팅을 켜기 전의 연결 단계다. head_tag라는 이름과, UserActionSequence(피드 스코어러 입력)에도 함께 붙는 배치는 "한 시퀀스에 대해 어떤 리랭커 헤드를 쓸지 지정"하는 용도로 읽힌다. 모델 안의 리랭커 헤드 여러 개 가운데 하나를 실험 기간 동안 A/B로 고르는 스위치라는 해석이다. 여러 랭커 헤드를 함께 학습해 두고 서빙할 때 태그로 라우팅하는 패턴은 A/B 성숙기에 흔히 나타난다.

2. emb_table.rs가 gRPC로 받은 데이터를 목적지 버퍼에 복사하는 일을 blocking pool로 넘긴다 확인 — phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs (+79/−12)

기존에는 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()는 다음 목록을 반환한다.

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

나라 이름으로 옮기면 아르헨티나·호주·브라질·캐나다·독일·스페인·프랑스·영국·인도네시아·이탈리아·한국·멕시코·네덜란드·필리핀·포르투갈·태국이다. 이 목록은 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가 이 계약을 잠근다.

규칙 세 개에 국가 목록이 주입됐다 확인 — visibility-filtering/rules/nsfw_age_gating.rs. NSFW/GORE 라벨이 붙은 미디어 후보에는 세 규칙이 병렬로 작동한다.

  • SensitiveViewerLoggedOutDropRule: 로그아웃 뷰어면 무조건 드롭한다. 국가와 무관하다.
  • SensitiveViewerUnderageDropRule: 뷰어가 18세 미만으로 확인되면 드롭한다. 국가와 무관하다.
  • SensitiveViewerNoStatedAgeDropRule: 나이 미확인 뷰어가 위 16개국에 속하면 드롭한다. 즉 미국·일본·러시아 같은 나라에서는 이 규칙이 걸리지 않는다.

고인기 영상 전용 8단 DAG PlanSafetyPtosSpecialVideo가 새로 생겼다 확인 — grox/flows/ptos/plan_safety_ptos_special_video.py (55줄 신규). 의존 그래프는 다음과 같다.

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

진입 조건은 세 가지다(_skip_reason).

  • 유저가 있다.
  • media에 Video가 하나라도 있다.
  • 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 한 종류다. 이 필드가 비어 있지 않은 영상만 다음 태스크로 넘어간다.

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

프롬프트가 motion-reveal 프레임의 뜻과 판정 기준(rubric)을 정해 둔다 확인 — grox/core/lm/convo.py MOTION_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 도입에 이은 다음 단계다.

규칙의 표현이 struct에서 데이터 리터럴로 바뀌었다 확인 — visibility-filtering/rules/rule_spec.rs (89줄 신규).

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

#[derive(Clone)]
pub(super) enum RuleAction {
    Drop(FilteredReason),
    SensitiveMediaInterstitial(FilteredReason),
}
  • 규칙마다 struct + impl Rule을 쓰던 방식이 사라지고, 규칙 하나가 RuleSpec::Tweet { name, when, action, exempt_author } 같은 한 줄 리터럴이 됐다.
  • 변형은 세 가지다.
    • Tweet: 트윗 조건 predicate와 drop/interstitial
    • Author: 작성자 조건 predicate와 drop
    • Custom: 임의 함수 포인터. 조건이 predicate로 잡히지 않는 경우에 쓴다.
  • 공통 예외는 spec 필드로 흡수됐다. exempt_author는 트윗 규칙의 self-view 예외이고, exempt_follower는 작성자 규칙의 follower 예외다. 예전에 규칙마다 반복되던 검사가 사라졌다.
  • 액션은 Drop과 SensitiveMediaInterstitial 두 종류뿐이다. Interstitial은 viewer opt-in을 자동으로 확인한다.

트윗 축 규칙은 tweet_rules.rs(1,125줄)에서 그룹별 상수로 다시 정리됐다 확인 — visibility-filtering/rules/tweet_rules.rs. 옛 파일별 규칙을 그대로 옮기지 않고, 정책 트리거 유형끼리 다시 묶었다.

상수 (pub(super) const &[RuleSpec]) 규칙 개수 대체하는 옛 파일
TWEET_LABEL_DROPS 8 tweet_label_drops.rs (in-network drops)
NSFW_MEDIA_INTERSTITIALS 3 nsfw_interstitial.rs (label-triggered)
OON_TWEET_FLAG_DROPS 2 tweet_flag_rules.rs
OON_TWEET_LABEL_DROPS 9 tweet_label_drops.rs (OON)
EXCLUSIVE_TWEET_DROP 1 socialgraph_rules.rs의 exclusive 파트
NSFW_AUTHOR_INTERSTITIAL 1 nsfw_interstitial.rs의 author-flag 파트
NULLCAST_DROP 1 nullcast_rule.rs
TES_HOME_DROPS 3 tes_rules.rs (stale + takedown, 홈 공통)
FILTER_ALL 1 옛 registry.rs의 FilterAllRule
RECS_MEDIA_DROPS 2 tes_rules.rs의 DMCA/geo 파트 (recs 전용)
SENSITIVE_VIEWER_DROPS 3 nsfw_age_gating.rs

두 곳의 분리가 특히 의미 있다.

  • 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 스펙으로 옮겨졌다.

fn sensitive_viewer_no_stated_age(context: &RuleContext<'_>) -> VfAction {
    if context.viewer().has_no_stated_age()
        && context.viewer().country()
            .is_some_and(|country| context.nsfw_gating_country(country))
        && sensitive_base_condition(context)
    {
        VfAction::Drop(FilteredReason::ContainNsfwMedia)
    } else {
        VfAction::Allow
    }
}
  • 예전에는 규칙 자체가 Arc<NsfwGatingCountries>를 필드로 들고 있었다. 이제는 RuleContext가 참조를 갖고, 규칙은 context.nsfw_gating_country(code)로 조회한다. 규칙은 상태를 갖지 않는 zero-sized data literal이 됐다.
  • 규칙 이름 세 개(SensitiveViewerLoggedOutDropRule·SensitiveViewerUnderageDropRule·SensitiveViewerNoStatedAgeDropRule)는 그대로 유지된다. 감사·메트릭·회귀 테스트가 이 이름을 리터럴로 참조하기 때문이다.

작성자 축 규칙은 author_rules.rs(354줄)에 그룹별로 모였다 확인 — visibility-filtering/rules/author_rules.rs.

상수 규칙 개수 대체
AUTHOR_STATE_DROPS 5 (Suspended·Deactivated·Erased·Offboarded·Protected) 옛 user_rules.rs
OON_NSFW_AUTHOR_DROPS 2 (NSFW user·admin) 옛 user_rules.rs의 NSFW 파트
OON_USER_LABEL_DROPS 11 옛 user_label_drops.rs
SOCIALGRAPH_DROPS 3 (Custom) 옛 socialgraph_rules.rs

SOCIALGRAPH_DROPS는 viewer 상태(logged-out·blocks·mutes)와 relationship을 함께 봐야 해서 Custom으로 남았다.

registry.rs 회귀 방지 테스트가 정책 변화 없음을 리터럴로 잠근다 확인 — visibility-filtering/rules/registry.rs.

새 테스트 wired_rule_order_matches_pre_migration_sequence는 TimelineHome 28개, TimelineHomeRecommendations 추가 26개, FilterAll 1개의 규칙 이름을 순서까지 리터럴로 검증한다. 홈 타임라인의 규칙 순서는 다음과 같다(참조용).

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

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 패키지가 그대로 남아 있다.

scarecrow가 호출할 수 있는 함수 유닛이 두 개 새로 생겼다 확인 — scarecrow/legacy/XReviewIntakeRegistry.scala (179줄 신규).

  • CreateXReviewReportProd의 action level은 PROD_AGENT_WORKFLOW_ACTION이다.
  • CreateXReviewReportStaging의 action level은 NO_ACTION이고, dry-run이다.
  • 인자는 7개다: entityType("post"/"profile"), entityId, userId, reportType(XReview allowlist), botId, optional note, optional victimId.

리포트 페이로드는 SubmitReportRequest proto의 필드를 아래 표처럼 채운다 확인 — scarecrow/legacy/XReviewReportBuilder.scala (59줄 신규).

XReview report 키 값 뜻
subject "Botmaker report" 리포트 제목 고정
report_surface "x_app" 유저 신고와 같은 표면
report_type 인자 값(예: "bystander_impersonation") lane tag 라우팅의 discriminator
report_source "proactive" 유저 신고("user")가 아니라 봇 탐지임
reporter_id "0" 봇 리포트에는 신고자 유저 id 없음
detection_bot_id 인자 값 어떤 봇이 만든 리포트인지
detection_timestamp_ms context.startMillis() content-hash report id의 uniqueness에 들어감
victim_user_id 인자 값(optional) 피해자 유저 id

스키마에서 눈여겨볼 사실은 다음과 같다.

  • entityType은 "post"/"profile" 화이트리스트로 고정된다. 잘못된 값이 오면 normalizeEntityType이 IllegalArgumentException을 던진다.
  • XReview 서버는 report_source = "proactive" 필드로 유저 신고와 봇 리포트를 구분한다.
  • 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 인테이크를 프로그램으로 호출한다는 정황이다.

submitter에는 관측용 카운터가 5종 있다 확인 — scarecrow/legacy/XReviewReportSubmitter.scala (91줄 신규). 카운터는 attempted, submitted, skipped, unsupported_entity, rpc_failed다. submitter는 mTLS로 XReview intake에 붙고(xreview.tls.ca-cert 플래그), localMode()/personalAccountMode()에서는 noop이다.

의미 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개가 모두 제거됐다.

정책은 이제 정적 테이블로 rodata에 실린다 확인 — visibility-filtering/rules/registry.rs. 형태는 &'static [&'static [RuleSpec]]이다.

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

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

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

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

Policies의 이름이 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 자동 첨부

checkpoint 파일의 envelope 헤더 포맷이 정의됐다 확인 — phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py.

  • 헤더는 4096바이트이고, magic은 b"XAIENC01"이다.
  • 28~32 offset에 wrapped_len(big-endian u32)이, 32~36 offset에 context_len(u32)이 있다.
  • wrapped 필드가 xai-dek1:<salt>:<master> 형식이면 "derived" 방식으로, 아니면 legacy per-file 감싸기로 판정한다.

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).

커밋 하나가 6개 영역을 바꿨다 확인 — xai-org/x-algorithm@9b0dc31. 파일 74개, +1,363/−1,876으로 줄 수가 순감소한 커밋이다.

  • 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 편입) 확인:

판정에 들어가는 조건은 진입 게이팅 TaskMultiStepReplySpamFilter가 정한다 확인 — task_multi_step_reply_spam.py:

  • 대상이 답글이어야 한다. post.ancestors가 있어야 하고, 그중 삭제된(None) 것이 없어야 한다.
  • 작성자가 존재해야 한다. 대상 포스트, 스레드 root, 대상 포스트의 직계 부모의 user가 모두 살아 있어야 한다.
  • 작성자가 스레드 root 작성자와 달라야 한다. 원 포스터가 자기 스레드에 다는 답글은 건너뛴다.
  • 작성자가 GROK_USER_ID·GORK_USER_ID이면 건너뛴다. Grok/Gork 자체가 다는 답글은 판정 대상이 아니다.
  • 스레드 깊이가 2 이상이어야 한다. 답글 chain이 2단 이상이어야 한다는 뜻이다. 즉 "root → 첫 답글" 한 단계에는 이 파이프라인이 들어오지 않는다.
  • 직계 부모 작성자가 대상 포스트 작성자와 같아야 한다. 즉 같은 사람이 자기 답글에 자기가 또 답글을 다는 chain만 진입 대상이다. 전형적인 자기 스레드 스팸 패턴이다.
  • root 작성자의 팔로워가 1,000 이상이어야 한다("blast radius"). root가 널리 노출되는 계정일 때만 판정한다. 노출이 낮은 계정 밑의 답글 스팸은 노력 대비 무의미하므로 제외한다.
  • 답글 작성자가 high page-rank v2 유저나 grey badge 유저면 건너뛴다. 확립된 계정은 대상 밖이다. UserStratoLoader.is_high_page_rank_v2_user와 is_grey_badge_user를 asyncio.gather로 병렬 조회한다.

정확히 이 8개 축이 게이팅이라는 점이 정본에 고정된다. 특히 여섯 번째 축(같은 저자가 자기 답글에 자기가 또 답글)은 이 파이프라인이 잡으려는 패턴을 명시적으로 정의한다. 그 패턴은 자기 대화 chain을 통해 root 노출을 훔치는 답글 스팸 chain이다.

LM 프롬프트는 MultiStepReplySpamScorer._to_convo가 구성한다 확인 — classifier_multi_step_reply_spam.py.

  • 시스템 프롬프트는 multi_step_reply_spam_system_prompt()다. 원문은 게이밍 방지 목적으로 export에서 제외된다는 것이 README 정책이다.
  • human 메시지는 두 절로 되어 있다.
    • "Reply Author Info" 절은 답글 작성자의 유저 프로필을 렌더한다(UserRenderer.render(post.user)).
    • "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단 안전장치를 거친다 확인:

  1. _drop_if_newest_reply_not_flagged는 LM이 내놓은 후보 인덱스에 판정 대상(스레드의 마지막 포스트)이 없으면 결과 전체를 버린다. Task 진입 조건이 "이 반응이 스팸이라 볼 수 있어야 파이프라인이 도는 것"이라, 마지막 반응이 스팸이 아니라고 판정된 결과는 정합성 위반이다.
  2. _drop_other_author_posts는 LM이 스레드의 다른 저자 포스트를 스팸으로 지목하면 그 포스트를 제거한다. 이 파이프라인의 대상은 같은 저자가 자기 답글에 자기가 답한 chain이라, 다른 저자의 포스트가 결과에 나오면 무시한다.
  3. _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자만 남긴다.
  • save_reply_ranking_kafka_v2(..., ReplyRankingScoreKafka(...))로 v2 Kafka에 emit한다.
  • DISABLE_RULES = [DisableTaskForNonProd]는 non-prod에서 write를 건너뛰게 한다. 즉 dev·staging에서는 실제 label을 붙이지 않는다.

Plan DAG(PlanMultiStepReplySpam)는 filter, media hydration, detection, write 순서로 태스크를 잇는다 확인:

task_multi_step_reply_spam_filter →
  task_media_hydration →
    task_multi_step_reply_spam_detection →
      task_write_multi_step_reply_spam_reply_ranking

눈여겨볼 점은 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 등 카운터의 실측값)
  • 게이팅 상수(FOLLOWER_COUNT_THRESHOLD_FOR_SPAM_DETECTION = 1000)의 튜닝 이력
  • 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만 심판이 다른 모델로 갈아탄다.

Grok 4.1-fast가 심판으로 등록됐다 확인 — grox/flows/ptos/classifier.py:

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

_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 도메인 파인튜닝을 시사한다 추정.

IllegalAndRegulatedBehaviors는 first-pass에서도 Gemma 경로로 승격됐다 확인:

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

이전에는 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가 됐다.

IllegalAndRegulatedBehaviors가 classify_policy_for_violation cross-model reclassify 대상에 추가됐다 확인 — grox/flows/ptos/task_safety_ptos_policy.py:

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

이전에는 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는 이제 요청에 실려 들어온다 확인.

서빙 엔진과 inference 코드에서 SID 사이드카 관련 코드가 지워졌다 확인 — phoenix/crates/serving/xai-recsys-engine/:

  • src/sid_client.rs(−189줄)에서 SemanticIdClient Rust 클라이언트와 Python 바인딩(PySemanticIdClient)을 전부 제거했다.
  • src/lib.rs에서 pub mod sid_client;를 제거했다.
  • Cargo.toml에서 xai-recsys-sid-proto 의존성을 제거했다. pyproject.toml도 같다.
  • src/python.rs의 RecsysPredictorImpl·RecsysRetrievalPredictorImpl struct에서 SID 클라이언트 필드를 제거했다.
    • 제거된 필드는 sid_client: Option<Arc<crate::sid_client::SemanticIdClient>>다.
    • server_impl 매크로가 받던 sid_client: Option<&crate::sid_client::PySemanticIdClient> 인자도 제거했다.
  • xai_recsys_engine.pyi에서 PySemanticIdClient 스텁 5줄을 제거했다.
  • phoenix/xrex/inference/sid_retrieval_runner.py(−17줄)를 완전히 삭제했다. SID retrieval runner의 Python 진입점이다.
  • phoenix/xrex/inference/launch_inference.py에서 --sid_endpoint CLI 인자와 runner.sid_endpoint = args.sid_endpoint 대입을 제거했다.
  • phoenix/xrex/inference/model_runner.py에서 sid_endpoint: str | None = None 필드를 제거했다.
    • 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에서도 학습이 도는 첫 신호)

Phoenix 학습의 remat 정책에 H100용 값이 새로 생겼다 확인. 근거는 phoenix/xrex/models/remat.py의 enum이다:

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

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 스택에서 코드가 크게 줄었다 확인.

파일별 순 변화는 아래 표와 같다 확인 — xai-org/x-algorithm@9b0dc31:

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

전체 크기가 순감소한 방향이라, 이번 변경은 정책 확장이 아니라 안전망·중복 코드·과설계 층을 걷어 내는 작업이다. 이번 커밋은 아래 커밋들의 흐름을 같은 방향으로 이어 간다. 재작성이 안정된 뒤, 덧붙어 있던 폴백·중복·과설계 코드를 걷어 내는 흐름이다.

  • 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).

커밋 하나가 세 가지 축을 서로 맞물려 바꿨다 확인 — xai-org/x-algorithm@902a06f. 규모는 파일 25개, +472/−179다.

  • AuthorColdStart::apply가 실행 순서를 재정렬했다. 이제 control과 treatment 두 arm이 experiment bucket impression을 대칭적으로 남긴다(codivert 패턴).
  • Phoenix serving 요청 계약에 필드가 추가됐다. experiment_overrides: map<string, string>는 요청별 experiment param override이고, SlateContext.exact_k·exact_gap은 두 optional uint32 metric 필드다.
  • VF dark-traffic 미러링이 staging 외에 devel app_env(PR별 프리뷰 배포)도 인식한다.

그 밖에 CoreFeature.text가 예정된 muted-keyword rule을 위해 dead-code로 명시적으로 보존됐다. VF 스택에서는 pub → pub(crate) 정리가 대규모로 이뤄졌다.

A. 콜드스타트 실험의 두 arm이 codivert impression을 대칭으로 남긴다 확인 — home-mixer/scorers/author_cold_start.rs, home-mixer/params/param.rs.

새 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을 무조건 남긴다.
  • 이어서 record_tracked_ids를 호출한다.
  • 그리고 params.arm != Holdout이면 enabled 여부와 무관하게 author_corpus(...)를 호출해 author bucket impression을 남긴다.
  • 그다음에야 enabled 게이팅으로 조기 반환한다.

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 초기 연결의 강결합이 해체됐다.

D. Phoenix serving 요청·응답 계약이 확장됐다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto.

  • 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 등 유사한 이름은 명시적으로 거부한다.

F. VF에 muted-keyword rule이 예정됐다는 신호가 있다 확인 — visibility-filtering/models/tweet.rs:

#[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를 좁히는 흐름이다.

그 밖의 부수 변화는 다음과 같다.

  • checkpoint DEK의 KMS unwrap이 transient 실패를 재시도한다 확인 — phoenix/python/training/xai-checkpointing/xai_checkpointing/dek.py.
    • _unwrap_with_backoff가 KMS unwrap 실패(429·502·503·504·"KMS transport" 마커)를 jittered exponential backoff로 4회, 총 2초 상한까지 재시도한다.
    • assert_all_enveloped는 finalize 전에 _DEK 마스터 파일이 있어야 통과한다.
    • 4세대+13 envelope-encrypted Orbax writer의 안정화 후속이다.
  • retrieval_dataset.py를 DRY 리팩터했다 확인. SID snapshot window 경로를 _sid_window(filename) -> (primary, backup) 헬퍼로 통합했고, 실제 경로는 그대로 post_sid_v8_256x6_snapshots·_backup이다.
  • StableHLO bundle 매니페스트의 타임스탬프를 갱신한다 확인 — phoenix/xrex/train/recsys_bundle_export.py.
    • restamp_manifest(data)가 신설돼 MANIFEST.json의 created_timestamp를 export 시점 값으로 다시 기록한다.
    • 4세대+13에서 도입한 .mlirbc 번들의 fresh-detection을 위한 연결이다.
  • Backbone score를 bfloat16으로 다운캐스트한다 확인 — phoenix/xrex/train/trainer_recsys.py.
    • forward_fn이 log_probs·cont_preds를 .astype(bfloat16).astype(float32)로 왕복시킨다.
    • 서빙 정밀도(bfloat16으로 추정)와 학습 시 관측하는 numerics를 맞춘다. 4세대+14 backbone scores 요청 시맨틱과 짝을 이룬다.
  • log_util이 구조화 로깅을 한다 확인 — phoenix/xrex/utils/log_util.py.
    • LogRecord.__dict__의 extras를 자동으로 key=value로 이어 붙여 로그 라인에 추가한다. event=X extra는 앞에 둔다.
  • mutual_follow_stats_side_effect.rs의 borrow를 정리했다 확인. PhoenixRetrievalMOEInferenceClusterId 조회가 moe_enabled 조건 밖으로 이동해 lifetime이 정리됐다.

아직 공개되지 않은 것들이 있다.

  • moe_codivert_viewer experiment의 초기 활성 트래픽 비율
  • experiment_overrides map을 Phoenix 서빙이 실제로 어떻게 파싱해 어느 layer의 param을 override하는지
  • exact_k/exact_gap의 정확한 정의(Phoenix 서빙 채우는 코드는 오픈소스에 없음)
  • VF muted-keyword rule의 실물 등장 시점

4세대+17 (2026-09-08, 49815da) — 랭킹 스코어러 per-head weight perturbation + grox v8-search 멀티모달 임베딩 스택 신설 + Phoenix wire에 ContentFeatures 진입 + PTOS adult leading-frames 재스캔 경로 + FA4 attention 커널 SM80·SM90 forward·backward 완전 오픈소스

랭킹 쪽에는 행동별 가중치를 요청마다 흔드는 장치가 생겼지만 흔드는 폭의 기본값은 0.0이고, 랭커가 받는 포스트 정보에는 콘텐츠 특징 필드가 생겼다 확인(A·C 항목). 안전 판정에는 PTOS를 이미 거친 30초 이상 영상의 앞 10초를 다시 보는 경로가 생겼고, 검색 전용 임베딩 파이프라인과 attention 커널 backward 코드 공개도 같은 커밋에 들어 있다 확인(B·D·E 항목). 이 커밋은 daily에서 다뤘다.

한 커밋이 다섯 축에서 동시에 움직였다 확인 — xai-org/x-algorithm@49815da. 바뀐 파일은 78개이고, 줄 수는 +10,938/−982다.

  • 랭킹 스코어러가 참여 헤드 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이 추가됐다.

A. 랭킹 스코어러가 헤드별 가중치를 요청마다 흔든다(per-head weight perturbation) 확인 — home-mixer/scorers/ranking_scorer.rs, home-mixer/params/param.rs.

새 param은 두 개다. WeightPerturbationSigma는 f64이고 기본값은 0.0이다. WeightPerturbationSalt는 String이고 기본값은 ""다. ScoringWeights::from_params(&query.params) 뒤에 .perturbed(query)가 붙는다. 요청마다 sigma > 0이면 26개 헤드 각각을 weight *= (sigma * perturbation_sign(salt, user_id, head)).exp()로 곱해 스케일링한다. perturbation_sign은 md5(format!("{salt}:{user_id}:{head}"))[0] & 1로 정해지는 결정론적 ±1이다. negative_sum·positive_sum·total_sum은 흔들린 값으로 recompute_sums()에서 다시 계산한다.

대상 헤드는 모두 26개다.

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

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이라 프로덕션 기본 동작은 여전히 결정론적이다.

B. grox에 검색 지면 전용 멀티모달 임베딩 스택 v8-search가 생겼다 확인 — grox/flows/mm_emb/.

  • Plan은 네 개다.
    • mm_emb_v8_search: 실시간, grox-multimodal-embedding-requests-with-summary 토픽
    • mm_emb_v8_search_for_reply: reply용, content-understanding-...-min-traction-for-grox-multi-modal 토픽
    • mm_emb_v8_search_backfill·mm_emb_v8_search_backfill_batch: 과거 포스트 backfill, grox-multimodal-embedding-backfill 토픽
  • 임베더는 embedder_v8_search.py (113줄)다. RECSYS_V8_SEARCH_EMBED·RECSYS_V8_SEARCH_BACKFILL_EMBED 두 endpoint를 grox_config.get_embedding_model로 조회한다. XaiEmbeddingClientHttp(OpenAI 호환 embed API)로 embed_openai_async(text, images)를 호출하고, timeout은 60초다. 결과는 L2로 재정규화한다. 저자의 core 정보는 StratoUserCore().fetch(user_id)로 병렬 조회해 renderer로 넘긴다.
  • 렌더러는 renderer_v8_search.py (461줄)다. 미디어는 원본과 quoted를 합쳐 16 프레임 예산이고(MAX_MEDIA=16), 임베더가 실제로 요청하는 프레임 수는 MAX_FRAMES=4다. 렌더링 결과는 아래 요소로 구성된다.
    • instruction prefix "Represent this X post (formerly Twitter) for recommendation: capture its topic, intent, key entities, and likely engagement."
    • 저자 블록: 팔로워 수를 사람이 읽는 표기와 tier label 두 형태로 넣는다
    • 본문: 최대 4000자, dangling media URL 제거
    • quoted post(1000자), ASR(2000자), card title(100자), article title(200자)
  • backfill의 fav 임계는 통과량에 따라 스스로 조절된다. 구현은 adaptive_min_fav.py (63줄)다.
    • 컨트롤러는 AdaptiveMinFav(start=20000, target_qps=5.0, gain=0.3, deadband=0.1, window_s=30.0, floor=0, cap=20000, warmup_s=60.0)다.
    • 이 PI 컨트롤러는 실제 통과 QPS를 관측해 fav 임계를 30초 window마다 조절한다. 통과 QPS가 target보다 높으면 임계를 올리고, 낮으면 내린다.
    • 부수 조건이 붙지 않는 순수 PI다. error = (qps - target) / target이고, |error| ≤ deadband면 조정하지 않는다. 아니면 min_fav *= 1 + gain * clamp(error, -1, 1)로 바꾼다.
    • TaskBackfillFavGate는 fav count가 모자란 포스트를 task.backfill_gate.dropped.count{reason=low_favs}로 관측하며 스로틀한다.
    • 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 연결은 공개되지 않았다.

C. Phoenix wire format에 ContentFeatures가 들어왔다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/python/common/xai-proto/proto/recsys.proto, home-mixer/models/content_features.rs (185줄 신규).

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

빌더 build(candidate: &PostCandidate) -> ContentFeatures와 build_quoted(...)는 순수 함수다. TextMetrics::of(text, attachment_urls)는 Twitter의 텍스트 길이 계산 규칙을 그대로 따른다.

  • URL은 무조건 23자로 센다(URL_WEIGHTED_LEN). t.co shortener에 대응한 것이다.
  • CJK·이모지 base·punctuation은 2자로 센다(SINGLE_WEIGHT_CODEPOINT_RANGES 밖).
  • ASCII·기본 라틴·특정 range는 1자로 센다.
  • ZWJ·skin-tone modifier(0xFE0E·0xFE0F·0x1F3FB..0x1F3FF·0xE0020..0xE007F)는 0자로 센다.
  • 첫 미디어 attachment URL은 external URL 카운트에서 뺀다(external_urls = urls.saturating_sub(attachment_urls)).

회귀 테스트 5개가 이 규칙을 고정한다.

  • weighted("hello world")=11
  • weighted("日本語")=6
  • weighted("see https://t.co/abc123 now")=3+1+23+1+3
  • weighted("ok 👍🏽")=2+1+2: skin-tone modifier는 0자다.
  • weighted("日本\u{3000}語")=4+2+2: ideographic space도 CJK로 친다.

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에는 실렸지만, 학습에서 소비하는지는 다음 커밋에서 관측할 대상이다.

D. PTOS에 영상 앞부분의 성인 콘텐츠 여부를 다시 판정하는 leading-frames 심층 재스캔 경로가 생겼다 확인 — grox/flows/ptos/plan_safety_ptos_adult_content_leading_frames.py (56줄), grox/flows/ptos/task_safety_ptos_adult_content_leading_frames_filter.py (81줄), grox/flows/ptos/constants.py (+15).

PlanSafetyPtosAdultContentLeadingFrames는 rate_limit → leading_frames_filter → media_hydration → category_detection → policy_detection → safemodel_sex_nudity → cross_validation → sink_write 순서의 8단 DAG다. 진입 필터는 6단 게이트다.

  1. post.user가 있어야 한다.
  2. post.get_fav_count() >= deluxe_fav_count_threshold여야 한다. 4세대+12에서 도입된 deluxe 티어 임계로, 비용 관리용이다.
  3. post.media나 post.quoted_post.media에 30초 이상 영상이 있어야 한다(ADULT_CONTENT_LEADING_FRAMES_MIN_DURATION_SECONDS = 30.0).
  4. prior PTOS 판정이 있어야 한다(StratoSafetyPostAnnotationsResultDirectMh.fetch(post_id)가 non-null). 이미 PTOS 파이프라인을 한 번 통과한 포스트만 대상이다.
  5. prior 판정이 NSFW가 아니어야 한다(prior.safetyBoolMetadata.isNsfw가 false).
  6. prior에 AdultContent violated policy가 있거나, fav count가 512 이상이어야 한다(ADULT_CONTENT_LEADING_FRAMES_UNCONDITIONAL_FAV).

통과한 영상에는 video.crop_seconds = 10.0(ADULT_CONTENT_LEADING_FRAMES_CROP_SECONDS)이 설정돼, 미디어 hydration이 앞 10초만 잘라 판정한다.

deluxe 티어 태스크 유형은 DELUXE_TIER_TASK_TYPES = frozenset({SAFETY_PTOS_DELUXE, SAFETY_PTOS_SPECIAL_VIDEO, SAFETY_PTOS_ADULT_CONTENT_LEADING_FRAMES})이다. 새 kafka topic은 safety-ptos-adult-content-leading-frames다.

의미 이 경로는 4세대+12의 special-video 경로와 4세대+7의 3-카테고리 cross-validator에 이어, 첫 몇 초를 위장하는 시간축 회피를 겨냥한 세 번째 방어 layer다. special-video 경로는 정적 오버레이 회피에 대응하고, cross-validator는 강등만 허용한다. prior에 AdultContent violated policy가 있는데도 isNsfw가 아닌 것은 cross-validator가 강등만 허용하기 때문이다. 이 경로는 강등된 판정을 다시 뒤집을 수 있는 두 번째 심판이다. 무조건 진입(unconditional) 임계인 512 fav는 프로덕션의 중간 인기 포스트 규모다.

E. Phoenix FA4 attention 커널의 SM80·SM90 forward·backward가 전부 오픈소스로 공개됐다 확인 — phoenix/xrex/cutedsl/ranker_fa4/.

바뀐 파일은 새 파일 6개와 수정 파일 4개다.

파일 상태 줄 역할
flash_fwd.py added 1,333 SM80/Ampere forward
flash_fwd_sm90.py added 1,465 SM90/Hopper forward
flash_bwd.py added 1,954 SM80/Ampere backward
flash_bwd_sm90.py added 1,864 SM90/Hopper backward
flash_bwd_preprocess.py added 329 pre-bwd dO·row_sum·softmax_LSE
ampere_helpers.py added 135 SM80 유틸(warp shuffle, register 배치)
block_sparse_utils.py modified +412/−314 block-sparse iterator 재작성
ranker_attention_fa4.py modified +273/−130 fwd 진입점 리팩터
ranker_attention_varlen_fa4.py modified +178/−211 4세대+15 varlen 지원의 이관·정리
mask.py modified +63/−1 AttentionMask 확장 (bwd용)

새로 추가된 코드는 총 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 설정은 m_block_size=64·n_block_size=128·num_stages_Q=2·num_stages_dO=2·num_threads=256이다. _bs_bwd_seq_at constexpr subtile이 block-sparse backward의 mask·full·diag iterator 도메인 인덱스를 매핑한다. flash_bwd_preprocess.py는 forward에서 저장된 softmax_LSE(log-sum-exp)를 활용해 dO·row_sum을 미리 계산한다.

의미:

  • 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).
    • _sanitize_cutlass_calls가 StableHLO stablehlo.custom_call{target=CuteDSLRT_NvJaxCutlassCall}의 backend_config module 필드를 SHA256 digest(device_digest:<hex>)로 바꾼다. 이 필드는 ELF/fatbin 바이트열이다.
    • cutlass_module_device_digest는 ELF 헤더를 파싱해 device blob 섹션만 hash한다.
    • AOT_CACHE_VERSION도 "20260705" → "20260904"로 바뀌었다.
  • 서빙 쪽 checkpoint 탐색이 번들이 끝난 checkpoint만 고르게 됐다 확인 — phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs (+207/−9).
    • full_prefixes_newest_first는 채널 전체에 있는 prefix를 최신 순으로 돌려주는 헬퍼로 분리됐다.
    • select_bundle_checkpoint는 export/MANIFEST.json이 모든 채널에 non-empty로 있는 최신 checkpoint를 고른다. 아직 unbundled인 최신 checkpoint는 warn 로그를 남기고 건너뛴다.
    • download_named_files는 지정한 파일 이름 리스트를 원자적으로 다운로드한다.
    • 4세대+13의 .mlirbc 번들 시스템(StableHLO export)이 서빙 로더에서도 완전히 연결됐다.
  • 서빙용 sequence_len을 자동으로 계산한다 확인 — phoenix/xrex/train/recsys_bundle_export.py (+48).
    • _serving_sequence_len은 training config의 sequence_len - history_seq_len - candidate_seq_len * (1 + num_negatives) - num_global_negatives = prefix_tokens를 역산해, num_user_prefix_tokens와 일치하는지 확인한다.
    • 그다음 서빙 sequence_len = prefix + history + candidate로 attention kernel 크기를 override한다.
    • MANIFEST에도 model_sequence_len이 추가됐다.
  • quote_hydrator.rs가 확장됐다 확인 (+142/−12).
    • MediaInfoCacheClient가 새 dependency로 주입된다.
    • get_quoted_content는 quoted post의 core data와 media info를 200ms timeout 안에서 병렬 조회하고, QuickCache<u64, QuotedContent>로 캐시한다.
    • 이 확장은 4세대+17의 ContentFeatures.quotedContentFeatures 연결의 상류 조건이다.
  • media_info_hydrator.rs의 미디어 정보 조회가 리팩터됐고, Content features 계산에 필요한 max_video_duration_ms 등이 여기서 hydration된다 확인 (+70/−15).
  • home-mixer/bin/pipeline_components_json.rs 파일 전체가 삭제됐다(76줄) 확인. 오프라인 debugging JSON dump 유틸이었을 것이다 추정.
  • PTOS 재분류 트리거가 확장됐다 확인.
    • task_safety_ptos_policy.py (+4/−1)에서 policy detection이 leading-frames 경로의 재판정에도 대응한다.
    • task_rate_limit.py에는 TaskRateLimitSafetyPtosAdultContentLeadingFrames가 추가됐다.
  • 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에서 다뤘다.

이 커밋은 줄 기준 +801/−252, 파일 28개 규모이고, 다섯 축이 함께 움직였다 확인 — xai-org/x-algorithm@75d93d9.

A. VF 판정의 기준이 FilteredReason에서 Action으로 바뀌었다

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을 유도한다. 유도 규칙은 네 가지다.

  • None → Allow
  • Some(SafetyResult) with NotEvaluated → Allow
  • Some(SafetyResult) with other → 그대로
  • Some(other reason) → Drop(DropReason{})

gRPC 경로는 서버가 내려주는 result.action을 그대로 매핑하고, 없으면 NotEvaluated로 둔다. PostCandidate는 visibility_action: Option<Action> 필드를 갖고, VFFilter::filter는 should_drop_action(위 랭킹·필터 절 line 392 참조)으로 후보를 나눈다. should_drop_ancillary도 조상·quoted·retweeted 포스트의 action을 보고 결정한다. FilteredReason::ViewerBlocksAuthor가 정식 variant로 추가됐다 확인. 뷰어가 작성자를 차단하는 경우로, 기존 AuthorBlockViewer의 대칭 방향이다. 다만 아직 이 reason을 리턴하는 rule 코드는 없다 확인 — visibility-filtering-client/vf_client.rs (+130/−62), visibility-filtering-client/models.rs (+7), home-mixer/filters/vf_filter.rs (+27/−69), home-mixer/candidate_hydrators/vf_candidate_hydrator.rs (+159/−23).

⚠ canon 주장이 갱신됐다. 이전 정본은 "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>다.

HeaderImagePrompt의 구조는 아래 필드로 이뤄진다.

  • header_image: MessageImage
  • header_text, body_text
  • header_rich_text(RichText, entity 없음), body_rich_text
  • primary_button_action: MessageTextAction
  • secondary_button_action: Option<MessageTextAction>

각 버튼은 자체 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).

  • header_image_prompt_ad_marshals_to_message_prompt_entry
  • header_image_prompt_ad_emits_secondary_button_when_set
  • header_image_prompt_ad_info_absent_stays_a_tweet_item

C. grox가 30초를 넘는 영상에 preview thumbnail을 붙인다

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).

D. Broadcast(라이브 비디오)가 PTOS 안전 파이프라인에 정식으로 들어왔다

BroadcastLoader가 broadcast_tools.broadcast에서 grox 미디어 로더로 들어왔다. MediaLoader._get_broadcast_loader()는 X_API_BEARER·BROADCAST_GUEST_TOKEN 환경변수로 로더를 lazy init한다. _extract_broadcast_video(broadcast_metadata)는 BroadcastLoader.load_broadcast(broadcast_id, media_key, max_frames, tile_size, max_duration=crop_seconds)로 프레임을 뽑는다. 뽑은 프레임은 Video(convo_video=ConvoVideo(frames, duration=평균간격, total_duration=sampled_duration))로 정규화한다.

PTOS leading-frames filter의 진입 조건이 재구성됐다. 4세대+17에서 정본에 반영한 6-gate 가운데 두 곳이 바뀌었다.

  • Deluxe fav 게이트(gate #2)가 삭제됐다. prior AdultContent 판정이 없는 포스트는 여전히 fav ≥ 512(gate #6)여야 진입한다 확인 — task_safety_ptos_adult_content_leading_frames_filter.py:63-67.
  • 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개다.

  1. post.user 존재
  2. 30초 이상 영상 또는 broadcast_id 존재
  3. prior PTOS 판정 존재
  4. prior 판정이 NSFW 아님
  5. 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에 들어오는지 지켜본다.

F. 그 밖의 변경

  • NUMA CPU affinity 버그가 수정됐다 확인 — phoenix/xrex/utils/gpu.py (+32/−3).
    • 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 코어에 스케줄될 여지가 있었다.
  • candidate-pipeline에서 quiet-spans feature로 gate된 로그 6곳이 삭제됐다 확인 — xai-org/x-algorithm/candidate-pipeline.
    • filter.rs·hydrator.rs·query_hydrator.rs·scorer.rs·selector.rs·source.rs에서 #[cfg(feature = "quiet-spans")] tracing::info!(...) 블록이 각 1개씩 제거됐다(총 −40줄).
    • 에러 로그 문자열 포맷도 error!(component=…, error=%err, "source_failed")에서 error!("{} Failed: {}", self.name(), err)로 되돌아갔다. 구조화 필드가 빠지고 문자열 포맷으로 회귀한 것이다.
  • 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에서 다뤘다.

파일 33개, +1587/−391, 9개 축이 함께 움직였다 확인 — xai-org/x-algorithm@fee1d0f.

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 서비스는 이제 다음 두 가지를 측정할 수 있다.

  1. 호출자가 허용한 데드라인 대비 자기 지연이 얼마나 여유 있는지, 또는 얼마나 초과했는지
  2. 요청 시간이 하이드레이션과 그 뒤 rule engine 단계에 어떻게 배분되는지

이 관측 추가와 A의 timeout 상향은 사실상 같은 프로젝트로 읽힌다. 확인 — visibility-filtering/filter_tweets.rs (+42/−0), visibility-filtering/filter.rs (+6/−1), visibility-filtering/rules/metrics.rs.

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.

J. 부수 변화는 다음과 같다.

  • ExpiringCache::with_clock이 초기화할 때 cache.reserve(capacity)를 호출한다. Safety-label expiring cache가 이제 쓸 capacity를 미리 예약해 첫 삽입에서 재할당을 피한다. 확인 — visibility-filtering/safety_label_source/expiring_cache.rs.
  • 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).
    • _MOTION_REVEAL_MAX_INPUT_FRAMES가 32→48로 바뀌었다.
    • 새 헬퍼:
      • _ecc_align: cv2.findTransformECC
      • _is_static: phaseCorrelate로 정적 프레임 판정
      • _dense_align_residual: Farneback optical flow로 residual 정합
      • _weighted_median: 정합 후 shift한 오버레이의 가중 중앙값
      • _native_chroma: LAB 색공간에서 밝은 픽셀의 중앙 chroma
      • _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에서 다뤘다.

파일 24개, +1397/−338, 8개 축이 함께 움직였다 확인 — xai-org/x-algorithm@6bb4594.

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로 채널을 다음처럼 조립한다.

ChannelBuilder::new(params.name)
    .tls(TlsMode::mtls_from_env()?.with_domain_override(params.tls_domain))
    .request_timeout(STRATO_REQUEST_TIMEOUT)          // = HYDRATION_TIMEOUT (390 ms)
    .connect_timeout(STRATO_CONNECT_TIMEOUT)          // 400 ms
    .xds(StartFrom::Lds(params.xds_listener))         // TES: "tweet-entity-service.prod.tweet-entity-service:fed-grpc"
                                                       // Gizmoduck: "gizmoduck.prod.gizmoduck:fed-grpc"
    .eager_resolution(Duration::from_secs(5))
    .aperture(STRATO_APERTURE_SIZE)                    // 12
    .readiness_probe(READINESS_PROBE_PORT, "/ready")
    .buffer_max_wait(DEFAULT_BUFFER_MAX_WAIT)
    .total_timeout(DEFAULT_TOTAL_TIMEOUT)
    .build_load_balanced(params.lb_policy)             // TES: penalized peak-EWMA / Gizmoduck: least-request

TES 경로만 enable_serve_within: true로 서버측 데드라인 힌트(OpContext.serve_within, round_trip_allowance_microseconds = 10_000)를 실어 준다. Gizmoduck은 이 옵션을 끈다. retry_config: Some(RetryConfig::for_idempotent())도 TES 경로에만 설정된다.

이 마이그레이션이 바꾸는 것은 세 가지다.

  • 서비스 발견 계층이 hostname 템플릿에서 xDS LDS(Envoy 계열 리스너 이름) 경유로 옮겨졌다.
  • TESClientConfig·GizmoduckClientConfig처럼 서비스 crate 안에 있던 별도 client-config 구조체가 없어졌다. 클라이언트 조립 로직은 VF server_deps.rs로 흡수됐다.
  • L2 cache warmer의 Strato 클라이언트도 이제 deterministic_aperture 플래그를 받는다.

4세대+19의 HYDRATION_TIMEOUT 통일이 여기까지 파급됐다는 관점으로도 읽힌다. 확인 — visibility-filtering/server_deps.rs (+119/−48).

D. Phoenix proto에 LLM-judge search relevance action 두 개와 returnLogitsList 응답 형식이 생겼다. phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto와 파이썬 미러 양쪽에 다음 정의가 붙었다.

enum ActionName {
  ...
  CLIENT_TWEET_SEARCH_QUERY_REFORMULATED = 104;
  CLIENT_TWEET_RELEVANT_TO_SEARCH = 105;
  CLIENT_TWEET_NOT_RELEVANT_TO_SEARCH = 106;
  LLM_JUDGE_RELEVANT_TO_SEARCH = 107;
  LLM_JUDGE_NOT_RELEVANT_TO_SEARCH = 108;
  ...
}

message PredictNextActionsRequest {
  ...
  bool returnLogitsList = 22;
}

message NextActionDistribution {
  ...
  repeated float requestedActionLogits = 12;
}

액션 두 개는 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();

즉 응답은 requested_action_indices의 순서를 그대로 유지한 positional list다. 요청한 action index가 모델 vocab을 벗어나면 -inf로 채운다. 기존 return_log_map이 sparse HashMap을 wire에 실어 보내던 것을 positional dense array로 압축한 것이다. key overhead가 사라지고 순서가 보장된다. 두 형식은 상호배타적으로 하나만 켜지도록 되어 있고, 둘 다 켜면 return_logits_list가 우선한다. 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto (+6), phoenix/python/common/xai-proto/proto/recsys.proto (+6), phoenix/crates/serving/xai-recsys-engine/src/python.rs (+19/−6), phoenix/crates/serving/xai-recsys-engine/src/util.rs (+2).

E. VF에 NonZeroU64로 packed한 8-byte 표현 CompactAuthorFeatures가 생겼지만, 아직 연결되지 않았다. visibility-filtering/models/author.rs에 다음 코드가 붙었다.

pub(crate) struct CompactAuthorFeatures(NonZeroU64);

const COMPACT_PRESENT_BIT: NonZeroU64 = match NonZeroU64::new(1 << 63) { ... };
const COMPACT_LABEL_SHIFT: u32 = 8;
const COMPACT_LABEL_MASK: u64 = (1 << (63 - COMPACT_LABEL_SHIFT)) - 1;

impl AuthorFeatures {
    pub(crate) fn to_compact(self) -> CompactAuthorFeatures { ... }
    pub(crate) fn from_compact(compact: CompactAuthorFeatures) -> Self { ... }
}

레이아웃은 세 부분으로 나뉜다.

  • 63번 비트: present 마커
  • 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를 쓴다.
  • SASL_PLAIN_PASSWORD가 있으면 SASL로 fallback한다.

Phoenix 학습이 소비하는 recsys 액션 스트림 Kafka가 SASL/SCRAM 외에 mTLS로도 붙을 수 있게 됐다. 실제 운영에서 어느 쪽이 채택되는지는 이 코드만으로는 알 수 없다. 확인 — phoenix/xrex/data/streaming/kafkaloader.py (+159/−9), phoenix/xrex/data/streaming/kafkaconsumer.py (+96/−40), phoenix/xrex/data/rust_kafka_recsys.py (+23/−5).

I. 부수 변화는 다음과 같다.

  • Brazil2026ElectionFilter의 하드코딩 계정 리스트가 정비됐다. 정치인 계정이 활성화·비활성화되는 것을 반영하는 정기 관리로, 정책 변화는 없다. 확인 — home-mixer/filters/brazil_2026_election_filter.rs (+334/−42).
    • 계정 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).

파일 17개, +592/−9, 5개 축이 함께 움직였다 확인 — xai-org/x-algorithm@2d4a03c.

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.*로 시작한다.

  • 크기: Size
  • 작성자: UniqueAuthors·UniqueAuthorRatio·MaxAuthorShare, AuthorHhi·AuthorEntropyNorm
  • 소스: UniqueSources·SourceEntropyNorm(축=served_type, retrieval 소스)
  • 그 밖: InNetworkShare, SidCoverage
  • SID L1 축 3개: UniqueSidL1·MaxSidL1Share·SidL1EntropyNorm
  • SID L2 축 2개: UniqueSidL2·SidL2EntropyNorm

HHI(Herfindahl-Hirschman Index, 시장 집중도 지표를 슬레이트에 적용한 것)와 entropy / ln(size)로 정규화한 shannon entropy를 함께 계산한다. 이렇게 해서 슬레이트 카디널리티 편차와 무관하게 다양성을 비교할 수 있게 한다. 확인 — home-mixer/side_effects/response_diversity_stats_side_effect.rs, home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs (+2), home-mixer/params/param.rs (+7/−1).

의미: 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)에 정의됐다.

슬레이트 전역:

  • UNIQUE_AUTHOR_RATIO_LTE_50_PCT = 239: 슬레이트의 unique_ratio ≤ 0.5
  • UNIQUE_AUTHOR_LTE_5/10/15 = 240/241/242
  • SINGLE_AUTHOR_GTE_25_PCT/50_PCT = 243/244

슬레이트 문맥 (score 내림차순 순회):

  • 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 코드 슬라이스를 안전하게 잘라 낸다.

의미: TweetTypeMetrics는 랭킹 학습·서빙 로그와 오프라인 분석의 축이다. 그래서 이제 슬레이트 다양성, 작성자 문맥, SID 문맥이 후보별 feature로 로깅되고 학습·모델링에서 참조 가능해진다. 다만 이번 커밋에는 이 bit들을 실제로 소비하는 학습 config 변경이 없어, 통로만 열렸다. 실제 소비는 후속 커밋에서 관측할 대상이다. 이 bit들이 랭커 feature로 승격되면, X 랭커가 pointwise → slate-aware 스코어링으로 옮겨 간다는 신호가 된다. 확인 — home-mixer/candidate_hydrators/tweet_type_metrics_hydrator.rs, home-mixer/util/tweet_type_metrics.rs, home-mixer/models/candidate.rs.

D. 뷰어 이력에서 긍정 반응을 보인 작성자 집합을 뽑는 함수가 생겼다.

새 함수 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를 모아 반환한다.

ServerTweetFav, ServerTweetReply, ServerTweetQuote, ServerTweetRetweet,
ClientTweetClick, ClientQuotedTweetClick, ClientTweetOpenLink,
ClientTweetClickProfile, ClientTweetPhotoExpand, ClientQuotedTweetPhotoExpand,
ClientTweetVideoOpen, ClientTweetVideoQualityView, ClientQuotedTweetVideoQualityView,
ClientTweetShare, ClientTweetClickSendViaDirectMessage, ClientTweetShareViaCopyLink,
ClientTweetBookmark, ClientTweetFollowAuthor

리포트, 뮤트, 블록, 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).

같은 커밋에서 PredictNextActionsRequest에 새 필드 23이 생겼다.

repeated PurchaseValueBaseline purchase_value_baselines = 23;

message PurchaseValueBaseline {
  int64 impression_id = 1;
  int64 advertiser_account_id = 2;
  double mean_value_usd_28d = 3;
}

각 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다.
  • find_baseline(request, impression_id, advertiser_account_id)
  • wire tag 안정성 unit test 5개: field 23 = 0xBA 0x01이 returnLogitsList field 22 = 0xB0 0x01 0x01와 독립인지, 구 요청과 하위 호환되는지 본다.

의미: 광고 랭킹이 지금까지 광고 클릭·컨버전의 확률만 예측했다면(4세대+6의 conversion 20개 액션 포함), 새 head는 금액 자체를 회귀 target으로 받는다. 광고주별 28일 baseline도 요청에 함께 실어, residual 학습을 가능하게 할 연결을 준비했다. 직접 학습 target인지 baseline offset regression인지는 이번 커밋에 명시되지 않았다. Value-optimizing 옥션(Meta VBB, Google tCPA/tROAS와 같은 방향)의 첫 서빙 연결로 읽힌다 추정. Baseline이 요청마다 실려 온다는 것은 별도 오프라인 파이프라인이 광고주 계정의 28일 평균값을 매일 갱신해 서빙 요청에 병렬로 넣는다는 뜻이므로, 요청 시점에 사전 pruning으로 고른 광고주의 baseline만 붙는 형태일 가능성이 높다 추정. 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto, phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs, phoenix/python/common/xai-proto/proto/recsys.proto.

F. 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).

G. Phoenix 학습의 Kafka OTel 지표 export 간격이 30s에서 120s로 늘었다.

_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.

확인 — grox/flows/reply_spam/task_filter.py (+1/−1).

I. 그 밖에 param 미러 시각이 갱신됐다.

  • 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).

파일 26개, +552/−233, 5개 축이 함께 움직였다 확인 — xai-org/x-algorithm@fad2f71.

A. 광고 구매액 head가 학습 loss까지 이어졌지만, 기본 설정에서는 꺼져 있다.

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-*로 리네임됐다.
  • purchase_value_loss_compute는 Huber loss(default delta=1.0)를 잰다. 입력은 셋이다.
    • raw_ratio: GT ratio
    • pred_ratio: 모델 예측
    • baseline_mean_usd: 광고주 28일 평균
    • 배출 stat은 7종이다: purchase-value-loss·-valid-count·-weight-sum·-ratio-mae·-target-ratio·-pred-ratio·-baseline-mean-usd.
    • loss의 argument는 raw ratio이지 raw USD가 아니다. 학습 target은 광고주의 28일 평균 대비 상대 비율이다. baseline_mean_usd는 요청에 실려 오는 offset이고, stat 계산 용도로만 쓰인다.

recsys_model.py(+236/−26)의 loss 루프에는 purchase_value_loss_weight > 0이면 실행되는 블록이 추가됐다.

  • candidate_seq에서 trained_candidate_mask·value_label_valid·value_baseline_mean_usd를 꺼내 mask를 만든다.
  • candidate_continuous_actions[:, :, PURCHASE_VALUE_ACTION_INDEX](=slot 5)를 raw ratio로, candidate_continuous_preds[:, :, 5]를 예측으로 삼아 loss를 산출한다.
  • 이 loss를 purchase_value_loss_weight로 가중해 continuous_action_loss_total에 합산한다.

기본값이 purchase_value_loss_weight = 0.0이라 기본 config에서는 여전히 꺼져 있다. 켜려면 mparams["purchase_value_loss_weight"]를 명시적으로 넘겨야 한다.

새 checker validate_purchase_value가 활성 조건을 강제한다.

  • num_continuous_actions > 5: 슬롯 5가 실존해야 한다.
  • output_vocab_size > ADS_PURCHASE_CONVERSION: 액션 head 200이 실존해야 한다.
  • purchase_value_huber_delta > 0 && finite
  • 동일 슬롯에 generic continuous loss가 붙어 있으면 안 된다. 구매액 슬롯 5는 전용이다.

Purchase value head가 활성일 때 서빙 활성화 함수는 softplus(양수 실수)로 고정된다.

wire 측에는 새 필드 4개가 들어왔다(phoenix/xrex/data/recsys/recsys_batch.py +53, parquet_recsys.py +4, feature_config.py +2).

  • PostSeq에 value_label_valid: bool·value_baseline_mean_usd: float32 슬롯이 추가됐다.
  • Parquet 리더 _read_value_labels가 valueLabelValidSeq·valueBaselineMeanUsdSeq 컬럼을 pa.list_(dtype, seq_len) 형식으로 읽어 (batch, seq) 배열로 unpack한다.
  • Feature 리스트에 두 컬럼이 등록됐다.

이로써 광고주 28일 baseline이 학습 시점에도 배치마다 병렬 주입된다. 서빙 요청 필드 purchase_value_baselines는 4세대+21에서 이미 열려 있었다.

의미: 광고 랭킹이 학습 파이프라인 단계까지 value-optimizing 옥션 방향으로 정식 진입했다. Meta VBB(Value-Based Bidding)·Google tCPA/tROAS와 학습 target 유사도가 높다. 실제 활성 여부는 다음 커밋에서 mparams["purchase_value_loss_weight"] 값이 등장하는 시점에 결정된다. 학습 target이 광고주 baseline 대비 ratio라는 점은 광고주 규모 정규화를 학습 target 자체에 이식한 것이다. 이 정규화는 "1만 원 광고주"와 "10만 원 광고주"의 loss 스케일을 통합한다. 확인 — phoenix/xrex/models/loss_recsys.py, phoenix/xrex/models/recsys_model.py, phoenix/xrex/data/recsys/recsys_batch.py, phoenix/xrex/data/recsys/constants.py.

B. 새 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에서는 기본값 두 개가 바뀌었다.

  1. 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"다.
    • 이진화와 정규화 설정은 binary_threshold=10.0, norm_config=NormConfig(norm_scale=60.0)이다.
  2. 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를 계산한다.

  • 유효한 쌍은 pair_valid = mask_i & mask_j & (dy != 0)이다.
  • concordant는 sign(dy) == sign(dp)이고, tied는 dp == 0이며 0.5로 가중한다.
  • Metric 값은 (concordant + 0.5·tied) / pair_valid_count다. pair_valid_count == 0이면 0을 반환한다.
  • 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.
  • 모델 라우팅 임계는 150k다. 전용 모델은 150k 초과부터 쓰인다.
  • 그래서 이 커밋 기준으로 전용 모델은 두 임계 사이 30k 대역(150k 초과 180k 이하) 스레드에서만 쓰인다 확인 — classifier_simple_reply_scorer.py:35-45.

의미: 큰 계정 스레드에서 나오는 리플라이 스팸 패턴이 일반 스팸과 통계 분포가 다르다는 판단 아래 전용 파인튜닝 모델을 쓰는 방향이다 추정. 4세대+21의 coordinated_spam 임계 10k→30k와 짝으로 읽으면, 전체 스팸 파이프라인이 "높은 팔로워 대역과 낮은 팔로워 대역을 각기 다른 모델·기준으로 처리한다"는 방향으로 세분화되는 중이다 추정. 확인 — grox/flows/reply_spam/constants.py, grox/flows/reply_spam/classifier_simple_reply_scorer.py, grox/flows/reply_spam/task_filter.py.

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 빈 문자열도 통과한다.

의미: AES가 실행한 판정을 원본 score run과 조인 가능한 상태로 로깅한다. 정책 위반 판정을 특정 스코어 배치와 back-reference하고, 판정 재현·감사·rollback에 대응할 때 쓸 조인 키가 생긴다. 확인 — abuse-enforcement-service/service-lib/src/lib.rs.

L. VF enum의 문자열 변환이 매크로로 통일됐고, 동작은 바뀌지 않았다.

VF enum 13개가 strum::IntoStaticStr 매크로로 통일됐다(파일 7개, +53/−184) 확인 — 2d4a03c와 fad2f71의 visibility-filtering/ 비교. 파일별 줄 수 변화는 아래와 같다.

  • visibility-filtering/safety_label_source/metrics.rs(-76순)
  • safety_label_source/types.rs(-25순)
  • rules/registry.rs(-9순)
  • hydration/metrics.rs(-10순)
  • twemcache/metrics.rs(-11순)
  • reference_compare.rs(+0/−0), rules/metrics.rs(+0/−0)

손으로 짠 as_str() 구현을 이 enum들에서 지웠다.

  • 대상 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는 무변화)
  • user_gender 인덱스 매핑 변경이 프로덕션 임베딩 테이블 재학습과 짝지어 배포됐는지
  • EnableAdsBrandSafetyVerdictV2 롤백의 사유
  • VMRanker DSCP=InferenceCritical의 실측 지연 개선 폭

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.

A. 홈믹서에 새 필터 FavHoldoutFilter가 생겼다. 새 파일은 home-mixer/filters/fav_holdout_filter.rs(166줄 신규)이다. 이 필터는 home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs에서 InventoryHoldoutFilter 다음에 연결된다. 홀드아웃 확률은 fav count 23-tier 계단 테이블로 결정된다.

const STEPS: &'static [(i64, u32)] = &[
    (10000, 15), (5000, 15), (3000, 15), (2000, 14), (1500, 13), (1000, 13),
    (750, 12), (500, 11), (300, 10), (200, 9), (150, 8), (100, 7),
    (75, 7), (50, 6), (30, 6), (20, 5), (15, 5), (10, 5),
    (7, 4), (5, 4), (3, 3), (2, 3), (1, 2),
];

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 하나가 들어왔다.

fn click_dwell_term(&self, scores: &PhoenixScores) -> Option<f64> {
    if !self.enable_cdwell_on_impr { return scores.click_dwell_time; }
    match (scores.click_dwell_time, scores.click_score) {
        (Some(cd), Some(click)) => Some(cd * click),
        (cd, None) => cd,
        (None, _) => None,
    }
}

이전 함수 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로 꺼져 있다.

D. Author cold-start scorer의 Treatment 팔에서 두 게이트가 함께 완화됐다. 바뀐 파일은 home-mixer/scorers/author_cold_start.rs(+7/−50)와 home-mixer/params/param.rs다.

  • 이전에는 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) 필터를 건다.
  • ColdStartMaxPostAgeSecs 기본값도 86400 → 172800(24시간 → 48시간)으로 늘었다.
  • 회귀 테스트도 이름을 바꾸며 정리됐다.
    • treatment_viewer_cold_starts_treatment_moe_only → _treatment_authors_only
    • treatment_skips_post_older_than_max_post_age → skips_post_older_than_max_post_age
    • control_ignores_max_post_age는 삭제됐다.

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는 다음을 정의한다.

pub enum EvaluationResult { Evaluated(Box<Action>), NotEvaluated, Failed }
pub const MAX_ACTION_BYTES: usize = 64 * 1024;
pub fn decode_action(bytes: &[u8]) -> Result<Action>

디코딩 규칙은 다음과 같다.

  • 64KB payload 상한(TConfiguration의 4개 상한 모두 64KiB)
  • Compact Thrift 프로토콜
  • trailing byte 금지
  • Action::NotEvaluated 거부

의미 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).

파일 30개, +1491/−154줄이 바뀌었다 확인 — xai-org/x-algorithm@c279172.

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 헤드는 학습에서 제외되어 중복 관측을 피한다.

메트릭 슬라이스도 재정의됐다. build_metric_masks는 condition_conversion_on_click·split_head_training_by_source 파라미터를 잃고 ads_head_masking 하나만 받는다. 새 슬라이스 late_split_no_early(= mask × late × click × no_early)가 생겼고, 기존 fresh·delayed_clicked·delayed_non_clicked는 유지된다. 확인 — phoenix/xrex/models/ads_head_masking.py (67줄 신규), phoenix/xrex/data/recsys/ads_late_window.py (29줄 신규), phoenix/xrex/models/recsys_model.py (+50/−55), phoenix/xrex/train/trainer_recsys.py (+19/−2).

의미 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로 재초기화한다.

새 스탯 두 개도 발행된다 확인 — phoenix/xrex/models/loss_recsys.py (+75/−10), phoenix/xrex/train/trainer_recsys.py (+19/−2).

  • 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)에 똑같이 반영됐다.

EngagementCounts 메시지는 (action × time-window) 카운트 매트릭스를 요청 페이로드에 담는다.

message EngagementCountRow { int32 action = 1; repeated uint32 counts = 2; }
message EngagementCounts {
  bool present = 1;
  repeated uint32 window_secs = 2;   // 시간 창 길이 리스트
  repeated EngagementCountRow rows = 3;  // 액션마다 창별 카운트
  int64 updated_at_ms = 4;
  int64 window_end_ms = 5;
}

이 메시지는 두 슬롯에 연결됐다.

  • PredictNextActionsRequest.user_ads_engagement_counts = 24는 유저의 광고 참여 이력이다.
  • AdInfo.engagement_counts = 23은 광고 자체의 참여 이력이다.

서버 측 학습과 인코딩은 이번 커밋에 없다.

SearchLexicalMatch는 TweetInfo.searchLexicalMatch = 38으로 붙었고, 다음 필드를 담는다.

  • float matchedWordFraction은 쿼리 단어 중 트윗에 등장하는 비율이다.
  • bool exactPhrase는 쿼리 문자열이 완전 일치로 등장했는지를 나타낸다.

xrecsys_search에서 이 신호를 소비하는 지점은 이번 커밋에 명시되지 않았다.

의미 EngagementCounts는 4세대+9의 ConvAssetIds(광고 크리에이티브 asset ID 이력)에 이어, 유저·광고 페어의 최근 참여 통계가 랭커에 명시 피처로 넘어가는 두 번째 채널이다. SearchLexicalMatch는 X 검색 랭킹 라인에 렉시컬 매치 시그널이 wire 필드로 처음 편입된 사례다. EarlyBird BM25 계열 대신 (matched fraction, exact phrase) 두 축으로 단순화했다. 확인 — 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> 형식의 판정을 요구한다.

MediaInjectedAdultInfraredVideoSpamVerdict 파싱에 성공하고 violation=True이면 SafetyPtosState에 위반 정책을 추가한다. 결과는 TaskWriteSafetyPostAnnotationsResultSink가 안전 파이프라인 sink로 흘려 보낸다. 확인 — grox/flows/ptos/plan_safety_ptos_media_injected_adult_infrared_video_spam_detection.py (44줄), grox/flows/ptos/task_safety_ptos_media_injected_adult_infrared_video_spam_detection.py (188줄), grox/core/data_loaders/media_reference_bundle.py (146줄).

의미 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은 각 배치 키를 세 종류로 분류한다 확인(같은 파일).

  • packing_layout.*은 packing_layout이다.
  • REQUEST_BATCH_KEYS에 포함되면 source="request"이고, 요청 페이로드에서 채워진다.
  • 나머지는 source="template"이다. example_data의 균일 상수를 fill 값으로 굳혀 번들에 넣는다.

균일하지 않은 template은 오류를 낸다. 즉 서빙 번들의 요청 계약과 상수 계약이 명시적으로 분리된다. 확인(같은 파일)

F. abuse-enforcement-service의 Kafka 라우팅에 토픽별 cluster/zone override가 생겼다. 컨슈머·프로듀서 config가 서비스 전역 mTLS cluster/zone 대신 토픽마다 override할 수 있게 됐다. 새 메트릭 abuse_enforcement_kafka_consumer_start_total{topic,cluster,result}은 부팅 시점의 컨슈머 시작 결과를 라벨링한다. consumer_error_total에는 감시 대상 토픽 집합만 세도록 필터도 붙어, 감시 대상이 아닌 토픽의 에러는 워치독을 트리거하지 않는다. 확인 — abuse-enforcement-service/service-lib/src/lib.rs (+390/−56), abuse-enforcement-service/service-lib/src/growthbook.rs (+35/−6), abuse-enforcement-service/service-lib/src/metrics.rs.

G. grox에서 작은 정리가 있었다.

  • _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).

파일 87개, +7,780/−5,759줄이 바뀌었다 확인 — xai-org/x-algorithm@bf7db1b.

A. Hydration이 함수 계층에서 데이터 테이블과 스케줄러로 다시 쓰였다. 어제(4세대+28)까지는 hydrator variant마다 별도 .rs 파일에 자기 함수가 있었다. conversation_control_hydrator.rs·exclusive_content_hydrator.rs·socialgraph_hydrator.rs 등이 그런 파일이다. 이 구조가 전부 삭제되고, 실행은 다음 3파일로 옮겨졌다.

  • visibility-filtering/hydration/plan.rs는 471줄짜리 새 파일이다. 각 Hydrator variant의 NodeSpec { source, part, key, label } 데이터를 Hydrator::spec() const fn으로 등록한다.
    • Source는 backend이고, TesPureCore·TesComposite·TesConversationControl·SafetyLabels·GizmoduckViewer·GizmoduckAuthor·Flock·ViewerCountry의 8종이다.
    • 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를 병합한다.
    • compile-time assert 3개(inputs_precede_nodes·author_keys_come_from_pure_core·Hydrator::VARIANTS.len() <= u32::BITS)가 스케줄 순서와 bitset 폭 불변식을 잠근다.
  • visibility-filtering/hydration/execute.rs는 1,280줄짜리 새 파일이다. HydrationPlan::hydrate(sources, request)가 FuturesUnordered로 dependency graph를 스케줄한다. 알고리즘은 아래 순서로 진행한다.
    • ready 큐를 input.is_none()인 그룹으로 초기화한다.
    • 각 ready 그룹의 RPC를 Call<'a> future로 감싸 running에 push한다.
    • 완료된 그룹의 결과를 Store<'a>에 저장한다. 결과는 Reply enum(PureCores·Tweets·Controls·Labels·Viewer·Authors·Edges·ViewerCountry)이다.
    • 이 그룹을 dependency로 갖던 하위 그룹을 ready로 승격한다.
    • 이 과정을 반복한다.
  • visibility-filtering/hydration/sources.rs는 445줄짜리 새 파일이다. Sources trait는 8종 backend 각각에 대한 메서드 8개를 정의한다.
    • 메서드는 pure_cores(tweet_ids)·tweets(tweet_ids)·conversation_controls(tweet_ids)·safety_labels(tweet_ids)·viewer(viewer_id, fields)·users(user_ids, fields)·select_edges(viewer_id, queries)·viewer_country(viewer_id)다.
    • 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를 검증한다.

  • PureCore·Tweet·ConversationControl·TweetSafetyLabels·ViewerProfile·AuthorSafety·AuthorLabels·Follows·Blocks·Mutes·MuteRetweets·BlockedByAuthor·BlockedByReplyRoot·SuperFollowsExclusive·RootFollowsViewer·SuperFollowsRoot·ViewerCountry

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 그래프에서 각 호출은 아래 조건이 채워지면 실행된다 확인(같은 파일).

  1. 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만 있으면 바로 시작한다.
  2. gizmoduck/get_users는 pure_core가 완료된 뒤에 실행된다. 이 호출은 author_safety와 labels 필드의 합집합을 조회한다.
  3. blocked_by/batch_check_blocked_by는 pure_core가 완료된 뒤에 실행된다.
  4. exclusive_content/batch_check_super_follows는 tweet이 완료된 뒤에 실행된다.
  5. 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가 그 데이터 원천을 직접 소비한다.

D. rules/golden_corpus가 15개 파일로 나뉘었다. 1,493줄짜리 단일 파일 visibility-filtering/rules/golden_corpus.rs가 삭제되고, visibility-filtering/rules/golden_corpus/ 아래 15개 파일로 정책 카테고리별로 다시 나뉘었다. 이제 카테고리별로 verdict 매트릭스를 따로 pin할 수 있다.

  • 파일별 줄 수는 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은 이번 커밋에 명시적으로 나오지 않는다.

  • AFR(58개국)·AUS(33개국)·CAS(6개국: AF·KG·KZ·TJ·TM·UZ)·EAS(8개국: CN·HK·JP·KP·KR·MN·MO·TW)·EUR(54개국)·NAM(40개국)·SAM(14개국)·SAS(7개국: BD·BT·IN·LK·MV·NP·PK)·SEA(11개국: BN·ID·KH·LA·MM·MY·PH·SG·TH·TL·VN)·WES(19개국: AE·AM·AZ·BH·CY·GE·IL·IQ·IR·JO·KW·LB·OM·PS·QA·SA·SY·TR·YE)

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가지다.

  1. 인터페이스가 함수에서 데이터로 바뀌었다. 어제 구조에서는 실행이 여전히 hydrator variant마다 별도 함수나 파일이었지만, 오늘은 실행도 데이터가 됐다. rule을 추가할 때 hydrator 함수를 새로 쓸 필요 없이 NodeSpec 테이블에 한 줄을 더하면 된다.
  2. RPC 그룹 병합이 자동화됐다. 같은 backend와 같은 입력을 갖는 hydrator들은 자동으로 한 RPC로 병합된다. gizmoduck의 SAFETY와 LABELS 필드 조회, socialgraph의 Follows·Blocks·Mutes·MuteRetweets 4-edge 조회가 그런 예다. hydration 예산이 gizmoduck·socialgraph 같은 fan-out 서비스에 몰리는 X 구조에서는 이것이 P99에 직접 영향을 준다.
  3. 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).

이 커밋은 파일 80개를 바꿨고, 줄 수는 +9,130/−2,652다 확인 — xai-org/x-algorithm@4c5cfe8.

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다.
    • positive cache는 60초(최대 3,600초)이고, 응답 본문 상한은 64 KiB다.
    • moka 캐시의 CACHE_MAX_WEIGHT는 100,000, 별도 상수인 CONFIG_PUBLISH_INTERVAL은 15초다.
  • 회로 차단기는 실패가 BREAKER_FAILURES=5에 이르면 열리고, 열린 뒤 DEFAULT_BREAKER_OPEN=10s 동안 기다린다. 도달 가능한 에러(예: HTTP 5xx)가 5회 연속 나면 REACHABLE_ERROR_BACKOFF=5s만큼 back-off한다.

AES(abuse-enforcement-service)의 lib.rs에 게이트가 연결된 지점은 두 곳이다 확인 — abuse-enforcement-service/service-lib/src/lib.rs:166-509.

  • 시작할 때 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 구조체를 정의한다.

필드는 Option<...> 8개다.

  • pure_cores: 트윗 골격
  • tweets: TweetForVisibility
  • controls: ConversationControl arm
  • labels: SafetyLabelMap
  • viewer: ViewerProfile
  • authors: DecodedAuthor
  • edges: Vec<(Hydrators, RawHydrationBatch<Hydrators>)>
  • viewer_country: Arc<str>

Store::keys(origin: KeyOrigin) -> Vec<u64>는 하위 hydrator에 필요한 입력 키를 origin 축별로 뽑아 리졸브한다. origin 축에는 RequestTweets·Viewer·ViewerForCoAllowedList·PureCoreAuthor·PureCoreReplyRoot·ExclusiveConversationAuthor 등이 있다. Store::callable: Hydrators 필드는 이번 요청에서 실행할 수 있는 hydrator를 bitset으로 고정한다.

같은 커밋에서 전날 커밋(4세대+29)이 남긴 잔재 두 개가 삭제됐다.

  • visibility-filtering/hydration/safety_label_hydrator.rs는 −167줄로, 파일이 삭제됐다.
  • visibility-filtering/hydration/tes_hydrator.rs는 −79줄로, 파일이 삭제됐다.

두 파일은 전날 데이터 기반 스케줄러로 옮겨 갈 때 기능이 필요 없어졌지만, 24시간 유예된 채 남아 있다가 이번에 완전히 제거됐다.

C. socialgraph 직접 호출을 wingman 경로로 옮기는 follows 클라이언트가 생겼다.

visibility-filtering/clients/wingman_client.rs가 54줄로 새로 들어왔다 확인.

const FOLLOWS_GRAPH: u32 = 1;
const FOLLOWS_FLEET: Fleet<'static> = Fleet {
    wily_path: "/s/wingman/wingman-api-follows",
    authority_template: "wingman.wingman-api-follows.prod.{datacenter}.s2s.twttr.net",
    ...
};

인터페이스는 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 모듈이 생겼다.

visibility-filtering/rules/registry.rs는 +206/−203으로 사실상 완전히 다시 쓰였다. 핵심은 SafetyLevel마다 규칙 세트를 담는 Policy<'a> 구조체다. 다만 이 구조체와 아래 static 5개는 4세대+29(bf7db1b)에 이미 같은 모양으로 있었다 확인 — registry.rs:23-29, 125-170.

pub(super) struct Policy<'a> { base: &'a [RuleClause], additional: &'a [RuleClause] }
static FILTER_ALL_POLICY: Policy = Policy::new(&[tweet_rules::FILTER_ALL]);
static TIMELINE_HOME_POLICY: Policy = Policy::new(&TIMELINE_HOME_SHARED_RULES);
static TIMELINE_HOME_RECOMMENDATIONS_POLICY: Policy = Policy::with_additional(...);
static TIMELINE_HOME_HYDRATION_POLICY: Policy = Policy::new(&[...]);
static IMMERSIVE_EXPANDED_RECOMMENDATIONS_POLICY: Policy = Policy::new(&[...]);

RuleEngine::select(level) -> &'static Policy는 요청된 SafetyLevel마다 5개 static 중 하나를 참조한다. Policy::with_additional(base, additional)은 두 슬라이스를 합쳐 파생 정책을 만든다. RuleEngine::wired_rule_names(level)은 정책에 연결된 rule 이름 목록을 반환한다 확인 — registry.rs:346-460. 골든 테스트는 등록된 다섯 SafetyLevel 각각에 대해 wired_rule_names가 반환하는 리스트를 assertion으로 고정한다 확인(같은 파일).

visibility-filtering/rules/metrics.rs도 58줄로 새로 들어왔다. 전날까지는 hydration의 metrics.rs가 rule 판정 관련 metric까지 맡았는데, 판정과 하이드레이션이 분리되면서 metric도 분리됐다.

  • rule_spec.rs(+149/−74)에서 RuleClause·ActionSpec이 리팩터됐다.
  • filter.rs는 −94줄(+26/−120), context.rs는 −53줄(+9/−62)로 줄었다. Verdict 트리가 안착하면서 필터 계층이 rule 실행 조율에서 손을 뗀다.

E. Phoenix의 광고 구매액 target이 카탈로그로 명시됐다.

phoenix/crates/serving/xai-recsys-proto/src/purchase_value.rs가 +230/−4로 바뀌었다. 광고 구매액 continuous head의 event × level 조합이 데이터 구조로 명시됐다.

pub const ACTION_INDEX: usize = ContinuousActionName::AdsWebCtPurchaseValue as usize;
pub const MAX_EVENT_USD: f64 = 100_000.0;
pub fn is_countable_usd(usd: f64) -> bool {
    usd.is_finite() && usd > 0.0 && usd <= MAX_EVENT_USD
}
pub enum ValueEvent { Purchase, ContentView, AddToCart }
pub enum ValueLevel { ClickThrough, ViewThrough }
pub struct ValueTarget { event: ValueEvent, level: ValueLevel }
impl ValueTarget {
    pub const ALL: [ValueTarget; 6] = [ ... ];
    pub const PUBLISHED: [ValueTarget; 4] = [ ... ];
    pub const REQUIRED: [ValueTarget; 2] = [Self::PURCHASE_CT, Self::PURCHASE_VT];
}

카탈로그는 아래와 같다.

  • ValueEvent는 3종이다 확인.
    • Purchase: conversion_type 2, clickhouse purchase
    • ContentView: 19, content_view
    • AddToCart: 12, add_to_cart
  • ValueLevel은 2종이다 확인.
    • ClickThrough: 광고 클릭 뒤에 발생
    • ViewThrough: 노출만 있고 클릭 없이 발생
  • REQUIRED 2 target은 purchase_ct, purchase_vt이고, 모델이 반드시 예측한다 확인.
  • PUBLISHED 4 target은 purchase_ct, purchase_vt, content_view_ct, add_to_cart_ct이고, 광고주 웹훅 대상이다 확인.
  • is_countable_usd에 따라, 이벤트 하나가 $100,000 USD를 넘거나 NaN·음수·무한대이면 학습·평가에서 제외된다 확인.
  • ValueTarget::mean(&PurchaseValueBaseline)은 target별 mean을 baseline에서 읽는다(4세대+21에서 신설된 PurchaseValueBaseline request 필드의 소비 계약이 이번에 확정됐다) 확인.

구매액 예측 계층은 아래 세 커밋에서 파편적으로 공개됐고, 이번 커밋에서 그 target 축이 데이터로 고정된다.

  • 4세대+21(2026-09-15, ADS_WEB_CT_PURCHASE_VALUE 신설)
  • 4세대+22(2026-09-16, purchase-value loss 정식 연결)
  • 4세대+24(2026-09-18, purchase-value loss가 early-relabel 슬라이스로 좁혀짐)

F. Phoenix Cold retrieval 채널이 cold-start arm-gate에 들어갔다.

home-mixer/scorers/author_cold_start.rs(+45/−6)에서 헬퍼 함수 is_phoenix_moe의 이름이 is_arm_gated_retrieval로 바뀌면서 대상 served_type이 넓어졌다 확인.

fn is_arm_gated_retrieval(c: &PostCandidate) -> bool {
    matches!(
        c.served_type,
        Some(
            pb::ServedType::ForYouPhoenixRetrievalMoe | pb::ServedType::ForYouPhoenixRetrievalCold
        )
    )
}

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가 데이터 중심으로 정리되는 방향이다.
  • Phoenix trainer_recsys.py +48/−200, rowwise_adagrad.py +51/−120으로 optimizer·trainer boilerplate가 줄었다. phoenix/xrex/utils/recsys_async_emb.py는 이름이 바뀌었다(rename+54).
  • VF hydration 파일도 함께 바뀌었다.
    • visibility-filtering/hydration/tes_composite.rs +29/−32, hydration/plan.rs +48/−12(wingman 편입 대응)
    • hydration/mod.rs +22/−29, hydration/batch.rs +40/−64, hydration/metrics.rs +140/−180(rule metrics 분리의 반작용)
  • visibility-filtering/evaluate_tweets.rs +57/−51, filter.rs +26/−120, server.rs +35/−21은 하이드레이션·규칙 개편에 맞춰 dispatch를 정리한 변경이다.
  • abuse-enforcement-service/service-lib/src/lib.rs +612/−4, service-lib/src/metrics.rs +79/−3은 overturn-hold 통합의 반작용이다.

의미 이 커밋의 진짜 헤드라인은 abuse-enforcement의 overturn-hold gate 신설이다. VF 개편의 24시간 반감기 패턴도 이어진다. 전날까지 함수였던 safety_label_hydrator·tes_hydrator가 이번에 삭제됐고, 전날 신설된 execute.rs의 상태 로직이 store.rs로 분리됐다.

  1. 자동화 판정과 사람 판정의 우선순위가 데이터로 고정된다. 자동화 dispatch 경로에서 정지 spec이 만들어질 때마다, hold ledger를 조회해 "이 조치가 사람 손에서 취소된 적이 있는가"를 확인하고 gate하는 마지막 문이 코드로 명시됐다.
  2. 정지의 취소가 지속 상태로 취급된다. hold는 만료 시각(expires_at)을 갖는 시간 창이다. 그동안 특정 대상(계정·라벨)에 대한 자동화 조치가 막힌다. 사람 검토자의 이의제기 승인이 "1회성 취소"가 아니라 "이후 시간 T까지의 재적용 금지"라는 정책 항목이 된다.
  3. follows 조회가 wingman 경로로 옮겨 갔다. socialgraph 직접 호출이 wingman 경로로 옮겨지면서 hydration RPC의 fan-out 구성이 다시 정리된다. FOLLOWS_GRAPH=1이 상수로 고정된 것은 wingman이 여러 그래프(2·3···)를 갖고 있음을 시사하지만, 나머지는 이번 공개에서 확인되지 않는다.
  4. Phoenix 광고 구매액 target이 데이터로 고정된다. ValueEvent 3 × ValueLevel 2 = 6 조합 가운데 REQUIRED 2개와 PUBLISHED 4개가 명시됐다. $100k 이벤트 상한도 학습·평가 계약에 고정된다.
  5. 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).

이 커밋은 파일 89개를 바꿨고, 줄 수는 +9,552/−2,325다 확인 — xai-org/x-algorithm@a707cc2.

A. overturn-hold의 서버인 abuse-ledger-service가 공개됐다.

4세대+30(2026-09-26)이 신설한 클라이언트 overturn_hold.rs는 원격 ledger의 POST /v1/holds/lookup을 호출했다. 이번에 그 서버 구현이 전부 공개됐다.

  • Cargo crate 이름은 xai-abuse-ledger-service다.
  • 파일은 11개(api.rs·boot.rs·codes.rs·config.rs·lib.rs·main.rs·metrics.rs·pg.rs·repo.rs·sql.rs·tls.rs)이고, +2,282줄이다 확인 — a707cc2의 파일별 통계.
  • axum, deadpool-postgres, rustls를 조합한 read-only 조회 서비스다.

조회 API는 user id와 라벨 목록을 POST로 받아 hold 목록을 돌려준다 확인 — abuse-ledger-service/src/api.rs.

  • 경로는 LOOKUP_PATH = "/v1/holds/lookup"이고, method는 POST, 헤더는 x-request-deadline-ms다 확인(같은 파일).
  • 요청은 {user_ids: Vec<i64>, labels: Vec<String>}, 응답은 {holds: Vec<Hold>, db_ms: u64}다 확인(같은 파일).
  • 한도는 MAX_USER_IDS = 100, MAX_LABELS = 32이고, 빈 문자열 라벨은 거부한다 확인(같은 파일).
  • budget(deadline, statement_timeout)은 헤더 값과 statement timeout 중 작은 쪽을 실제 DB 예산으로 쓴다 확인(같은 파일).
    • 헤더가 없으면 statement_timeout + QUERY_DEADLINE_SLACK(100ms)를 쓴다.
  • 부수 엔드포인트로 /healthz, /readyz, /metrics가 있다 확인(같은 파일).

조회 SQL은 해제되지 않았고 아직 만료되지 않은 hold만 읽는다 확인 — abuse-ledger-service/src/sql.rs.

SELECT user_id, hold_id, head, expires_at, case_group_id, reason, action_kind, label
FROM ledger.enforcement_holds
WHERE user_id = ANY($1::bigint[]) AND cleared_at IS NULL AND expires_at > now()
AND (action_kind = 'suspend' OR (action_kind = 'label' AND label = ANY($2::text[])))
  • 테이블은 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를 하지 못한다 확인(같은 파일).

Hold 레코드는 대상 user id, 조치 종류, 라벨, 만료 시각, 사유 등을 담는다 확인 — abuse-ledger-service/src/repo.rs.

pub struct Hold {
    pub user_id: i64,
    pub hold_id: i64,
    pub action_kind: String,   // "suspend" | "label"
    pub label: Option<String>,
    pub head: Option<String>,           // 발행 도구 식별자 [추정]
    pub expires_at: DateTime<Utc>,
    pub case_group_id: Option<i32>,     // 이의제기 case를 여러 hold로 묶는 그룹 키
    pub reason: String,
}

pg 상수는 아래와 같다 확인 — abuse-ledger-service/src/pg.rs.

  • 풀 타임아웃 세 개는 POOL_WAIT_TIMEOUT = 1s, POOL_CREATE_TIMEOUT = 1.5s, POOL_RECYCLE_TIMEOUT = 500ms다 확인(같은 파일).
  • QUERY_DEADLINE_SLACK = 100ms도 이 파일의 상수다 확인(같은 파일).
  • PgError는 7-variant다 확인(같은 파일).
    • variant는 BadDsn / Connect{Refused|Auth|Tls|Timeout|Other} / Saturated / Transport / Query{sqlstate,message} / Decode / Timeout이다.
  • SQLSTATE 57014(query_canceled)는 ErrorCode::StatementTimeout으로 매핑된다 확인(같은 파일). 28P01/28000(auth)은 ErrorCode::AuthFailed로 매핑된다 확인(같은 파일).
  • 부팅할 때 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씩 배칭한다.
  • MAX_IN_FLIGHT = 32: semaphore fan-out 상한이다.
  • TWEETYPIE_TIMEOUT = 1500ms
  • SAFETY_LEVEL = SafetyLevel::TimelineHomeHydration: Tweetypie에 요청하는 판정 레벨을 고정한다.
  • RECORD_CAP_PER_MINUTE = 20: 샘플 로그 상한이다.
  • WEB_CLIENT_APPLICATION_ID = 3_033_300

결과 클래스는 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가 얼마나 자주 필요한지 관측된다.

D. VF 클라이언트 스위치 6개가 데이터 테이블로 고정됐다.

params/client_switches.rs가 225줄로 새로 들어와, 스위치 6개를 데이터 테이블로 고정한다 확인 — visibility-filtering/params/client_switches.rs.

enum ClientSwitch {
    AgeVerification(VerifyBlurSupport),   // 4가지 클라이언트 상태별 다른 rule key
    ModernBlur,
    StaleTweetLimits,
    GoreBlurIgnoresSettings,
    FosnrRules,
    FosnrFallbackDrops,
}
  • VerifyBlurSupport의 4-축은 각각 서로 다른 age_verification_*_tombstone_rule_enabled 키에 매핑된다 확인(같은 파일).
    • 4-축은 Unsupported | IosNeedsUpdate | AndroidNeedsUpdate | Supported다.
  • default_on으로 기본으로 켜지는 것은 AgeVerification(Unsupported), StaleTweetLimits, FosnrRules 3개다 확인(같은 파일).
  • 요청이 오면 TwitterContextViewer와 viewer_id로 RecipientBuilder를 구성하고, xai_feature_switches::FeatureSwitches가 이를 매치한다 확인(같은 파일).
    • 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 파라미터는 제거됐다.

G. author_policy_zeroed 필드가 완전히 제거됐다.

홈믹서 후보(PostCandidate)와 VMRanker 요청 shape 양쪽에서 author_policy_zeroed: bool 필드가 삭제됐다 확인 — home-mixer/scorers/value_model.rs, scorers/vm_ranker.rs, scorers/vm_ranker_request.rs, models/candidate.rs. 이 필드는 author policy에 의해 스코어가 0으로 억눌린 후보를 표시하던 마커였고 추정, 이제 policy-driven 억제는 VF verdict에서 직접 판정된다.

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).
  • home-mixer/sources/ads_source.rs, sources/phoenix_source.rs는 candidate 필드 제거의 반작용으로 소폭 조정됐다.
  • agatha/scalding/blink/BlinkScoreJobTemplate.scala(+87/−28)에서 Blink score job 템플릿이 수정됐다.
  • phoenix/xrex/inference/model_runner.py +134/−38, models/recsys_model.py +83으로 inference 경로가 소폭 조정됐다.

의미 이 커밋에서는 안전 조치, VF 검증, 랭킹 가중치에 관한 세 가지 사실이 겹친다.

  1. 자동화 정지 정책의 신뢰 계층이 두 서비스로 물리적으로 분리됐다. 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 권한을 원리적으로 갖지 않는다.
  2. VF 레퍼런스 비교 대상이 자체 참조를 벗어나 storage 계층(Tweetypie)까지 넓어졌다. Rust VF ↔ Scala VF diff는 구현 drift에 편향된다. Tweetypie는 tweet 자체에 이미 붙여 둔 visibility 결정을 갖고 있으므로, 그것과 diff하면 "storage 축의 결정"과 "serving 축의 결정" 사이의 drift가 잡힌다. Safety Level을 TimelineHomeHydration으로 고정하고 prod 기동을 거부하는 것은, 이 harness가 안전망이 아니라 정확성 검증 도구임을 명시한다.
  3. 랭킹이 "클릭"을 덜 믿기 시작했다. 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도 명시 부정 신호를 함께 강화하는 방향이다.

4세대+32 (2026-09-30, 77d431a) — VF hydration Store가 Fetcher<V> trait 어레이로 재작성되고 리트윗 소스 확장이 한 번-hydrate + in-flight로 단순화 + treatment.rs 사영 격리 + Edge 8-variant enum 통합 + VF 클라이언트 응답이 Thrift Action → TweetFieldsResultState 승격 + Phoenix xrecsys_two_tower.py가 6-preset 카탈로그로 registry 승격 (SID 6-level + seqpack + H100/GB300 하드웨어 프리셋 + Home/Immersive 두 head 공유) + ads_head_masking JAX 학습 그래프 → numpy 데이터 로더로 이동하며 web/app conversion 축 분리 (proto ADS_ATTRIBUTED_MACT_MMP_{CLICK,VIEW}_INSTALL = 194,195 신설) + 홈믹서 cold-start 6 파라미터 프로덕션 튜닝 (PhoenixColdStartMaxResults 0 → 200, ColdStartMaxPostAgeSecs 48h → 2h, ColdStartFollowerCap 1000 → 50k, ColdStartImpressionThreshold 1000 → 200, LowImpressionsMaxPositionRatio 0.85 → 0.97, EnableColdStartThompsonSampling false → true) + home-mixer/util/composition.rs 63줄 완전 삭제 (candidate-pipeline 공용 유틸로 이관) + Brazil 2026 election filter 2,786 → 2,795 handle

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 재작성을 마무리한다.

A. VF hydration Store가 Fetcher<V> trait 어레이로 다시 짜였다. 새 파일 visibility-filtering/hydration/fetcher.rs(NEW +113)가 Fetcher<V>와 AnyFetcher trait을 만든다.

  • 내부에는 HashMap<u64, State<V>> 하나가 있고, 키마다 enum State<V> { Pending, Landed(Hydrated<V>) } 상태를 담는다.
  • claim(keys) -> Vec<u64>는 아직 없는 키만 Pending으로 잡고, 실제로 RPC로 보낼 키만 돌려준다. 이렇게 중복을 거르고 이전 호출과도 합친다(dedup, 이전 콜과의 join).
  • land(&[u64], HydrationBatch<u64, V>)는 클레임된 키에 응답을 채우고, 응답이 없는 키는 Hydrated::NotFound로 채운다.
  • has_incomplete/is_incomplete/has_claimed/is_claimed가 재호출이 필요한지를 판별한다.

전날 커밋인 4세대+31(a707cc2)의 visibility-filtering/hydration/store.rs가 갖고 있던 Option<TweetHydrationBatch<...>> 필드 8개는 이번에 전부 Fetcher<V>로 바뀌었다 확인 — store.rs:54-84.

  • 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.
    • 담는 세부는 group, is_first, keys, key_count, queries, viewer_id, batch_size, counts다.
  • 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})를 세 종류의 다운스트림 표현으로 사영한다 확인.

  • thrift_action(&Verdict, SafetyLevel) -> Action은 thrift action::Action으로 사영한다.
    • 사영 결과는 Allow / Drop / Tombstone / ComposedMediaVisibilityResults / Interstitial / LimitedEngagements / TweetInterstitial 중 하나다.
  • thrift_result_state(&Verdict, SafetyLevel) -> TweetFieldsResultState는 tweet-service 응답 상태(Found / Filtered)로 사영한다. FilteredReason::{TweetIsBounced, AuthorBlockViewer, AuthorIsProtected, AuthorIsSuspended} 네 개만 레거시 arm으로 따로 다루고, 나머지는 SafetyResult로 감싼다.
  • proto_action(Verdict) -> (vf_pb::Action, Option<vf_pb::FilteredReason>)은 VF proto용 3-way(Allow / Drop / Interstitial)로 사영한다.
  • metric_label(&Verdict) -> &'static str는 6-way 메트릭 라벨을 돌려준다.
    • 라벨은 allow / drop / tombstone / interstitial / limited_engagement / tweet_interstitial이다.
  • 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.

G. Phoenix xrecsys_two_tower.py가 6-preset 카탈로그로 registry에 승격됐다. 확인 — phoenix/xrex/configs/xrecsys_two_tower.py. 딕셔너리 MODEL_CFGS에 preset 6개가 들어 있다 확인 — xrecsys_two_tower.py:418-547.

  • xrecsys_two_tower / xrecsys_two_tower_immersive_only / xrecsys_two_tower_immersive_only_gb300
  • xrecsys_two_tower_combined / xrecsys_two_tower_combined_gb300 / xrecsys_two_tower_nano

공용 스케일은 _xrecsys_two_tower_combined_base에 정의돼 있다.

  • 시퀀스와 모델 크기는 history_seq_len=1023, candidate_seq_len=64, num_layers=8, emb_size=1024, emb_table_width=1024다.
  • 어텐션 헤드는 query_heads=16, kv_heads=4(GQA 4:1)다. negative 설정은 num_global_negatives_per_example=64, num_negatives_per_example=0(in-batch 없이 global sampled softmax만)이다.
  • 학습 규모와 어휘 크기는 total_samples=1e11(100B), item_hash_vocab_size=100_000_000, author_vocab_size=30_000_000이다 확인 — xrecsys_two_tower.py:466-475.
  • SID만 쓰고 유저·포스트 임베딩은 쓰지 않는다 확인 — xrecsys_two_tower.py:311-318.
    • 플래그는 use_user_embedding=False · use_post_embedding=False · use_post_sid=True다.
    • SID 설정은 sid_num_levels=6 · sid_codebook_size=256 · sid_embed_dim=1024 · sid_hash_level=True · sid_cross_attn=True다.
  • Sequence packing 설정은 use_seqpack=True, right_anchored_rope=True, effective_sequence_len=513이다. 길이 분포는 BetaLengthDistribution(min=0, max=1023, mean=511, α=β=1.0, block_size=128)이다.

combined preset은 home과 immersive 두 head를 둔다 확인 — xrecsys_two_tower.py:293-302.

  • 설정은 num_candidate_heads=2, head_names=["home", "immersive"]다.
  • head_dataset_mapping은 HOME/HOME_COLD/HOME_HOT → head 0, IMMERSIVE4Day/IMMERSIVE2Day/IMMERSIVENSFW → head 1로 배정한다.
  • home head의 positive_actions는 [SERVER_TWEET_FAV] 하나뿐이다.
  • immersive_positive_actions는 8종이다 확인 — xrecsys_two_tower.py:283-292.
    • FAV / REPLY / QUOTE / RETWEET / VIDEO_QUALITY_VIEW / FOLLOW_AUTHOR / BOOKMARK / SHARE다.

하드웨어 프리셋은 H100용과 GB300용 두 가지다 확인 — xrecsys_two_tower.py:409-413.

  • _H100_OVERRIDES = {bs_per_device: 480, ep: 128, attn_impl: "cutedsl_ranker_varlen_attn"}
  • _GB300_OVERRIDES = {bs_per_device: 960, ep: 64, ...}
  • GB300에서는 per-device batch가 두 배이고 EP는 절반이다.

학습 설정 값은 다음과 같다.

  • Optimizer는 Adam(b1=0.95, b2=0.98, weight_decay=1e-3)이다 확인 — xrecsys_two_tower.py:826-831, 271.
  • 학습률은 learning_rate=2e-3이다 확인 — 같은 파일.
  • 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만 쓴다.

데이터 파이프에서 numpy bool 마스크를 미리 계산하는 새 함수가 생겼다 확인 — data/recsys/ads_head_masking.py:44-88.

  • 시그니처는 build_trained_candidate_mask(actions, sample_source, trained_actions_bits, *, ads_head_masking: bool) -> npt.NDArray[np.bool_]다.
  • 인자는 source_id가 sample_source로, raw_targets가 trained_actions_bits: uint8 비트마스크로 바뀌었다.
  • 새 상수 ADS_FAMILY_SLICE_HEADS: dict[str, int]는 4개 슬라이스마다 대표 head index를 담는다.
    • 슬라이스는 delayed_website_clicked / delayed_website_non_clicked / delayed_app_clicked / delayed_app_non_clicked다.
  • MMP(모바일 어트리뷰션 partner) app-conversion 축이 추가되면서, masking은 web × app × early/late × click/view 격자로 넓어졌다.

recsys_model.py에서는 기존 두 마스크가 web/app으로 나뉘었다 확인 — recsys_model.py:1493-1520.

  • 기존 마스크는 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 컬럼도 새로 받는다.

constants.py에는 새 카탈로그가 생겼다 확인 — phoenix/xrex/data/recsys/constants.py.

  • MMP_CLICK_ACTION_INDEX = ADS_MMP_CLICK이 정의됐다.
  • MMP_CLICK_CONDITIONED_ACTION_INDICES(app click-through 7종)가 새로 생겼다.
  • MMP_CLICK_VIEW_THROUGH_ACTION_INDICES(app view-through 7종)도 새로 생겼다. 전날까지 VIEW_THROUGH_ACTION_INDICES에 섞여 있던 것을 분리했다.
  • 비트 상수 CONVERSION_KEEP_WEB = 1 << 0, CONVERSION_KEEP_APP = 1 << 1이 생겼다.
  • 전날까지 매직 넘버였던 line_item_objective == 5가 LineItemObjective(enum.IntEnum): WEBSITE_CLICKS = 5로 승격됐다 확인 — recsys_model.py:1495-1499.
  • 새 라벨 IsCustomConversion / IsContentViewConversion이 각각 ADS_CUSTOM_CONVERSION / ADS_CONTENT_VIEW_CONVERSION action에 매핑된다.

recsys.proto에는 mirror 두 파일 모두에 action 두 개가 새로 생겼다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto.

  • ADS_ATTRIBUTED_MACT_MMP_CLICK_INSTALL = 194
  • ADS_ATTRIBUTED_MACT_MMP_VIEW_INSTALL = 195

I. 크론 미러에서 홈믹서 cold-start 파라미터 6개가 프로덕션 값으로 바뀌었다. 확인 — home-mixer/params/param.rs.

파라미터 이전 값 새 값 배율
PhoenixColdStartMaxResults 0 200 0 → 200(Phoenix cold-start retrieval 배출량이 0에서 200으로 활성)
ColdStartImpressionThreshold 1000 200 ×0.2(홈 노출 200 미만 포스트로 cold-start 대상을 좁힘)
ColdStartFollowerCap 1000 50,000 ×50(author follower 상한 사실상 해제)
ColdStartMaxPostAgeSecs 172,800(48h) 7,200(2h) 1/24(post 신선도 극단화)
LowImpressionsMaxPositionRatio 0.85 0.97 +0.12(position boost 상한이 피드 뒷부분까지 확장)
EnableColdStartThompsonSampling false true 신규 ON

주석 헤더의 last sync는 2026-09-28T16:00:35Z에서 2026-09-29T17:02:52Z로 갱신됐다 확인. 이번에 cold-start 파이프라인의 6개 노브가 한 세트로 크게 조정됐다.

  • Phoenix cold retrieval 채널이 켜졌다(0 → 200 posts).
  • 후보 포스트의 홈 노출 임계가 낮아졌다(1000 → 200). 노출이 200 미만인 포스트만 남으므로 대상이 좁아진다 확인 — author_cold_start.rs:293-294.
  • author follower cap은 사실상 해제됐다(1000 → 50k).
  • post age는 2일에서 2시간으로 극단적으로 짧아졌다.
  • Thompson sampling으로 exploration도 켜졌다.

이 변경은 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다. 로직은 바뀌지 않았다.

의미 이번 커밋에는 네 갈래의 사실이 겹친다.

  1. 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을 골든 테스트로 묶은 것이 이 단순화의 마무리다.

  2. 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의 세 사용처를 하나로 묶을 잠재력이 커진다.

  3. 광고 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을 학습 헤드에 정식으로 편입할 채널이 생긴다.

  4. 홈믹서 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에서 다뤘다.

바뀐 파일은 47개이고 규모는 +2,490/−506이다 확인 — xai-org/x-algorithm@b79b947. 4세대+32(77d431a)의 다음 날 나온 커밋이다.

A. VF에 제한 액션 정책과 그 안내 문구를 다루는 파일 두 개가 새로 생겼다.

새 파일은 limited_actions_copy.rs(NEW +397)와 params/limited_actions_policy.rs(NEW +306)다. 전날 커밋에서 신설된 treatment.rs는 verdict를 Thrift Action으로 사영하는 일을 맡았지만, Thrift LimitedEngagements payload에는 사유 enum만 담겼다. 이 커밋에서 두 파일이 신설되면서 payload가 "사유 → 액션 리스트 → 로컬라이즈 Prompt"의 전개를 담게 된다.

  • limited_actions_copy.rs는 로컬라이즈된 UI 문구 번들을 불러온다 확인 — limited_actions_copy.rs:1-85.
    • 번들 경로는 /config/stringcenter/visibility-ltd-actions.json이고, pinned metadata 경로는 /config/stringcenter/_BUNDLE_METADATA.json이다.
    • live metadata 디렉터리는 /usr/local/config/stringcenter/bundles/visibility-ltd-actions다.
    • DriftCheck::run()은 bundle_hash를 pinned와 live로 비교해 limited_actions_copy_drift_checks{result=match|mismatch|unreadable} 카운터를 발행한다.
  • LimitedActionType이라는 26-variant enum이 새로 생겼다 확인 — params/limited_actions_policy.rs:16-43.
    • variant는 Reply · Retweet · QuoteTweet · Like · React · SendViaDm · AddToBookmarks · AddToMoment · PinToProfile · ViewTweetActivity · ShareTweetVia · Follow · ListsAddRemove · MuteConversation · Embed · ViewHiddenReplies · HideCommunityTweet · CopyLink · VoteOnPoll · RemoveFromCommunity · ShowRetweetActionMenu · ReplyDownVote · Autoplay · EditTweet · Highlight · ViewPostEngagements다.
  • LimitedActionsPolicies::resolve가 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이다.

이 매핑은 테스트로 고정돼 있다.

LimitedEngagementReason 제한 액션
ConversationControl Reply
RootAuthorBlockedViewer Reply
BlockedViewer Reply · Retweet · QuoteTweet · Like · React · SendViaDm · AddToBookmarks · AddToMoment · PinToProfile · ShareTweetVia · Follow · ListsAddRemove · Embed · CopyLink · VoteOnPoll · ShowRetweetActionMenu · ReplyDownVote · EditTweet · Highlight (19개)
ReadonlyViewer AddToBookmarks · AddToMoment · Embed · HideCommunityTweet · Like · ListsAddRemove · PinToProfile · QuoteTweet · React · Reply · Retweet · VoteOnPoll · EditTweet · Highlight (14개)
StaleTweet Reply · Retweet · QuoteTweet · Like · React · SendViaDm · AddToBookmarks · ShareTweetVia · AddToMoment · Embed · HideCommunityTweet · PinToProfile · ViewTweetActivity · VoteOnPoll · CopyLink (15개)
  • Prompt는 (액션, locale, country)마다 번들 키를 조회해 만든다 확인 — limited_actions_copy.rs:120-160.
    • 조회하는 키는 {namespace}_{action_key}_Headline·{namespace}_{action_key}_Subtext이고, 결과는 Prompt { headline, subtext, kind }다.
    • kind는 Basic / SeeConversation(CTA) / LearnMore { language, link_text, url }의 3-variant다.
    • BlockedViewer·ReadonlyViewer namespace는 공용 subtext 키 _GenericSubtext를 함께 쓴다.
  • 액션이나 문구가 누락되면 fail-fast로 처리하고 MISSING_POLICY_COUNTER = limited_actions_policy_missing{reason, missing=actions|copy} 카운터를 발행한다.

B. 제한 판정 하나에 사유를 여러 개 쌓을 수 있게 됐다.

LimitedEngagement가 복수 reason을 축적하는 구조로 승격됐다.

  • 이 커밋 전까지 단일 wrapper였던 이 타입이 사유 하나와 나머지 사유 Vec을 담는 구조로 바뀌었다 확인 — verdict.rs:67-98.
    • 이전: LimitedEngagement(pub LimitedEngagementReason)
    • 이후: { first: LimitedEngagementReason, rest: Vec<LimitedEngagementReason> }
  • add(reason) 메서드는 중복을 확인한 뒤 Vec에 push하고, reasons() 이터레이터는 first와 rest를 순회한다.

이에 맞춰 rules/registry.rs::collect_specs의 ActionSpec::LimitedEngagement(reason) 매치 분기는 두 경우로 나뉜다 확인.

  • engagement가 None이면 새로 설정한다(LimitedEngagement::new(reason)).
  • engagement가 Some이면 append한다(limit.value.add(*reason)).

즉 뷰어에게 BlockedViewer와 ReadonlyViewer가 동시에 걸리면 두 사유가 모두 payload에 실린다. BlockedViewer는 19개, ReadonlyViewer는 14개 액션을 제한한다. 이 커밋 전까지는 두 번째 이후의 engagement 매치가 dead-code였지만, 이 커밋부터는 실제로 축적된다. StaleTweet과 BlockedViewer, ConversationControl과 RootAuthorBlockedViewer 같은 다른 reason 조합도 똑같이 작동한다.

LimitedEngagementReason::limited_actions_string() const fn이 새로 생겨, 각 reason을 번들 key 접두사로 매핑한다 확인.

  • ConversationControl → "limited_replies"
  • ReadonlyViewer → "readonly_viewer"
  • BlockedViewer → "blocked_viewer"
  • RootAuthorBlockedViewer → "root_author_blocked_viewer"
  • StaleTweet → "stale_tweet"

C. Thrift 사영 함수가 제한 액션 정책을 세 번째 인자로 받는다.

전날 커밋에서 신설된 사영 레이어의 서명이 확장돼, treatment.rs::thrift_action이 LimitedActionsPolicies를 세 번째 인자로 받는다 확인 — treatment.rs:16-20.

pub(crate) fn thrift_action(
    verdict: &Verdict,
    level: SafetyLevel,
    policies: &LimitedActionsPolicies,
) -> Action

Thrift import에는 심볼 9개가 추가됐다.

  • BasicLimitedActionPrompt · CtaLimitedActionPrompt · LimitedAction · LimitedActionCtaType · LimitedActionPrompt · LimitedActionsPolicy · LocalizedMessage · LocalizedMessageLimitedActionPrompt · MessageLink.

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이다.
  • rpc는 3-variant enum Endpoint { FilterTweets, EvaluateTweets, GetSafetyLabels }다.
  • 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 엔드포인트가 요청을 처리하면서 제한 액션 정책을 조립한다.

  • VF evaluate_tweets.rs의 EvaluateTweetsEndpoint가 3-필드 구조로 바뀌었다 확인 — evaluate_tweets.rs:22-40.
    • 세 필드는 (filter_tweets, client_switches, limited_actions_copy)다.
    • server.rs::build_grpc_services는 생성자에 limited_actions_copy 인자를 추가로 넘긴다.
  • 요청 처리 흐름에서 Policy를 조립한 뒤, 하류의 thrift_action 호출로 전달한다.
    • 조립 호출은 client_switches.limited_actions_policies(viewer_profile, country_code, locale, reasons, &limited_actions_copy, Some(stats))다.

F. VF가 브라질 선거 라벨이 붙은 트윗을 drop 경로로 처리하도록 정식 등록했다.

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 설정이 바뀌면 스스로 재시작할지 판단하는 게이트가 생겼다.

새 파일 restart_on_config_change.rs(NEW +173)에는 보드 상태가 3개 있다 확인 — restart_on_config_change.rs:1-85.

  • Check::Unchanged: 감시 경로에 변경이 없다.
  • 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은 이 접두사의 점수만 소비한다.
  • SID_BACKFILL_QPS_CAP=800_000: SID 백필의 QPS 상한은 800k다.
  • SID 백필은 최근 1시간, 최소 3분 창의 포스트만 대상으로 한다.
    • SID_BACKFILL_BATCH_SIZE=5_000 · SID_BACKFILL_INTERVAL_SECS=30 · SID_BACKFILL_MAX_AGE_HOURS=1 · SID_MIN_POST_AGE_SECONDS=180
  • DUMP_INTERVAL_SECS=180: 3분 간격으로 Kafka dump를 한다.
  • VERSIONS_TO_KEEP=3
  • SEEK_HOURS=12: 최대 12시간까지 되감는다.
  • MAX_POLL_RECORDS=500 · COMMIT_AFTER_DUMP=true · COMMIT_INTERVAL_SECS=0

consumer/kafka.rs(+55/−33)에는 설정 추출을 KafkaConsumerConfigBuilder에 위임하는 얇은 어댑터만 남았다. SASL 메커니즘, bootstrap wily/non-wily 분기, SSL 설정 조립 로직은 삭제됐다.

J. VF의 fallback 캐시가 Mutex로 감싼 shard 구조에서 quick_cache의 sync 캐시로 재작성됐다.

대상 파일은 VF hydration/fallback_cache.rs(+69/−173)다.

  • 이 커밋 전까지의 캐시 구조가 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 시각만 바뀌었고, 실질 값은 그대로다.

의미 이 커밋에서는 네 가지 사실이 겹친다.

  1. "제한 사유" 결정권이 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 버튼이 달라지는 동작이, 이제 서버 주도 선언으로 완성됐다.

  2. 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 때문이기도 하다"고 답할 수 있는 경로가 생긴다.

  3. 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 경로로 반영된다.

  4. 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).

이 커밋은 파일 105개를 바꿨고, 규모는 +5,227/−756이다 확인 — xai-org/x-algorithm@76843a5. 직전 커밋인 4세대+33(b79b947)이 나온 지 하루 뒤다.

A. home-mixer가 generative SID retrieval 전용 microservice를 후보 소스로 쓰도록 연결됐다.

새 파일은 sources/sid_source.rs(신규 +112)와 clients/sid_retrieval_client.rs(신규 +246)다. 호출 대상 endpoint는 xai-recsys-sid-retrieval.prod.fou.twitter.biz:443다.

  • 요청은 seed 포스트들을 보내고, 응답은 후보마다 포스트 id와 공유 접두사 깊이를 돌려준다 확인 — sid_source.rs:60-85.
    • gRPC 요청 shape는 RetrieveRequest { seed_post_ids, max_results, max_per_seed, min_prefix_depth, max_prefix_depth, caller }다.
    • 응답 shape는 RetrieveResponse { candidates }이고, 각 Candidate에 post_id와 shared_prefix_depth가 담긴다.
  • shared_prefix_depth는 PostCandidate.retrieval_sources[0].score에 f32로 담겨 다운스트림에 넘어간다.

소스가 켜지려면 다섯 조건을 모두 만족해야 한다 확인 — sid_source.rs:58-64.

  • EnableSidSource=true
  • !query.is_topic_request()
  • !query.in_network_only
  • !query.has_cached_posts
  • has_post_signals(query)

마지막 조건의 has_post_signals와 post_signal_ids는 SimclustersSource와 완전히 같은 함수를 재사용한다 확인 — sid_source.rs:14. 두 OON retrieval 소스는 동일 seed 집합(유저의 positive engagement 포스트들)에서 분기한다.

파라미터 5개가 새로 생겼다 확인 — home-mixer/params/param.rs.

파라미터 기본값 역할
EnableSidSource false 소스 자체 켜기
SidSourceMaxResults 800 응답 상한 (seeds × per_seed = 50×100 = 5000 이론치를 재조임)
SidSourceMaxSeeds 50 요청에 실을 seed 포스트 상한
SidSourceMaxPerSeed 100 seed당 최대 결과 포스트 수
SidSourceMinPrefixDepth 3 6-level 중 공유 접두사가 3개 이상 일치하는 후보만 반환

SID 소스의 후보는 PhoenixMOESource와 같은 ServedType으로 라벨된다. 라벨 값은 served_type=ForYouPhoenixRetrievalMoe다. 즉 하류 scorer/filter 입장에서는 두 소스의 후보가 라벨 축에서는 구분되지 않는다.

SID retrieval 클라이언트 코드는 호출 데드라인과 연결 경로를 정한다.

  • gRPC 데드라인 300ms는 하드코딩돼 있다(TIMEOUT_MS: u64 = 300, sid_retrieval_client.rs:21).
  • 연결은 xDS P2C 로드밸런서가 1차로 맡고, DNS RotatingChannel(60초 refresh)이 2차 폴백이다 확인 — sid_retrieval_client.rs:120-180.
    • 10초 안에 빌드하지 못하거나 bootstrap이 없으면 2차 폴백으로 넘어간다.
    • discovery authority는 xdstp://discovery-*/envoy.config.endpoint.v3.ClusterLoadAssignment/fou-xai-recsys-sid-retrieval.prod.prod:grpc-tls다.

readiness probe는 port 29180의 GET /readyz다. 메트릭은 2종이다.

  • SidRetrievalClient.retrieve{result=success|timeout|failure, path=xds|dns}는 호출 결과와 경로를 라벨로 단다.
  • SidSource.retrieved{type=seeds|retrieved|hydrated}는 3단계의 양을 잰다.

B. Phoenix에 SID generative retrieval 전용 Transformer의 학습·서빙 코드가 생겼다.

새 파일은 config, 모델, 트레이너, 러너의 4개다.

학습 config에는 하이퍼파라미터가 공개돼 있다 확인 — xrecsys_sid_retrieval.py:11-45, 130-180.

  • 입력 시퀀스는 유저 prefix 1토큰, 유저 액션 히스토리 1023토큰, SID 타겟 6토큰을 합친 1030 길이다. 캔디데이트 시퀀스 길이는 64다.
  • SID는 sid_num_levels=6, sid_codebook_size=256이고, 이론 공간은 $256^6 \approx 2.8 \times 10^{14}$이다.
    • 나머지 SID 설정은 sid_embed_dim=1024, sid_prediction_hidden_dim=512, sid_level_loss_decay=1.0(레벨별 loss 가중치 균등), sid_hash_level=True, sid_cross_attn=True다.
  • Transformer는 8-layer이고, GQA 20:4(query_heads=20·kv_heads=4)를 쓴다.
    • 나머지 설정은 key_size=128, emb_size=2560, attn_logit_cap=80.0에 soft_sign, rotary와 causal, widening_factor=2에 gated_mlp=False, remat WHOLE이다.
  • 입력 vocab은 user_vocab=0(미사용), item raw vocab=0, item hash vocab 100M, author vocab 30M, action type 60, continuous action 8이다.
    • 세 축 모두 2쌍 해시(linear scale × modulus prime)로 double hashing한다.
    • 이 수치는 2026-09-30 공개된 two-tower config와 정확히 일치한다. 두 모델은 동일 vocab 공간을 공유한다.
  • 학습 optimizer는 Adam(b1=0.95, b2=0.98)이다.
    • 나머지 학습 설정은 weight_decay=1e-3, 상수 lr=2e-3, batch size per device=128, parallel ep=128·dp=1(128-way expert-parallel), max_steps $\approx 3.125 \times 10^9$이다.
  • 데이터셋은 aggregated_kafka, num_kafka_partitions=1024, use_post_sid=True, multimodal_embedding_type=None으로 설정됐다.
  • 체크포인트는 every_n=100 · keep_every_nth=1000 · keep_last_n=10 · verify_checksums=True · replica_axis="replica"로 설정됐고, chunked와 compressed는 모두 False다.

모델 쪽의 positive action은 SERVER_TWEET_FAV 단 하나다 확인 — recsys_sid_retrieval_model.py:40.

  • hard-negative action 7개가 명시적으로 열거돼 있다.
    • CLIENT_TWEET_REPORT·_NOT_INTERESTED_IN·_SEE_FEWER·_UNFOLLOW_AUTHOR·_BLOCK_AUTHOR·_MUTE_AUTHOR·_NOT_RELEVANT
  • SID 토큰 임베딩은 레벨별 별도 테이블과 레벨별 프로젝션으로 구성된다.
    • 테이블은 sid_emb_level_{level}이고, 크기는 [256, 128]이다.
    • 프로젝션은 sid_emb_proj_{level}이고, 128 → 1024로 올린다.

인퍼런스 러너 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 커널 묶음이 생겼다.

새 디렉터리는 phoenix/xrex/cuda/row_emb/이고, 12-파일 묶음에 +3,582줄이다 확인 — phoenix/xrex/cuda/row_emb/.

  • 파일별 규모: row_emb_api.cc(+766) · row_emb_comm.cc(+1,104) · row_emb_kernel.cu(+629) · row_emb.py(+290) · row_emb_comm.hpp(+256) · comm_utils.cc(+146) · row_emb_kernel.hpp(+135) · row_emb_common.cuh(+82) · cuda_error_utils.hpp(+68) · comm_utils.h(+57) · comm_utils.py(+47) · __init__.py(+2)

Phoenix의 기존 async_emb는 열(column) 축으로 샤드된 임베딩 테이블을 다룬다. 반면 row_emb는 행 축으로 샤드된 테이블을 다룬다.

  • vocab이 N개면 N / group_size개 row가 각 rank에 올라간다 확인 — row_emb.py:95-145.
  • JAX FFI target 6개가 CUDA 플랫폼에 등록된다.
    • xrex_row_emb_lookup_start · _lookup_done · _stage_update · _rowwise_adagrad_update_start · _rowwise_adagrad_lazy_update_start · _rowwise_adagrad_update_done
  • RowEmbContextHandle이 아래 값들을 묶는다.
    • (context_id · group_size · flatten_replicas · tokens_per_rank · emb_width · rows_per_rank · recv_capacity · num_devices_per_node · mesh · table_axis · data_axis)

핵심 설계 결정은 3개다.

  1. lookup을 두 단계로 쪼개 통신과 compute의 overlap을 드러낸다.
    • lookup_start(token_ids, table, gate)는 (table_out, lookup_pin)을 리턴하고, lookup_done(pin)은 embeddings를 리턴한다.
    • 두 FFI 사이의 창이 compute 삽입점이 되고, all-to-all이 compute와 overlap된다.
    • recv_capacity = min(ceil(tokens_per_rank × recv_factor), tokens_per_rank × group_size)로 수신 버퍼 상한이 미리 잡힌다.
  2. rowwise Adagrad가 lookup과 같은 2-phase 구조로 fused됐다.
    • rowwise_adagrad_update_start는 테이블을 in-place로 업데이트하고(input_output_aliases={0:0, 1:1}), _update_done은 완료 신호를 리턴한다.
    • rowwise_adagrad_lazy_update_start는 last_step·step을 받아 접근된 row만 lazy update한다. sparse embedding에서 흔한 최적화다.
  3. gate 축으로 XLA의 실행 순서를 보장한다.
    • 모든 FFI가 gate: jax.Array를 I/O 축으로 받아 side-effect 체인을 명시한다. XLA가 자유롭게 재배열하지 못하게 하는 장치다.

유효성 검사는 아래 조건을 본다.

  • vocab_rows % group_size == 0
  • tokens_per_batch % group_size == 0
  • emb_width % 8 == 0
  • recv_factor > 0
  • table_axis ⊂ data_axis: 포함되지 않으면 "token shard가 rank마다 1개가 아니다" ValueError가 난다.

Phoenix의 recsys_async_emb.py(+112/−36)는 async_emb와 row_emb를 모두 받아들이는 adapter 레이어로 재작성됐다 확인.

  • is_row_sharded(context_handle) 분기로 column과 row를 결정한다.
  • table_spec은 row면 P(table_axis, None), column이면 P(None, table_axis)로 돌려준다.
  • 즉 Phoenix async embedding 유틸이 두 샤딩 전략을 동시에 지원하는 축으로 승격됐다.

D. Phoenix proto가 재편됐고, 같은 변경이 Python 미러에도 들어갔다. 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto

  • 자리를 비웠던 LLM judge action 두 개가 번호를 바꿔 다시 나타났다.
    • 새 값은 LLM_JUDGE_RELEVANT_TO_SEARCH=108과 LLM_JUDGE_NOT_RELEVANT_TO_SEARCH=109다.
    • 두 action은 4세대+20(2026-09-12)에 107/108로 신설됐다.
    • 4세대+23(2026-09-17)에 5일 만에 자리를 비웠다. 107 자리는 CLIENT_TWEET_LIVE_STREAM_REPLY가 영구히 차지했고, 108은 RESERVED_108로 보존됐다.
    • 이 커밋(4세대+34, 15일 만에)에서 108/109로 번호를 바꿔 다시 나타났다. RESERVED_108이 LLM_JUDGE_RELEVANT_TO_SEARCH로 재사용되고, 새 번호 109가 NOT_RELEVANT 쪽이다.
    • 학습 config나 wire 소비처는 이번 커밋에 공개되지 않았다. proto만 자리를 선점했다.
  • 광고 전환 가치를 USD 절대값과 비율의 2축으로 나눈 ContinuousActionName이 생겼다.
    • 새 값은 WEB_CONV_VALUE_USD=6과 WEB_CONV_VALUE_RATIO=7이다.
    • 기존 continuous action 6개(ACTIVE_SECS_* · BRIDGE_PROBABILITY=4 · ADS_WEB_CT_PURCHASE_VALUE=5)에 2개가 추가됐다.
  • 광고 랭킹이 뷰어 × 광고주 축의 engagement/conversion 히스토리를 피처로 받을 수 있게 됐다.
    • AdInfo에 viewer_advertiser_engagement_counts(필드 24)와 viewer_advertiser_conversion_counts(필드 25)가 추가됐다.
    • 둘 다 기존 EngagementCounts 타입이다.
  • EngagementCounts에 collected_actions(필드 6, repeated int32)가 추가됐다. 어떤 action 종류들이 집계에 포함됐는지 자체를 함께 wire에 담는다.
  • two-tower retrieval도 이제 seed 조건부 검색과 이미 본 포스트의 bloom-filter exclusion을 받는다. RetrieveTopKCandidatesRequest가 세 축으로 확장됐다.
    • 필드 14 seed_post_ids
    • 필드 15 excluded_posts_bloom_filters(repeated ExcludedPostsBloomFilter { bit_array: repeated fixed64, size_cap, false_positive_rate })
    • 필드 16 excluded_post_ids
    • SidSource(별도 microservice)와 two-tower retrieval이 같은 seed 축을 공유하는 쪽으로 설계가 수렴했다.
  • 광고 unified card 포맷을 proto 수준에서 열거하는 UnifiedCardType enum(29-variant)이 생겼다. 광고 ranking이 unified card 포맷을 범주형 피처로 받을 자리를 선점했다.
    • 열거 예: IMAGE_WEBSITE·VIDEO_WEBSITE·IMAGE_APP·...·VIDEO_PLAYABLE_WEBSITE·IMAGE_COLLECTION_WEBSITE
  • sid_lookup.proto가 신규 crate xai-recsys-sid-proto에 공개됐다 확인 — phoenix/crates/serving/xai-recsys-sid-proto/proto/sid_lookup.proto.
    • 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 쪽 조건이 단순해졌다.

  1. Phoenix MoE/Cold retrieval 소스를 판정하던 is_arm_gated_retrieval 함수가 삭제됐다 확인 — author_cold_start.rs +15/−38.
    • 어제까지 Control arm은 "Control corpus 포스트 중에서도 Phoenix MoE/Cold retrieval 출신"은 cold-start 부스트에서 제외했다.
    • 이 커밋부터 Control arm에서는 Control corpus의 모든 포스트가 retrieval 소스와 무관하게 부스트 대상이다.
    • 테스트 cold_retrieval_keeps_its_score_in_every_arm이 삭제되고, control_viewer_lifts_cold_retrieval_from_control_corpus가 그 자리를 대신한다.
    • 메트릭 home_mixer.cold_started_posts_total에서 is_moe 라벨이 제거됐다.
    • 의미: cold-start 부스트가 arm × corpus × 소스 3축에서 arm × corpus 2축으로 좁혀졌다.
  2. EngagementCountsHydrator에서 cached_posts 상황의 cold-start eligibility 분기가 제거됐다 확인 — engagement_counts_hydrator.rs +16/−48.
    • 과거에는 has_cached_posts일 때 cold_start_base_eligible을 통과한 후보만 재fetch하고, 나머지는 preserve_counts로 cached 값을 유지했다.
    • 이 커밋부터는 모든 후보를 조건 없이 재fetch한다.
    • preserve_counts helper가 삭제됐다. ColdStartFollowerCap 의존성이 걷혔다.
    • 의미: engagement counts 축과 cold-start의 상호의존이 사라졌다.
  3. home-mixer/util/author_rules.rs(156줄)가 삭제되고, 심볼이 xai_feature_switches::AuthorRulesEvaluator로 옮겨졌다 확인.
    • home-mixer 안에 있던 작성자 룰 평가 유틸이 공용 crate로 승격됐다.

G. 나머지는 작은 변경이다.

  • VF filter.rs(+94/−2)에 테스트 2개가 추가됐다. PureCore 실패 시 viewer_mutes_retweets·nullcasted_tweet의 결정 behavior를 pin하는 테스트이고, 정책 변화는 없다.
  • VF hydration/execute.rs(+68/−2), hydration/tweet_source.rs(+8/−14), safety_label_source/metrics.rs(+29/−7)는 4세대+32(2026-09-30)의 Fetcher<V> 재작성의 후속으로 소폭 리팩터됐다.
  • VF vf_client.rs(+1/−35)와 test_support.rs(+37/−0)에서 테스트 fixture가 분리됐다.
  • Phoenix kms_client.py(+74/−0, 신규)는 KMS 공용 wrapper다.
  • Phoenix trainer_recsys.py(+164/−39)는 공용 트레이너에서 SID retrieval 전용 trainer의 상속 축을 분리했다 추정.
  • 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개를 추가한 몫이다.

의미

이 커밋에서는 세 가지 사실이 겹친다.

  1. 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 되는 날이 관측 지점이다.
  2. 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는 양쪽이 각자 테이블을 가진다. 공유 임베딩으로 통합되는지는 다음 커밋에서 관측해야 한다.
  3. 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).

바뀐 파일은 148개, 변경 규모는 +9,128/−2,356이다 확인 — xai-org/x-algorithm@b412112. 직전 커밋인 4세대+34(어제 76843a5)의 하루 뒤다.

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)도 있다.

proto는 RPC 하나와 요청 메시지, 후보 메시지, seed 출처 enum을 정의한다 확인 — sid_retrieval.proto:

service SidRetrievalService {
  rpc Retrieve(RetrieveRequest) returns (RetrieveResponse);
}
message RetrieveRequest {
  repeated int64 seed_post_ids = 1;
  uint32 max_results = 2; uint32 max_per_seed = 3;
  uint32 min_prefix_depth = 4; uint32 max_prefix_depth = 5;
  string caller = 6;
}
message Candidate {
  int64 post_id = 1; int64 author_id = 2;
  int64 seed_post_id = 3; uint32 shared_prefix_depth = 4;
}
enum SeedSidSource { UNSPECIFIED=0; INDEX=1; SID_SERVICE=2; MISSING=3; }

인덱스는 SID 하나를 정수 key 하나로 묶고, 정렬 순서가 다른 벡터 두 개에 담는다 확인 — src/index.rs:5-110:

  • SID_LEVELS = 6, BITS_PER_LEVEL = 8이다. 따라서 각 레벨의 코드 범위는 0~255, 전체 SID는 48-bit u64 key 하나에 패킹된다. pack_full_key가 왼쪽 shift로 상위 레벨부터 쌓는다.
  • IndexedPost {key, post_id, author_id} 벡터를 2개 유지한다.
    • by_key는 key 사전순으로 정렬되고, 같은 key 안에서는 post_id 역순이다. 접두사 범위 검색에 쓴다.
    • by_post는 post_id 오름차순이다. seed post를 SID로 바꿀 때 이진탐색에 쓴다.
  • prefix_range(key, depth)는 깊이 d의 상위 d 레벨만 보존하고, 하위 레벨을 0으로 clear하거나 채운 [lo, hi) 범위를 돌려준다. by_key에서 partition_point로 그 범위 안의 포스트를 뽑는다.

검색은 깊은 접두사에서 얕은 접두사 순으로 내려가며 후보를 모은다 확인 — src/index.rs:138-193:

// 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이다.
  • timestamp는 파일명 suffix(*_<unix_ts>.parquet)에서 뽑고, 파싱에 실패하면 mtime으로 대신한다.
  • watch_snapshots(path, poll_interval, index, ready_tx)는 30초(기본)마다 canonicalize(path)를 호출하고, 경로가 바뀌었으면 전부 다시 적재한다. 새 인덱스는 tokio::spawn_blocking에서 build한다. 성공하면 ArcSwapOption<SidIndex>로 atomic swap하고, ready_tx.send(true)로 /readyz를 initial-ready로 올린다.
  • pack_full_key(row_codes)는 len != 6이거나 코드 하나라도 0..=255 범위를 벗어나면 None을 돌려주고, 이때 skipped 카운터가 오른다. 즉 잘못된 SID는 조용히 버려진다.
  • 메트릭 함수 record_index_load(index, elapsed_s)는 sid_index_posts 게이지, sid_index_skipped_posts, sid_index_load_seconds 히스토그램을 함께 발행한다.

seed post의 SID는 로컬 트라이에서 먼저 찾고, 없으면 외부 lookup 서비스에 묻는다 확인 — src/seed_sids.rs:1-114:

  • seed post_id가 로컬 트라이에 있으면 그 트라이 엔트리의 코드(valid_depth=6)를 그대로 쓴다.
  • 없으면 별도 gRPC 서비스 SidLookupService(xai-recsys-sid-proto, endpoint는 --sid-endpoint 플래그)에 batch로 조회한다.
  • SidLookupService는 4세대+15(2026-09-04, 9b0dc31)에서 Phoenix 서빙 엔진 내부 사이드카로 삭제됐던 그 lookup 서비스가, 이번에 별도 외부 서비스로 다시 나타난 셈이다.
  • moka 캐시는 2백만 엔트리, TTL 6시간이다. 응답에서 codes.is_empty()면 Missing으로 분류한다.

service 계층은 seed를 정리한 뒤 출처별로 SID를 찾는다 확인 — src/service.rs:68-112:

  • 요청의 seed_post_ids에서 중복을 없애고 max_seeds(기본 200)까지만 남긴다.
  • 모든 seed에 대해 로컬 트라이에서 lookup(post_id)를 시도하고, 없는 것만 SidLookupService.lookup(missing)으로 batch 호출한다(병렬). 그 뒤 각 seed를 세 경로(Index / SidService / Missing) 중 하나로 분류해 메트릭 라벨로 발행한다.
  • 요청 경로의 메트릭은 5종이다 확인 — src/metrics.rs:35-71.
    • sid_retrieval_requests_total{caller, result} 카운터
    • sid_retrieval_seeds_total{source}·sid_retrieval_sid_service_lookups_total{result} 카운터
    • sid_retrieval_candidates_per_request{caller}·sid_retrieval_request_latency_seconds{caller} 히스토그램

main.rs 플래그의 기본값은 다음과 같다 확인 — src/main.rs:26-52.

  • 포트: --grpc-port=9090, --http-port=8080
  • 스냅샷: --snapshot-path=post_sid_snapshot.parquet, --snapshot-poll-secs=30
  • SID lookup: --sid-endpoint=http://localhost:50051, --sid-timeout-ms=200, --sid-cache-size=2_000_000
  • 요청 상한과 기본값: --max-seeds=200, --default-max-results=800, --default-max-per-seed=100, --default-min-prefix-depth=3, --default-max-prefix-depth=6

B. home-mixer가 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)이고, 모두 신규다.

PopularPostsSource는 다음 순서로 동작한다 확인 — popular_posts_source.rs:68-115:

  1. 소스가 켜지는 조건은 !has_cached_posts && !in_network_only && EnablePopularPostsSource==true다.
  2. PopularAuthorsCache.maybe_spawn_refresh(now_ms)를 호출해 백그라운드 리프레시를 건다. 리프레시는 필요할 때만 돌고, token-held AtomicBool 하나로 중복 실행을 막는다.
  3. top_author_ids(top_k)로 상위 K명(기본 1000)의 author_id를 뽑는다.
  4. 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순으로 돌려준다.
  5. 응답 포스트에는 served_type=ForYouPhoenixRetrieval 라벨을 달아 PostCandidate로 돌려준다. 이 라벨은 PhoenixSource와 같은 라벨이다.

PopularAuthorsCache는 인기 저자 스냅샷을 들고 있다가 주기적으로 갱신한다 확인 — util/popular_authors.rs:135-223:

  • RwLock<Snapshot>, AtomicBool refreshing, Arc<dyn PopularAuthorsStore>로 구성된다.
  • 리프레시 주기는 authors.is_empty() ? 60초 : 60분이다. 빈 상태일 때만 공격적으로 재시도하는 흔한 패턴이다.
  • 스토어의 바이트 포맷은 version=1(1B) + updated_at_ms(8B BE) + count(4B BE) + [author_id(8B BE), follower_count(8B BE)] * count다 확인 — util/popular_authors.rs:64-104. 즉 1,000명이면 13 + 16,000 = 16,013바이트이고, Manhattan의 한 키에 전부 저장된다.
  • sort_by_followers는 follower_count 내림차순으로 정렬한다(동률은 author_id 오름차순).
  • 메트릭은 PopularAuthorsCache.Refresh{result=ok|error} 카운터와 PopularAuthorsCache.LoadedAuthors 게이지다.

파라미터 5개가 새로 생겼다 확인 — home-mixer/params/param.rs:

파라미터 기본값 역할
EnablePopularPostsSource false 소스 자체 켜기
PopularPostsTopAuthors 1000 KV 스토어에서 상위 몇 명을 "가상 following"에 넣을지
PopularPostsMaxResults 500 Thunder 응답 상한
PopularPostsMaxPerAuthor 3 저자 1명당 최대 포스트 수
PopularPostsMaxRepliesRepostsPerAuthor 0 답글/리트윗은 소스에서 완전히 배제

설계상 이 소스는 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 패턴이다 추정.

C. 광고 브랜드 안전 V2 verdict 기본값이 ON으로 바뀌었다. 확인 — home-mixer/params/param.rs:

  • 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 삽입하는 경로다.

핵심 자료구조는 판정 enum과 작성자 fallback enum이다 확인 — home-mixer/models/brand_safety.rs:6-80:

#[repr(i32)]
pub enum BrandSafetyVerdict {
  Unspecified = 0, Safe = 1, LowRisk = 2, MediumRisk = 3, HighRisk = 4,
}
pub enum AuthorBrandSafetyFallback { Sfa, NsfaLimited, Nsfa }

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와 분리된 서버 측 레이턴시 전용 모듈이다. 운영 관측을 넓히는 변경이고, 모델 쪽 변경은 아니다.

G. 그 밖의 작은 변경은 다음과 같다.

  • 공용 트레이너 trainer_recsys.py(+376/-853)에서 어제 분리된 SID retrieval trainer 몫을 걷어 내, 공용 코드가 순수하게 줄었다.
  • 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 신규)로 분리됐다.

의미

이 커밋에서는 세 가지 사실이 겹친다.

  1. 어제 공개된 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 되는 날이 관측 지점이다.

  2. 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가 여전히 기본값이므로, 실제로 켜지는 시점이 다음 창의 관측 지점이다.

  3. 광고 측 변경 두 가지는 모두 "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다.

A. value model에 조건부 기대 체류 시간 항 3개가 생겼다. 확인 — xai-value-model/scoring.rs:61-63, 111-128, 141-158:

fn product(probability: Option<f64>, conditional_value: Option<f64>) -> Option<f64> {
    Some(probability? * conditional_value?)
}
...
apply(product(scores.video_open_score, scores.home_video_continuation_secs), weights.video_continuation),
apply(product(scores.video_open_score, candidate.user_video_continuation_secs), weights.user_video_continuation),
apply(product(scores.profile_click_score, scores.home_profile_visit_secs), weights.profile_visit_secs),
  • Phoenix proto ContinuousActionName에 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 서비스뿐이다.
  • home-mixer가 두 head를 Phoenix 응답에서 꺼내는 것도 EnableHomeExcursionScores(기본 false) 뒤에 있다 확인 — home-mixer/scorers/phoenix_scorer.rs:109-117, 141-153. 학습 쪽 집계 타입 DENSE_WITH_LONG_DWELL_HOME_EXCURSION = 30이 PLACEHOLDER_30 자리에 생겼다 확인 — recsys.proto:1815.
  • "home excursion"은 홈 피드에서 영상·프로필로 나갔다가 머문 시간을 뜻하는 이름으로 보인다 추정. 근거는 head 이름이 모두 HOME_ 접두사이고, 확률 head(video_open, profile_click)와 짝지어 곱한다는 점이다.
  • README의 Phoenix 예측 목록 Attention 줄에서 active seconds가 빠지고 video continuation seconds · profile visit seconds가 들어갔다 확인 — README.md:341.
  • post_unexplored(새-작성자 부스트) 항은 지금까지 in-network 후보에만 붙었다. PostUnexploredIncludeOutOfNetwork(기본 false)를 켜면 OON 후보에도 붙는다 확인 — xai-value-model/scoring.rs:85-90.

B. PopularPostsSource가 요청 시점의 Thunder 조회 대신, 오프라인 잡이 만든 조회수 랭킹 목록을 읽도록 재작성됐다. 확인 — home-mixer/sources/popular_posts_source.rs, home-mixer/util/popular_posts.rs:36-87, home-mixer/popular_posts_job.rs:21-178, home-mixer/util/popular_authors.rs:11, 25-40.

  1. popular_authors_job은 기본 3600초 주기로 돈다. 이 잡은 BigQuery 테이블 twttr-bq-timelines-prod.pulse.popular_posting_authors에서 팔로워 순위 스냅샷을 읽고, 최근 7일 활동 작성자 수 × 0.00005(=상위 0.005%)명을 고른다. 스냅샷이 30시간보다 오래되면 거부한다(--popular-authors-max-snapshot-age-hours=30).
  2. popular_posts_job(기본 300초 주기)이 그 작성자들을 20명씩 묶어 Thunder GetInNetworkPostsRequest로 포스트를 모은다(작성자당 50개, 요청당 최대 1,200개). 답글과 리트윗은 버린다(is_original).
  3. 24시간 이내 포스트에 대해 engagement counts 서비스의 view_count를 500개씩 받는다.
  4. 점수는 quality = views × (1 − e^{−λ·24}) / (1 − e^{−λ·max(age_h, 0.5)})이고, λ = ln2 / 8이다. 조회 속도가 반감기 8시간으로 줄어든다고 가정하고, 지금까지의 조회수를 24시간 누적 조회수로 외삽하는 식이다 추정. 근거는 분모가 "age 시점까지 누적된 비율", 분자가 "24시간까지 누적될 비율"이라는 점이다.
  5. 작성자당 상위 5개, 전체 상위 500개를 남겨 Manhattan에 쓰고, 다시 읽어 generated_at_ms가 같은지 확인한다.
  6. 서빙 소스는 60초마다 목록을 갱신하고, PopularPostsMaxResults(500)만큼 꺼내 seen_ids만 빼고 낸다. 목록 나이는 PopularPostsSource.ListAgeSecs 게이지로 보고한다.

4세대+35의 파라미터 PopularPostsTopAuthors(1000)·PopularPostsMaxPerAuthor(3)·PopularPostsMaxRepliesRepostsPerAuthor(0)는 삭제됐다 확인. 유저별 개인화 입력은 seen_ids 하나뿐이므로 이 소스는 전역 인기 목록이다 확인. 기본값은 여전히 off다.

C. "Under the Hood" 응답이 새로 생겼다. 확인 — home-mixer/util/under_the_hood.rs, home-mixer/scored_posts_server.rs:

  • ScoredPostsQuery.is_under_the_hood_request가 참이면 응답에 파이프라인 트레이스와 포스트별 점수가 실린다.
    • 트레이스에는 단계별 지연, 입력·유지·제거 건수, 필터율이 들어간다.
    • 포스트별 점수에는 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}로 나뉘었다.

E. 그 밖에 값과 설정도 바뀌었다. 확인 — home-mixer/params/param.rs:

  • RetrievalCandidatesKafkaMaxCandidates가 200에서 100,000으로 바뀌었다. retrieval 후보를 Kafka로 기록하는 상한이다. 사실상 상한을 풀어 전체 후보를 로깅하는 쪽으로 바뀌었다 추정.
  • VMRankerClusterId가 "Experiment6"에서 "Experiment1"로 바뀌었다.
  • Phoenix 학습에는 H100용 remat 정책 SAVE_H100_QKV_ATTN_FFN = 34가 추가됐고, num_microbatch > 1과 use_row_emb를 함께 쓰지 못하게 하던 조건이 삭제됐다 확인 — phoenix/xrex/models/remat.py, phoenix/xrex/train/trainer_recsys.py.

의미

  • 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가 최종값일 가능성이 있다 추정. 두 미러가 서로 다른 값을 가진 것은 이번이 처음 관찰된 사례다.

C. 검색 랭킹 negative 샘플의 렉시컬 매치 피처가 교정됐다. 확인 — 신규 phoenix/xrex/data/recsys/negative_word_match.py(51줄), recsys_batch.py:1476-1488:

  • 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로 바뀌었다. 학습 동작 변화는 없는 리팩터링으로 보인다 추정.

E. VF의 NotFound 판정 경로가 정리됐다. 확인 — visibility-filtering/evaluate_tweets.rs, visibility-filtering-client/evaluated.rs: Evaluation::NotFound가 판정 대상에서 빠져 Outcome::Failed로 간다. 클라이언트 쪽은 tweetypie 결과가 Found일 때만 VF 판정으로 덮어쓰고, Filtered·NotFound·Failed는 원래 상태를 유지한다.

의미

  • 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·리플레이 코드다. 이번 절은 피드 동작과 관련된 부분만 다룬다. "심층 분석 필요" 항목은 열린 질문 절에 남겼다.

A. For You에 세로 영상 캐러셀 모듈이 생겼다. 확인 — home-mixer/util/video_carousel.rs, selectors/top_k_score_selector.rs:10-27, filters/video_carousel_filter.rs, params/config.rs:17-19, 35-36:

  • 켜지는 조건은 RequestType::ForYou, EnableVideoCarousel(기본 false), 피로도 게이트 세 가지다. 피로도는 served history에서 마지막 VIDEO_CAROUSEL_MODULE 노출 시각으로부터 VideoCarouselFatigueMinutes(60분)가 지났는지로 판정한다 확인 — query_hydrators/served_history_query_hydrator.rs:52-57, 110-126.
  • 선정은 두 단계로 이뤄진다.
    • 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·프롬프트·프레임은 포스트 랭킹을 쓰지 않는 별도 후보였다.

B. 랭킹의 dwell_time 학습 손실이 CREAD로 바뀌었고, 기본으로 켜져 있다. 확인 — phoenix/xrex/configs/xrecsys.py:272-275, 687-742, models/loss_recsys.py cread_loss_compute, models/recsys_model.py _decode_cread:

  • _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초다.
  • 같은 임계 체계로 히스토리의 체류 시간을 버킷 임베딩으로 입력하는 피처도 기본 on이 됐다 확인 — models/recsys_feature_prep.py:180-184, 885-910, configs/xrecsys.py:301-304.
    • 설정은 enable_dwell_bucket_table=True, 상한 240초, 임계 30개이고, 인접한 두 버킷을 선형 보간한다.
  • CREAD는 Kuaishou가 영상 시청 시간 예측용으로 발표한 방법이다(arXiv 2401.07521, 2024, 논문). X 코드의 이름과 구조(분류와 복원)가 논문과 같다 확인. X 코드는 논문의 핵심인 error-adaptive 구간 설계 대신 고정 로그 간격을 쓴다 확인(cread_log_thresholds).

C. two-tower retrieval에 MoL과 다중 query head 옵션이 생겼다(기본 off). 확인 — phoenix/xrex/models/recsys_two_tower_model.py:523-598, 1629-1665, 2214-2224, phoenix/xrex/cuda/mol_epilogue/__init__.py, configs/xrecsys_two_tower.py:699-743:

  • 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가 공개되지 않았다.

D. 그 밖의 변경

  • topic 피드 요청(topic_ids가 있는 요청)은 author cold-start 승격 대상에서 빠진다 확인 — home-mixer/scorers/author_cold_start.rs:384-386.
  • Phoenix 임베딩에 Semantic ID 앞 2자리·3자리 prefix 조회 테이블 함수가 추가됐다. 3자리는 해시로 행 수를 줄인다 확인 — phoenix/xrex/models/recsys_sid.py:32-79. 이 함수가 실제로 연결됐는지는 확인하지 않았다.
  • 광고 블렌더가 탈락시킨 광고를 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다.

A. Phoenix 랭킹 요청 PredictNextActionsRequest에 요청 맥락 필드 2개가 추가됐다. 확인 — phoenix/python/common/xai-proto/proto/recsys.proto:27-50, 175-176 (서빙 crate 쪽 xai-recsys-proto/proto/recsys.proto도 같다):

  • 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 밖에서 나오지 않는다. 그래서 랭킹 피처로 쓰이는지, 로깅용인지는 아직 판단할 수 없다 추정.

B. H100 홈 랭커 학습 프리셋이 재조정됐다. 확인 — phoenix/xrex/configs/xrecsys.py:324-345, 427-432:

  • home_direct_packed 프리셋은 _home_direct_packed_base()에 _H100_OVERRIDES를 합친 것이다. 이번에 bs_per_device가 256 → 512, ep(expert parallelism)가 256 → 128이 됐고 num_microbatch=2가 추가됐다. 마이크로배치(한 스텝의 배치를 나눠서 순차 계산하고 그래디언트를 합치는 방식) 하나는 256명이다.
  • use_async_emb, seqpack_drop_empty_candidates, overlap_step_host_work가 True가 됐다. remat_policy=SAVE_H100_QKV_ATTN_FFN과 unroll_layer_stack=True는 H100 override에 들어왔다. Muon optimizer에는 muon_ns_shard_group_size=8이 추가됐다.
  • 학습률(7.1e-4)과 checkpoint_every_n(150)은 그대로다. GB300 프리셋(bs_per_device 512, ep 64)은 바뀌지 않았다.

C. 빈 후보 슬롯을 지우는 시퀀스 패킹이 들어왔다. 확인 — phoenix/xrex/data/recsys/sequence_packing.py:171-203, 579-754, phoenix/xrex/train/trainer_recsys.py:172-195, 291-292, 404-470, 2033-2060:

  • 기존 패킹은 유저마다 후보 슬롯 수만큼 토큰 자리를 고정으로 잡았다. 새 레이아웃은 유저별로 실제 채워진 후보만 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)만 본다.
  • 패딩 토큰이 줄어든 만큼 attention·FFN 보조 손실과 활성 L2 손실의 요약값에 토큰 수 비율을 곱해 기존 레이아웃과 같은 스케일로 맞춘다 확인 — phoenix/xrex/models/transformer.py:761-781.

D. 그 밖의 변경

의미

  • 랭킹 모델이 "이 요청이 새로고침인지, 아래로 스크롤한 것인지"를 받을 수 있는 통로가 생겼다 확인. 같은 유저라도 앱을 막 연 순간과 피드 깊이 스크롤한 순간에 반응 확률이 다르므로, 다음 단계로 이 값을 맥락 피처로 쓸 가능성이 있다 추정. 필드를 추론 요청 메시지에 추가했다는 것 외의 근거는 없다.
  • 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다.

A. 홈믹서 크론 미러의 가중치 3개가 바뀌었다. 확인 — home-mixer/params/param.rs:320, 341-346, 401-406:

파라미터 이전 이후
FavoriteWeight 0.5 0.6
VideoOpenWeight 0.07 0.02
ContDwellTimeWeight 0.004 0.006
  • 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다.

B. two-tower retrieval에 체류 기반 세 번째 헤드 ld가 생겼다. 확인 — phoenix/xrex/configs/xrecsys_two_tower.py:251-300, phoenix/xrex/models/recsys_two_tower_model.py:93-120, 1916-1920, 2385-2412, 2527-2541:

  • _xrecsys_two_tower_combined_base()의 후보 헤드가 2개(home, immersive)에서 3개(home, immersive, ld)가 됐다. ld 헤드의 positive 행동은 CLIENT_TWEET_RECAP_DWELLED 하나이고, negative는 home과 같은 목록을 쓴다.
  • 서빙 점수는 w_fav × 〈u, home 임베딩〉 + w_ld × 〈u, ld 임베딩〉이다. combined 프리셋은 w_fav=1.0, w_ld=0.75다.
    • 포스트 임베딩 테이블의 폭이 두 배가 되어, 한 행에 home 슬롯과 ld 슬롯이 나란히 들어간다(num_table_slots=2).
    • 유저 벡터 하나로 두 슬롯을 각각 채점해 가중합한다(score_over_slots). 내적, MoL, int8 경로 모두 같은 방식이다.
  • home 헤드의 positive는 4세대+32 이후 SERVER_TWEET_FAV 하나였다. 이번 변경으로 combined 프리셋의 홈 retrieval 목적이 "좋아요"에서 "좋아요 + 0.75 × 체류"로 넓어졌다 확인(config 값).
  • ld는 long dwell의 약자로 보인다 추정. positive 행동 이름이 RECAP_DWELLED이고, "Recap"은 Twitter가 랭크드 홈 타임라인을 부르던 내부 이름이다. 체류 판정 임계는 공개 코드에 없다.
  • 같은 base에서 enable_user_installed_apps가 True → False가 됐다 확인(259행). 유저가 설치한 앱 목록을 user tower 피처로 쓰지 않게 됐다. immersive 전용 base(344행)는 여전히 True다.
  • xrecsys_two_tower_combined가 프로덕션 홈 retrieval 프리셋인지는 공개 코드로 확인할 수 없다 추정. 4세대+32에서 공개된 6개 프리셋 중 home·immersive를 함께 학습하는 유일한 프리셋이라는 점이 근거다.

C. 대화 화면 답글 랭커 reply_two_tower_v2가 신설됐다. 확인 — phoenix/xrex/configs/xrecsys_reply_two_tower_v2.py(79줄), phoenix/xrex/models/recsys_reply_two_tower_v2_model.py(1,077줄), phoenix/crates/common/xai-recsys/src/reply.rs(680줄):

  • 이 모델은 포스트 하나(focal post)의 답글 후보 최대 64개를 유저별로 채점한다. 서빙 등록 파일은 이 모델을 "reply-ranker"라고 부른다 확인 — phoenix/xrex/inference/reply_services.py.
  • 규모는 홈 랭커보다 훨씬 작다.
    • Transformer 1층, emb_size=256, 히스토리 127, vocab 유저·아이템 각 2천만, 작성자 5백만, SID 16단계다.
    • 손실은 행동 multi-hot에 negative feedback 가중 10.0, 체류 시간 Tweedie(가중 0.02)다.
  • 홈 Phoenix와 달리 손으로 만든 피처를 많이 쓴다.
    • 답글 쪽 피처로는 그 답글의 좋아요·답글·리포스트·인용·조회 수와 작성자 팔로워 수·인증 여부가 들어간다.
    • 원글 작성자가 좋아요를 눌렀는지, 원글 작성자가 답했는지, 자기 답글인지, 답글 영역(section), 누적 리포트·차단·뮤트 수도 들어간다.
    • grox 답글 점수(grox_score, 1001 오프셋을 25 단위 26버킷으로 나눈 값)도 들어간다.
    • focal post 쪽에는 좋아요·답글·조회 수, 작성자 팔로워 수, 뷰어가 원글 작성자인지가 들어간다.
  • grox 점수는 이 문서의 "Grox" 절이 다루는 ReplyRankingTaskGenerator가 Grok으로 매기는 답글 랭킹 점수로 보인다 추정. 이름(grox_score)과 1001 오프셋 인코딩 외에는 두 코드를 잇는 연결이 이번 diff에 없다.
  • home-mixer에서 이 랭커를 부르는 코드는 없다 확인. 이번 diff의 home-mixer 변경 파일 17개에 reply 랭커 호출이 없다. 대화 화면은 For You 피드와 다른 서비스가 처리하는 것으로 본다 추정.

D. value model에 react_with_video 항이 생겼다(가중 0). 확인 — xai-value-model/scoring.rs:136, home-mixer/params/param.rs:395-400, vm-ranker/params.rs:102-107:

  • 행동 SERVER_TWEET_REACT_WITH_VIDEO가 proto ActionName 44번에 들어왔다. 이 자리는 원래 CLIENT_CARD_CLICK이었고 이번에 이름이 바뀌었다 확인 — recsys.proto:449.
  • value model 가중합에 react_with_video_score × ReactWithVideoWeight 항이 추가됐고, 이 항은 양수 가중 합계에도 들어간다. 두 미러 모두 기본값은 0.0이다.
  • 이름으로 보면 포스트에 영상 답글을 다는 행동이지만, "영상으로 반응하기"가 어떤 제품 기능인지는 코드에 설명이 없다 추정.

E. 그 밖의 피드 관련 변경

  • 영상 캐러셀 선정에 dedup 두 가지가 추가됐다 확인 — home-mixer/util/video_carousel.rs, param.rs:1071-1082.
    • EnableVideoCarouselAuthorDedupe(기본 false)는 원작성자당 1개만 남긴다.
    • VideoCarouselSemanticIdDedupeLevels(기본 0)는 SID 앞 N자리가 같은 영상을 1개만 남긴다.
    • 걸러진 자리는 아래 순위로 채우고, 결과가 3개 미만이면 캐러셀을 만들지 않는다.
  • 영상 제외 요청(exclude_videos)에서는 인용한 포스트가 영상인 경우와 대화 조상 포스트에 영상이 있는 경우도 걸러진다 확인 — filters/video_filter.rs, 신규 filters/ancestor_video_filter.rs·candidate_hydrators/ancestor_video_hydrator.rs. 이전에는 후보 자체의 영상만 봤다.
  • OON 답글을 허용할 때 루트 포스트에 직접 단 답글만 허용하는 PhoenixOonRepliesRootOnly(기본 false)가 생겼다 확인 — filters/oon_retweet_reply_filter.rs, param.rs:79-84. 상위 스위치 EnablePhoenixOonReplies도 여전히 false다.
  • 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의 신규 파일 줄 번호와 같다.

A. 페이지 단위 RL 재순위기 page_rl의 학습 코드가 생겼다. 확인 — phoenix/xrex/models/page_rl.py(403줄), page_decoder.py(592줄), page_critic.py(211줄), configs/xrecsys.py:388-417, 477-483:

  • 학습 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.
  • 디코더가 각 단계에서 매기는 logit은 세 항의 합이다 확인 — page_decoder.py:85-119, 233-247.
    • "지금까지 고른 아이템을 attention한 상태 × 후보 key"
    • 표준화한 behavior score(score_anchor_alpha=1.0)
    • 이미 고른 아이템과 작성자가 같은지·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.
  • actor 손실은 세 항이고, 여기에 로그된 순서를 따라 하는 behavior cloning 손실(가중 0.25)이 더해진다 확인 — page_decoder.py:37-48, 495-592, page_rl.py:80-88.
    • 첫째 항은 critic Q 기반 advantage다. 지원은 behavior 점수 최고값과 0.6 이내인 후보로 제한한다.
    • 둘째 항은 behavior 정책(점수 softmax, τ=0.2)으로의 KL 고삐다. 목표는 0.35이고, β를 0.1~3.0 사이에서 dual 변수로 자동 조정한다.
    • 셋째 항은 같은 작성자·유사 SID 연속 배치 페널티(가중 0.5)다.
  • 서빙 디코딩은 temperature가 0이면 beam 4개를 만든 뒤 critic 가치 합이 가장 큰 beam을 고르고, 0보다 크면 Gumbel 샘플링을 쓴다 확인 — page_decoder.py:427-477.
  • behavior score에는 홈믹서 미러와 다른 별도 가중치가 들어 있다 확인 — page_rl.py:62-77, 286-337.
    • favorite 0.5, reply 2.0, retweet 1.0, click 0.4, profile_click 0.5, VQV 0.5(10초 넘는 영상만), share 40.0, quote 5.0, cont_dwell_time 0.1, not_interested −500, report −1000, not_dwelled −1.2
    • 이 값이 서빙 기본값인지, 학습용 기본값일 뿐인지는 알 수 없다 추정. 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에 걸린 점이 근거다.

B. 홈믹서 크론 미러 가중치 2개가 바뀌었다. 확인 — home-mixer/params/param.rs:320, 414-419:

파라미터 이전 이후
FavoriteWeight 0.6 0.66
NotInterestedWeight −47.52 −52.272
  • 두 값 모두 정확히 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다.
  • VM-Ranker에서 topic 요청은 DPP 다양성 재순위를 기본으로 건너뛴다 확인 — vm-ranker/scoring/mod.rs:85-87, vm-ranker/params.rs:241-246. 새 스위치 TopicDppEnabled의 기본값이 false다. 주제 피드는 이미 한 주제로 모인 결과라서 의미 다양화가 맞지 않는다고 본 것으로 추정한다 추정.
  • 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이다.

A. 영상 캐러셀의 선정 점수가 video_open 확률과 immersive VQV 예측의 가중합이 됐다 확인 — home-mixer/util/video_carousel.rs:17-21, 104-123, params/param.rs:1083-1106.

  • 캐러셀 영상의 정렬 점수가 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행).
    • ImmersiveVideoScorer는 캐러셀 대상 영상만 모아 예측을 요청한다 확인 — scorers/immersive_video_scorer.rs(신규 65줄), util/phoenix_request.rs:228-252.
  • 스코어러 순서는 PhoenixScorer → ImmersiveVideoScorer → VMRanker다 확인 — candidate_pipeline/phoenix_candidate_pipeline.rs:462-471.
  • immersive 예측은 immersive_scores 필드에만 저장되고, For You 본 피드 점수에는 들어가지 않는다 확인.
    • weighted_score·VMRanker가 이 필드를 읽는 코드는 diff에 없다.
  • 같은 가중합이 캐러셀 선정의 두 단계에 모두 적용된다 확인 — selectors/top_k_score_selector.rs:16-25, filters/video_carousel_filter.rs.
    • 상위 50개 밖에서 캐러셀 후보 30개를 고르는 TopKScoreSelector 단계
    • 필터 이후 최종 3~5개를 고르는 VideoCarouselFilter 단계
  • 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 변경은 없다는 점이다.
  • VF 규칙 정의가 평면 조건 배열에서 불리언 트리로 바뀌었다. 다섯 SafetyLevel의 규칙 목록·순서·액션·국가 목록은 그대로다 확인 — visibility-filtering/rules/levels.rs, rules/rule_spec/rule.rs(신규 305줄).
    • 예전 형식은 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 두 군데다 확인(코드).
    1. 뷰어 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)와 조회 범위를 맞추는 변경으로 본다 추정.
    2. 찾은 뷰어에 나이 정보가 없으면 이제 상태와 상관없이 NotStated로 디코드한다 확인 — hydration/decode/viewer.rs:76-79.
  • 두 변경이 겹치면 이전에 조회되지 않던 상태의 뷰어가 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이라 서빙 여부는 알 수 없다.

4세대+43 (2026-10-16, bc3948b) — PopularPostsSource 라벨 분리(ForYouPopularPosts) + page_rl behavior score 가중치 변경(VQV 0.5 → 1.0, share 40 → 4)과 요청별 가중치 override + 캐러셀 임베딩 dedup(기본 off) + VF 보조 조회 best-effort화 + brand engagement 라벨

이 커밋으로 인기 포스트 소스의 후보가 자체 라벨을 달게 됐고, 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다.

A. 인기 포스트 소스의 라벨이 Phoenix retrieval에서 분리됐다. 확인 — home-mixer/sources/popular_posts_source.rs:13

  • PopularPostsSource의 served type이 ForYouPhoenixRetrieval → ForYouPopularPosts가 됐다 확인.
  • 2026-10-03 커밋(4세대+35)에서 "Phoenix retrieval과 구분되지 않게 섞인다"고 적은 설계가 끝났다 확인.
  • served type을 읽는 하위 단계(메트릭, served history)가 이제 두 소스를 구분한다 추정. 소비 코드는 확인하지 않았다.

B. page_rl behavior score의 기본 가중치가 바뀌고, 요청마다 가중치를 덮어쓸 수 있게 됐다. 확인 — phoenix/xrex/models/page_rl.py:69-70, 80-94, 319-324, recsys.proto:198-214:

-    vqv_weight: float = 0.5
-    share_weight: float = 40.0
+    vqv_weight: float = 1.0
+    share_weight: float = 4.0
  • 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로 바뀌었다(컴파일 최적화, 의미는 같다).

C. 영상 캐러셀에 임베딩 dedup이 기본 off로 추가됐다. 확인 — util/video_carousel.rs:29, 72-105, params/param.rs:1083-1094, clients/post_v8_search_embedding_client.rs:12-17:

  • 캐러셀 점수 순으로 영상을 고르면서, 이미 고른 영상과 임베딩 코사인 유사도가 0.72 이상이면 건너뛴다.
    • 인용 포스트와 임베딩이 없는 영상은 비교하지 않는다.
    • 검사 순서는 작성자 → SID 앞자리 → 임베딩이다.
  • 임베딩은 Manhattan의 tweet_multimodal_embeddings(버전 v8_search_1, 1024차원, 타임아웃 100ms, 실패 시 dedup 없음)에서 읽는다. home-mixer 서빙 코드가 grox의 v8 search 멀티모달 포스트 임베딩을 읽는 첫 코드이며, 용도는 retrieval이 아니라 중복 제거다 확인.
  • 2026-10-17 커밋(4세대+44)에서 기본 on으로 바뀌었다.

D. visibility-filtering의 보조 조회가 best-effort가 됐다(동작 변경). 확인 — visibility-filtering/hydration/plan.rs:375-409, hydration/mod.rs:30, rules/registry.rs:167-175:

  • 팔로우 관계, 작성자·대화 루트의 차단 관계, 대화 제어, 뷰어 국가 등 13개 조회가 best-effort로 분류되어 300ms 상한(일반 조회는 1초)을 받는다.
  • 이 조회가 실패해도 판정은 Partial이 아니라 Complete이고, 규칙에는 기본값이 들어간다.
    • 판정이 Partial이 되는 것은 포스트·안전 라벨·뷰어·차단·뮤트 같은 필수 조회가 실패했을 때뿐이다.
  • 테스트(rules/rule_spec/laws.rs:262-293)가 best-effort 조회를 읽는 규칙 중 허용 목록을 명시한다.
    • 예를 들어 팔로우 조회가 실패하면 실제 팔로워에게도 비공개 계정(protected_author)의 포스트가 드롭된다는 점을 받아들인 것으로 적혀 있다.

E. 그 밖의 변경

  • 학습 라벨 IsBrandEngagement가 생겼다. positive 행동은 좋아요·답글·리트윗·작성자 팔로우·클릭·링크 열기·프로필 클릭·사진 확대·캐러셀 넘기기(앞·뒤)의 10개이고, 영상 시청 이정표와 체류는 포함하지 않는다 확인 — phoenix/crates/serving/xai-recsys-proto/src/brand_engagement.rs:5-19.
    • 이 diff에서 쓰는 헤드나 손실은 없다.
    • 광고의 브랜드 목표용 라벨로 본다 추정. 근거는 이름뿐이다.
  • 답글 retrieval 인덱스 이벤트(1fav_reply·32fav_reply)에 부모 포스트 ID(inReplyToPostId)가 붙었다 — phoenixRankAllCandidateProcessor.strato:283, 295.
  • two-tower 학습에서 retrieval 포스트 코퍼스를 별도 프로세스가 미리 적재하는 옵션(retrieval_staged_corpus=False)과, 서빙 피처 추출에서 타입이 맞지 않는 컬럼을 panic 대신 0으로 채우고 세는 처리가 추가됐다 — trainer_recsys.py:420-423, xai-recsys/src/util.rs:115-126.

의미

  • ⚠ 주장 변경: 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를 줄 단위로 읽지는 않았다.

A. 영상 캐러셀의 중복 제거 3종이 기본 on이 됐다. 확인 — home-mixer/params/param.rs:1095-1112

  • 세 파라미터는 EnableVideoCarouselAuthorDedupe false → true, VideoCarouselSemanticIdDedupeLevels 0 → 3, EnableVideoCarouselEmbeddingDedupe false → true로 바뀌었다 확인.
  • 앞의 두 개는 2026-10-13 커밋(4세대+40)에서, 임베딩 dedup은 2026-10-16 커밋(4세대+43)에서 기본 off로 생겼다.
  • 캐러셀 자체는 여전히 기본 off이므로 캐러셀이 켜진 트래픽에서만 의미가 있다.

B. 같은 응답에 Who-to-Follow 모듈이 있으면 캐러셀을 넣지 않는다. 확인 — home-mixer/selectors/blender_selector.rs:50, 156-161:

fn drop_carousel_if_who_to_follow_present(
    video_carousel: Option<VideoCarouselModule>,
    wtf_modules: &[WhoToFollowModule],
) -> Option<VideoCarouselModule> {
    video_carousel.filter(|_| wtf_modules.is_empty())
}
  • 파라미터가 없는 무조건 규칙이라 한 응답에는 WTF 모듈과 영상 캐러셀 중 하나만 들어간다.
  • 2026-10-16 커밋(4세대+43)에서 붙인 캐러셀 헤더 아이콘(VIDEO_PLAYER_LIST)은 다시 None이 됐다 — util/urt/video_carousel_marshaller.rs:88.

C. 포스트 참여자 목록 피처 PostEngagers가 생겼다(기본 off, 섀도 트래픽은 항상 on). 확인 — candidate_hydrators/post_engagers_hydrator.rs:43-45, util/post_engagers.rs:7-8, 48-73, params/param.rs:785-790:

  • 후보 포스트마다 그 포스트에 참여한 유저 ID 목록을 Redis 캐시(post-engagers-cache, 응답 타임아웃 200ms)에서 받아 랭킹 요청의 새 필드 TweetInfo.postEngagers = 39에 넣는다.

    • 항목에는 유저 ID, 행동 비트 actionBits, 참여 후 경과 초 ageSecs가 있다.
    • positive 최대 32명과 negative 최대 8명을 담는다.
  • 뷰어 자신과 서빙 시각 이후의 참여는 뺀다. 학습 시점 누수를 막으려는 장치다 추정. 근거는 engaged_at_secs <= serve_secs 조건과, 실험 클러스터 요청이 후보의 last_scored_at_ms를 서빙 시각으로 쓴다는 점이다.

    (query.params.get(EnablePostEngagers) || query.is_shadow_traffic) && !query.has_cached_posts
    
  • 요청 캐시에 저장할 때는 이 필드를 지운다 — side_effects/phoenix_request_cache_side_effect.rs:95-98. 이 diff에서 Phoenix 모델이 이 필드를 읽는 코드는 없다 확인.

  • 섀도 트래픽에서 opt-in 클러스터(예: Experiment18Fou)는 명시적 override가 없으면 0%를 받게 됐다 — util/shadow.rs:9-29.

D. ZeroFav 채널의 양면 실험 필터 ZeroFavCodivertFilter가 기본 off로 생겼다. 확인 — filters/zero_fav_codivert_filter.rs:44-80, 105-110, params/param.rs:230-247

  • 뷰어가 treatment이면, 2026-10-13 커밋(4세대+40)의 ZeroFav retrieval 채널(ForYouPhoenixRetrievalZeroFav)로만 들어온 후보는 작성자도 treatment 버킷일 때만 남긴다 확인.
  • 필터 순서는 DropDuplicatesFilter 바로 뒤다 확인.
  • 뷰어와 작성자를 함께 나누어, ZeroFav 채널 노출이 작성자 쪽에 미치는 효과를 재려는 실험으로 본다 추정. 근거는 작성자 treatment·control 두 버킷을 따로 세는 카운터와 codivert라는 이름이다.

E. Phoenix 학습 코드에도 변경이 있었다. 확인 — phoenix/xrex/train/trainer_recsys.py:2349-2356, models/recsys_two_tower_model.py:556-618:

  • two-tower retrieval이 num_microbatch > 1을 지원한다.
    • 후보 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). 섀도 트래픽에서 항상 켜 두었으므로 모델 실험이 진행 중이라고 본다 추정.

↑ 목차